diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f2cb82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.bak +temp + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..15a15b2 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a2e120d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..157826a --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ + Copyright 2021 National Technology & Engineering Solutions of Sandia, LLC (NTESS). + Under the terms of Contract DE-NA0003525 with NTESS, the U.S. Government retains + certain rights in this software. + + 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. diff --git a/Lib/svpdnp3/__init__.py b/Lib/svpdnp3/__init__.py new file mode 100644 index 0000000..e365e0a --- /dev/null +++ b/Lib/svpdnp3/__init__.py @@ -0,0 +1,2 @@ + +__version__ = '0.9.4' diff --git a/Lib/svpdnp3/device_der_dnp3.py b/Lib/svpdnp3/device_der_dnp3.py new file mode 100644 index 0000000..8bc7feb --- /dev/null +++ b/Lib/svpdnp3/device_der_dnp3.py @@ -0,0 +1,136 @@ +''' This code sits in the svpdnp3 lib of the SVP Directory. + The script defines different methods that can be called by the + SVP scripts to send requests to the DNP3 Agent. +''' + +import socket +import json +import logging +import sys +import subprocess +import os +from os import path + +''' agent API definitions ''' + +STXB = b'\x02' +ETXB = b'\x03' +STX = STXB[0] +ETX = ETXB[0] + +OP_READ = 'read' +OP_WRITE = 'write' +OP_STATUS = 'status' +OP_ADD = 'add' +OP_SCAN = 'scan' +OP_DEL = 'delete' +OP_STOP = 'stop' + +stdout_stream = logging.StreamHandler(sys.stdout) +stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) + +_log = logging.getLogger(__name__) +_log.addHandler(stdout_stream) +_log.setLevel(logging.DEBUG) + +class AgentClient(): + + ''' This class creates a TCP Client which sends requests + to the TCP server in the DNP3 Agent. The request is a + JSON encoded object of the format: + + request_body = {'oid': oid, + 'op': op, + 'rid': rid, + 'params': params} + ''' + + def __init__(self, ip_addr=None, ip_port=None): + self.ip_addr = ip_addr + self.ip_port = ip_port + self.socket = None + + def connect(self, ip_addr=None, ip_port=None): + if ip_addr is not None: + self.ip_addr = ip_addr + if ip_port is not None: + self.ip_port = ip_port + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((self.ip_addr, self.ip_port)) + + def status(self, rid=None): + ''' Request to return the status for agent and each active outstation ''' + + resp = self.request(op=OP_STATUS, rid=rid) + + return resp + + def stop_agent(self, rid=None): + ''' Request to stop the agent from listening further requests ''' + + resp = self.request(op=OP_STOP, rid=rid) + + return resp + + def add_outstation(self, ipaddr=None, ipport=None, outstation_addr=None, master_addr=None, scan_time=None): + ''' Request to add an outstation with the given configuration ''' + + params = {'ipaddr': ipaddr, + 'ipport': ipport, + 'outstation_addr': outstation_addr, + 'master_addr': master_addr, + 'scan_time': scan_time} + resp = self.request(op=OP_ADD, params=params) + + return resp + + def read_outstation(self, oid, rid=None, points=None): + ''' Request to read the points from an outstation with the given oid ''' + + params = {'points': points} + resp = self.request(oid=oid, op=OP_READ, rid=rid, params=params) + + return resp + + def write_outstation(self, oid, rid=None, points=None): + ''' Request to write data points of the outstation with the given oid ''' + + params = {'points': points} + resp = self.request(oid=oid, op=OP_WRITE, rid=rid, params=params) + + return resp + + def scan_outstation(self, oid, rid=None, scan_type=None): + ''' Request to perform a specific scan on an outstation with the given oid ''' + + params = {'scan_type': scan_type} + resp = self.request(oid=oid, op=OP_SCAN, rid=rid, params=params) + + return resp + + def delete_outstation(self, oid, rid=None): + ''' Request to delete the outstation with the given oid ''' + + params = {} + resp = self.request(oid=oid, op=OP_DEL, rid=rid, params=params) + + return resp + + def request(self, oid=None, op=OP_READ, rid=None, params=None): + ''' This method creates the request and sends it to the agent ''' + + req = {'oid': oid, + 'op': op, + 'rid': rid, + 'params': params} + + req_msg = b''.join([STXB, json.dumps(req).encode(), ETXB]) + + if self.socket: + self.socket.send(req_msg) + + data = self.socket.recv(32768) + print('%s: received "%s"' % (self.socket.getsockname(), data)) + + return data \ No newline at end of file diff --git a/Lib/svpdnp3/dnp3_agent.exe b/Lib/svpdnp3/dnp3_agent.exe new file mode 100644 index 0000000..b88ecd2 Binary files /dev/null and b/Lib/svpdnp3/dnp3_agent.exe differ diff --git a/Lib/svpelab/EPRIserver/standalone_der_epri_pvsim.py b/Lib/svpelab/EPRIserver/standalone_der_epri_pvsim.py new file mode 100644 index 0000000..39bd6eb --- /dev/null +++ b/Lib/svpelab/EPRIserver/standalone_der_epri_pvsim.py @@ -0,0 +1,243 @@ +""" +Copyright (c) 2018, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import http.client +import json +import requests +import http.server +import socketserver + + +def client_tests(): + + # Client tests + headers = {'Content-type': 'application/json'} + + comm_start_cmd = { + "namespace": "comms", + "function": "startCommunication", + "requestId": "requestId", + "parameters": { + "deviceIds": ['03ac0d62-2d29-49ad-915e-15b9fbd46d86',] + } + } + + response = requests.post('http://localhost:8000', json=comm_start_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + pf_cmd = {"namespace": "der", + "function": "configurePowerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": ["03ac0d62-2d29-49ad-915e-15b9fbd46d86"], + "timeWindow": 0, + "reversionTimeout": 0, + "rampTime": 0, + "powerFactor": 0.85, + "varAction": "reverseProducingVars" + } + } + + print('Setting new PF...') + response = requests.post('http://localhost:8000', json=pf_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + pf_enable_cmd = {"namespace": "der", + "function": "powerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": ["03ac0d62-2d29-49ad-915e-15b9fbd46d86"], + "enable": True + } + } + + print('Enabling new PF...') + response = requests.post('http://localhost:8000', json=pf_enable_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + +if __name__ == "__main__": + + from http.server import BaseHTTPRequestHandler, HTTPServer + PORT_NUMBER = 8081 + + comm_start_cmd = { + "namespace": "comms", + "function": "startCommunication", + "requestId": "requestId", + "parameters": { + "deviceIds": ['03ac0d62-2d29-49ad-915e-15b9fbd46d86', '22261658-4c34-41ec-ab51-6a794bb47d37', + 'a3bbf028-ff09-4185-95ea-4c6dfea23d8c'] + } + } + + response = requests.post('http://10.1.2.2:8000', json=comm_start_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + server_values = {'03ac0d62-2d29-49ad-915e-15b9fbd46d86': {'Watts': None, 'Vars': None, 'SOC': None, 'W_set': None, + 'W_discharge': None, 'VV_V': None, 'VV_Q': None, + 'F': None, 'VphAN': None, 'PF': None, 'W_DC': None}, + '22261658-4c34-41ec-ab51-6a794bb47d37': {'Watts': None, 'Vars': None, 'SOC': None, 'W_set': None, + 'W_discharge': None, 'VV_V': None, 'VV_Q': None, + 'F': None, 'VphAN': None, 'PF': None, 'W_DC': None}, + 'a3bbf028-ff09-4185-95ea-4c6dfea23d8c': {'Watts': None, 'Vars': None, 'SOC': None, 'W_set': None, + 'W_discharge': None, 'VV_V': None, 'VV_Q': None, + 'F': None, 'VphAN': None, 'PF': None, 'W_DC': None}} + + class myHandler(BaseHTTPRequestHandler): + + # Handler for the GET requests + def do_GET(self): + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(server_values)) + # print 'Get complete' + return + + def log_message(self, format, *args): + return + + # Handler for the POST requests + def do_POST(self): + request_headers = self.headers + content_length = request_headers.getheaders('content-length') + length = int(content_length[0]) if content_length else 0 + + data = self.rfile.read(length) + data_dict = json.loads(data) + # print(data_dict) + + try: + inverter_id = data_dict['parameters']['deviceId'] + + # Get power + try: + if data_dict['parameters']['dataPointId'] == '5f2c5fa3-de91-4a61-9856-efbc5067ab29': + server_values[inverter_id]['Watts'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get reactive power + try: + if data_dict['parameters']['dataPointId'] == 'c11f25be-8f4c-460d-9e9e-e862eea0e7c4': + server_values[inverter_id]['Vars'] = data_dict['parameters']['value'] + # if inverter_id == '03ac0d62-2d29-49ad-915e-15b9fbd46d86': + # print('Inverter %s has %s VAr' % (inverter_id, server_values[inverter_id]['Vars'])) + except Exception as e: + print(('Error: %s' % e)) + + # Get charge level + try: + if data_dict['parameters']['dataPointId'] == '038ddc37-6e3d-4ab3-9ae8-23e88f196841': + server_values[inverter_id]['SOC'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get active power setpoint + try: + if data_dict['parameters']['dataPointId'] == '6d990f64-a07d-4d5d-990f-c0a416fd574a': + server_values[inverter_id]['W_set'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get discharge setpoint + try: + if data_dict['parameters']['dataPointId'] == '9da0712c-a979-4d2a-8412-99057d275c39': + server_values[inverter_id]['W_discharge'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get VV V points + try: + if data_dict['parameters']['dataPointId'] == '26258083-ae18-479c-8461-51f8cf218b94': + server_values[inverter_id]['VV_V'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get VV Q points + try: + if data_dict['parameters']['dataPointId'] == '09b4c134-a96c-4177-88a8-3baa634a86ec': + server_values[inverter_id]['VV_Q'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get frequency + try: + if data_dict['parameters']['dataPointId'] == 'f5cc27dd-df4a-4c8f-960c-9c8a1c73dbe6': + server_values[inverter_id]['F'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get Voltage A-N + try: + if data_dict['parameters']['dataPointId'] == '508efa72-c054-4995-a411-5b9d306f727b': + server_values[inverter_id]['VphAN'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get PF + try: + if data_dict['parameters']['dataPointId'] == '4096bbda-23d3-4f8e-8625-d800266ceba0': + server_values[inverter_id]['PF'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + # Get DC Power + try: + if data_dict['parameters']['dataPointId'] == 'c23b52fd-8dce-4a46-9480-61705534496a': + server_values[inverter_id]['W_DC'] = data_dict['parameters']['value'] + except Exception as e: + print(('Error: %s' % e)) + + except Exception as e: + print(('No inverter ID: %s....Data Dictionary: %s' % (e, data_dict))) + + self.send_response(200) + self.end_headers() + return + + try: + # Create a web server and define the handler to manage the incoming request + server = HTTPServer(('', PORT_NUMBER), myHandler) + print('Started httpserver on port ', PORT_NUMBER) + + # Wait forever for incoming http requests + server.serve_forever() + + print('THE SERVER DIED') + + except KeyboardInterrupt: + print('^C received, shutting down the web server') + server.socket.close() + diff --git a/Lib/svpelab/battsim.py b/Lib/svpelab/battsim.py index 7ba4d40..e7ff278 100644 --- a/Lib/svpelab/battsim.py +++ b/Lib/svpelab/battsim.py @@ -57,7 +57,7 @@ def params(info, id=None, label='Battery Simulator', group_name=None, active=Non info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) info.param(name('auto_config'), label='Configure battery simulator at beginning of test', default='Disabled', values=['Enabled', 'Disabled']) - for mode, m in battsim_modules.iteritems(): + for mode, m in battsim_modules.items(): m.params(info, group_name=group_name) BATTSIM_DEFAULT_ID = 'battsim' @@ -164,7 +164,7 @@ def battsim_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] raise BattSimError('Error scanning module %s: %s' % (module_name, str(e))) diff --git a/Lib/svpelab/battsim_dc_load.py b/Lib/svpelab/battsim_dc_load.py index f10233d..126295b 100644 --- a/Lib/svpelab/battsim_dc_load.py +++ b/Lib/svpelab/battsim_dc_load.py @@ -31,9 +31,9 @@ """ import os -import dcsim -import loadsim -import battsim +from . import dcsim +from . import loadsim +from . import battsim # This drive creates a battery simulator from a dc electronic load and dc power supply diff --git a/Lib/svpelab/battsim_manual.py b/Lib/svpelab/battsim_manual.py index 44ecf3c..af25e67 100644 --- a/Lib/svpelab/battsim_manual.py +++ b/Lib/svpelab/battsim_manual.py @@ -31,7 +31,7 @@ """ import os -import battsim +from . import battsim manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/battsim_nhr.py b/Lib/svpelab/battsim_nhr.py new file mode 100644 index 0000000..2605f7c --- /dev/null +++ b/Lib/svpelab/battsim_nhr.py @@ -0,0 +1,168 @@ +""" +Driver for the SCPI interface for NH Research, Inc. 9200 and 9300 Battery Simulators +""" + +import os +from svpelab import device_battsim_nhr as batt +from . import battsim + +nhr_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'NHR' +} + +def battsim_info(): + return nhr_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = nhr_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('ipaddr'), label='IP Address', default='10.1.2.181') + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=64.0) + info.param(pname('overcurrent'), label='Overcurrent Protection Level (A)', default=150.0) + info.param(pname('max_power'), label='Maximum Power (W)', default=8000.0) + + info.param(pname('voltage'), label='Voltage (V)', default=54.0) + info.param(pname('current'), label='Current (A)', default=150.0) + info.param(pname('power'), label='Power (W)', default=8000.0) + info.param(pname('resistance'), label='Resistance (Ohm)', default=0.005) + +GROUP_NAME = 'nhr' + + +class BattSim(battsim.BattSim): + + def __init__(self, ts, group_name, support_interfaces=None): + # todo: add support_interfaces like PVSim + battsim.BattSim.__init__(self, ts, group_name) + + self.ts = ts + self.pmod = None + self.support_interfaces = support_interfaces + + try: + self.ipaddr = self._param_value('ipaddr') + self.overvoltage = self._param_value('overvoltage') + self.overcurrent = self._param_value('overcurrent') + self.max_power = self._param_value('max_power') + self.voltage = self._param_value('voltage') + self.current = self._param_value('current') + self.power = self._param_value('power') + self.resistance = self._param_value('resistance') + + self.pmod = batt.NHResearch(ipaddr=self.ipaddr) + + self.pmod.set_battery_detect_voltage(bd_voltage=0.0) + + safety_vals = {'Min V': 0.0, 'Min V Time': 0.0, + 'Max V': self.overvoltage, 'Max V Time': 0.005, + 'Max Sink A': self.overcurrent, 'Max Sink A Time': 0.005, + 'Max Source A': self.overcurrent, 'Max Source A Time': 0.005, + 'Max Sink W': self.max_power, 'Max Sink W Time': 0.005, + 'Max Source W': self.max_power, 'Max Source W Time': 0.005, + 'Max Temperature': -1.0} + self.pmod.set_safety(safety_dict=safety_vals) + + self.pmod.set_output_on() + + op_mode = {'enable': ['VOLTAGE_ENABLED', 'CURRENT_ENABLED', 'POWER_ENABLED', 'RESISTANCE_ENABLED'], + 'voltage': self.voltage, + 'current': self.current, + 'power': self.power, + 'resistance': self.resistance} + self.pmod.set_operational_mode(op_mode=op_mode) + + except Exception: + if self.pmod is not None: + self.pmod.close() + raise + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def close(self): + if self.pmod is not None: + self.pmod.close() + self.pmod = None + + def info(self): + return self.pmod.info() + + def measurements_get(self): + """ + Measure the voltage, current, and power + + :return: dictionary with dc power data with keys: 'DC_V', 'DC_I', 'DC_P' + """ + + if self.pmod is not None: + # spread across active channels + meas = self.pmod.measure_all() + total_meas = {'DC_V': meas['Voltage'], 'DC_I': meas['Current'], 'DC_P': meas['Power']} + return total_meas + else: + raise battsim.BattSimError('Not initialized') + + def measure_power(self): + """ + Get the current, voltage, and power from the NHR + returns: dictionary with power data with keys: 'DC_V', 'DC_I', and 'DC_P' + """ + if self.pmod is not None: + return self.measurements_get() + else: + raise battsim.BattSimError('Not initialized') + + def power_set(self, power): + if self.pmod is not None: + if self.pmod.get_safety()['Max Source W'] > power: + self.pmod.set_operational_mode(op_mode={'power': float(power)}) + else: + self.ts.log_error('Cannot change power because Max Source W is too low.') + else: + raise battsim.BattSimError('Not initialized') + + def power_on(self): + if self.pmod is not None: + self.pmod.set_output_on() + else: + raise battsim.BattSimError('Not initialized') + + def power_off(self): + if self.pmod is not None: + self.pmod.set_output_on() + else: + raise battsim.BattSimError('Not initialized') + + # profiles + def profile_load(self, profile_name): + pass + + def profile_start(self): + pass + + def profile_stop(self): + pass + + + def clear_faults(self): + """ + Clear overvoltage and overcurrent faults on the channels + """ + if self.pmod is not None: + self.pmod.clear() + + # Solar functions + def iv_curve_config(self, pmp, vmp): + pass + + def irradiance_set(self, irradiance=1000): + pass + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/Lib/svpelab/chroma_17040.py b/Lib/svpelab/chroma_17040.py index 29eb089..86e867b 100644 --- a/Lib/svpelab/chroma_17040.py +++ b/Lib/svpelab/chroma_17040.py @@ -29,13 +29,13 @@ def _query(self, cmd_str): raise ChromaBattSimError('GPIB connection not open') return self.conn.query(cmd_str.strip('\n')) - except Exception, e: + except Exception as e: raise ChromaBattSimError(str(e)) def query(self, cmd_str): try: resp = self._query(cmd_str).strip() - except Exception, e: + except Exception as e: raise ChromaBattSimError(str(e)) return resp @@ -53,7 +53,7 @@ def _cmd(self, cmd_str): if self.conn is None: raise ChromaBattSimError('connection not open') return self.conn.write(cmd_str.strip('\n')) - except Exception, e: + except Exception as e: raise ChromaBattSimError(str(e)) def cmd(self, cmd_str): @@ -66,12 +66,12 @@ def cmd(self, cmd_str): if resp[0] != '0': raise ChromaBattSimError(resp + ' ' + cmd_str) - except Exception, e: + except Exception as e: raise ChromaBattSimError(str(e)) def open(self): - import visa + import pyvisa as visa """ Open the communications resources associated with the device. """ @@ -83,7 +83,7 @@ def open(self): self.conn.read_termination = TERMINATOR self.conn.timeout=10000 self.cmd('OUTPut:MODe 1') - except Exception, e: + except Exception as e: raise ChromaGridSimError('Cannot open VISA connection to %s' % (self.visa_device)) def protection_clear(self): diff --git a/Lib/svpelab/chroma_61845.py b/Lib/svpelab/chroma_61845.py index ef3d55b..a498216 100644 --- a/Lib/svpelab/chroma_61845.py +++ b/Lib/svpelab/chroma_61845.py @@ -4,8 +4,8 @@ (c) 2-feb-2017 Nathaniel Black at Outback Power Inc ''' -import gridsim -import gridsim_chroma +from . import gridsim +from . import gridsim_chroma import time TEST = None TERMINATOR = '\n' @@ -29,14 +29,14 @@ def _query(self, cmd_str): :return: """ if TEST is not None: - print(cmd_str.strip()) + print((cmd_str.strip())) return '0.0' try: if self.conn is None: raise ChromaGridSimError('GPIB connection not open') return self.conn.query(cmd_str.strip('\n')) - except Exception, e: + except Exception as e: raise ChromaGridSimError(str(e)) def _cmd(self, cmd_str): @@ -46,31 +46,31 @@ def _cmd(self, cmd_str): :return: """ if TEST is not None: - print cmd_str.strip() + print(cmd_str.strip()) return try: if self.conn is None: raise ChromaGridSimError('GPIB connection not open') return self.conn.write(cmd_str.strip('\n')) - except Exception, e: + except Exception as e: raise ChromaGridSimError(str(e)) def cmd(self, cmd_str): try: self._cmd(cmd_str) resp = self._query('SYSTem:ERRor?\n') #\r - print 'resp\n' - print resp + print('resp\n') + print(resp) if len(resp) > 0: if resp[0] != '0': raise ChromaGridSimError(resp + ' ' + cmd_str) - except Exception, e: + except Exception as e: raise ChromaGridSimError(str(e)) def query(self, cmd_str): try: resp = self._query(cmd_str).strip() - except Exception, e: + except Exception as e: raise ChromaGridSimError(str(e)) return resp @@ -114,13 +114,13 @@ def open(self): """ try: # sys.path.append(os.path.normpath(self.visa_path)) - import visa + import pyvisa as visa self.rm = visa.ResourceManager(self.visa_path) self.conn = self.rm.open_resource(self.visa_device) # set terminator in pyvisa self.conn.write_termination = TERMINATOR - except Exception, e: + except Exception as e: raise ChromaGridSimError('Cannot open VISA connection to %s' % (self.visa_device)) def close(self): @@ -269,7 +269,7 @@ def voltage_max(self, voltage=None): if voltage is not None: if (voltage > 0 and voltage < 300): self.cmd('source:volt:limit:ac %0.0f\n' % voltage) - else: raise(ChromaGridSimError ('Votlage out of range')) + else: raise ChromaGridSimError v1 = self.query('source:volt:limit:ac?\n') return float(v1), float(v1), float(v1) @@ -279,18 +279,18 @@ def voltage_range(self, range): elif range == 150: self.cmd('voltage:range low') else: - raise (ChromaGridSimError('Voltage Range is not supported')) + raise ChromaGridSimError def voltage_slew(self,slew): if slew is not None: self.cmd('output:slew:voltage:ac %s' % slew) - else: raise(ChromaGridSimError('Voltage Slew Must in range 0.0 to 1200 V/S')) + else: raise ChromaGridSimError def freq_slew(self,slew): if slew is not None: self.cmd('output:slew:freq %s' % slew) else: - raise (ChromaGridSimError('Voltage Slew Must in range 0.0 to 1600 Hz/S')) + raise ChromaGridSimError if __name__ == "__main__": @@ -303,13 +303,13 @@ def freq_slew(self,slew): cgs = ChromaGridSim(visa_device, visa_path) cgs.open() - print 'Testing Query String' - print cgs.query('*IDN?') + print('Testing Query String') + print(cgs.query('*IDN?')) ''' print '\nconfig_phase_angles for Single Phase' cgs.config_phase_angles(1) ''' - print '\nconfig_phase_angles for 3 phase' + print('\nconfig_phase_angles for 3 phase') cgs.config_phase_angles (2) ''' @@ -318,21 +318,21 @@ def freq_slew(self,slew): print '\nRe-Opening' cgs.open() ''' - print '\nRunning Config()' + print('\nRunning Config()') cgs.config() cgs.voltage(40) cgs.freq (50) - print '\nRunning Current()' + print('\nRunning Current()') cgs.current(15) - print '\nRunning freq()' + print('\nRunning freq()') cgs.freq (62) cgs.relay('closed') - print '\nRunning Profile_load()' + print('\nRunning Profile_load()') dwell_list = '2000,2000,2000,2000,2000,0.0' freq_start_list = '40,50,60,50,40' freq_end_list = '50,60,60,40,40' @@ -367,9 +367,9 @@ def freq_slew(self,slew): print '\nRunning Voltage()' cgs.voltage(122.5) ''' - print '\nRunning Voltage_max()' + print('\nRunning Voltage_max()') cgs.voltage_max({125,124,124}) - print '\nDone, closing connection' + print('\nDone, closing connection') cgs.close() diff --git a/Lib/svpelab/chroma_A800067.py b/Lib/svpelab/chroma_A800067.py index 8ba5eeb..dc77675 100644 --- a/Lib/svpelab/chroma_A800067.py +++ b/Lib/svpelab/chroma_A800067.py @@ -78,12 +78,12 @@ def __init__(self, visa_device=None, visa_path=None, params=None, volts = 220, f def open(self): try: - import visa + import pyvisa as visa self.rm = visa.ResourceManager(self.visa_path) self.conn = self.rm.open_resource(self.visa_device) self.conn.write_termination = TERMINATOR - except Exception, e: + except Exception as e: raise ChromaRLCError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device, str(e))) def close(self): @@ -93,7 +93,7 @@ def close(self): self.conn.close() self.rm.close() time.sleep(1) - except Exception, e: + except Exception as e: raise ChromaRLCError(str(e)) def info(self): @@ -108,7 +108,7 @@ def cmd(self, cmd_str): if len(resp) > 0: if resp[0] != '0': raise ChromaRLCError(resp + ' ' + cmd_str) - except Exception, e: + except Exception as e: raise ChromaRLCError(str(e)) def _query(self, cmd_str): @@ -123,7 +123,7 @@ def _query(self, cmd_str): raise ChromaRLCError('GPIB connection not open') return self.conn.query(cmd_str) - except Exception, e: + except Exception as e: raise ChromaRLCError(str(e)) def _write(self, cmd_str): @@ -136,7 +136,7 @@ def _write(self, cmd_str): if self.conn is None: raise ChromaRLCError('GPIB connection not open') return self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise ChromaRLCError(str(e)) def voltset (self,v): @@ -217,7 +217,7 @@ def calcC (i, f, v): myRLC = ChromaRLC('GPIB0::3::INSTR', 'C:/Windows/System32/visa32.dll') - print myRLC.info() + print(myRLC.info()) myRLC.resistance(2,120) time.sleep(4) diff --git a/Lib/svpelab/chromapv.py b/Lib/svpelab/chromapv.py index ce78bce..a29ecf6 100644 --- a/Lib/svpelab/chromapv.py +++ b/Lib/svpelab/chromapv.py @@ -33,7 +33,7 @@ import sys import time import os -import pvsim +from . import pvsim EN_50530_CURVE = 'EN 50530 CURVE' @@ -66,10 +66,10 @@ def _query(self, cmd_str): try: if self.conn is None: raise ChromaPVError('GPIB connection not open') - print cmd_str + print(cmd_str) return self.conn.query(cmd_str) - except Exception, e: + except Exception as e: raise ChromaPVError(str(e)) def query(self, cmd_str): @@ -96,7 +96,7 @@ def open(self): elif self.comm == 'VISA': try: # sys.path.append(os.path.normpath(self.visa_path)) - import visa + import pyvisa as visa self.rm = visa.ResourceManager("C:/Program Files (x86)/IVI Foundation/VISA/WinNT/agvisa/agbin/visa32.dll") self.conn = self.rm.open_resource(self.visa_device) @@ -105,7 +105,7 @@ def open(self): time.sleep(1) - except Exception, e: + except Exception as e: raise ChromaPVError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) else: raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) @@ -113,14 +113,14 @@ def open(self): def cmd(self, cmd_str): try: self._cmd(cmd_str) - except Exception, e: + except Exception as e: raise ChromaPVError(str(e)) def _cmd(self, cmd_str): try: - print cmd_str + print(cmd_str) self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise def info(self): @@ -131,7 +131,7 @@ def reset(self): time.sleep(5) def power_on(self): - print self.output_status() + print(self.output_status()) if self.output_status().strip() == 'OFF': if self.output_mode().strip != 'SAS': self.cmd('OUTPut:MODE SAS') @@ -160,7 +160,7 @@ def close(self): self.rm.close() time.sleep(1) - except Exception, e: + except Exception as e: raise pvsim.PVSimError(str(e)) else: raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) @@ -182,7 +182,7 @@ def curve(self, filename=None, voc=None, isc=None, pmp=None, vmp=None, form_fact def irradiance_set(self, irradiance, voc, isc, pmp, vmp): self.irradiance = irradiance #Since Chroma doesn't support it, we need to calcalate new parameters based on irradiance - print 'in irradiance' + print('in irradiance') isc = isc * irradiance/1000 pmp = pmp * irradiance/1000 @@ -216,8 +216,8 @@ def profile_start(self): # sas = TerraSAS(ipaddr='10.10.10.10') #sas.reset() - print sas.info() - print sas.status() + print(sas.info()) + print(sas.status()) Mvoc = 500 Misc = 13 @@ -226,7 +226,7 @@ def profile_start(self): sas.curve(None,Mvoc,Misc,Mpmp,Mvmp) sas.power_off() - print sas.output_status() + print(sas.output_status()) time.sleep(1) sas.power_on() @@ -255,6 +255,6 @@ def profile_start(self): sas.close() - except Exception, e: + except Exception as e: raise - print 'Error running TerraSAS setup: %s' % (str(e)) + print('Error running TerraSAS setup: %s' % (str(e))) diff --git a/Lib/svpelab/das.py b/Lib/svpelab/das.py index 8e3218b..2a8a00e 100644 --- a/Lib/svpelab/das.py +++ b/Lib/svpelab/das.py @@ -35,7 +35,7 @@ import glob import importlib -import dataset +from . import dataset ''' The DAS module supports collecting time series data records in a dataset. Each time series data record is comprised @@ -83,7 +83,7 @@ WFM_STATUS_COMPLETE = 'complete' points_default = { - 'AC': ('VRMS', 'IRMS', 'P', 'S', 'Q', 'PF', 'FREQ'), + 'AC': ('VRMS', 'IRMS', 'P', 'S', 'Q', 'PF', 'FREQ','INC'), 'DC': ('V', 'I', 'P') } @@ -103,12 +103,13 @@ def params(info, id=None, label='Data Acquisition System', group_name=None, acti name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) - for mode, m in das_modules.iteritems(): + for mode, m in das_modules.items(): m.params(info, group_name=group_name) DAS_DEFAULT_ID = 'das' -def das_init(ts, id=None, points=None, sc_points=None, group_name=None): + +def das_init(ts, id=None, points=None, sc_points=None, group_name=None, support_interfaces=None): """ Function to create specific das implementation instances. """ @@ -123,7 +124,8 @@ def das_init(ts, id=None, points=None, sc_points=None, group_name=None): if mode != 'Disabled': sim_module = das_modules.get(mode) if sim_module is not None: - sim = sim_module.DAS(ts, group_name, points=points, sc_points=sc_points) + sim = sim_module.DAS(ts, group_name, points=points, sc_points=sc_points, + support_interfaces=support_interfaces) else: raise DASError('Unknown data acquisition system mode: %s' % mode) @@ -143,7 +145,17 @@ class DAS(object): independent grid simulator classes can be created containing the methods contained in this class. """ - def __init__(self, ts, group_name, points=None, sc_points=None): + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + """ + Initialize the DAS object with the following parameters + + :param ts: test script with logging capability + :param group_name: name used when there are multiple instances + :param points: data points ('AC_P_1', etc.) + :param sc_points: soft channel points + :param support_interfaces: dictionary with keys 'pvsim', 'gridsim', 'hil', etc. + """ + self.ts = ts self.group_name = group_name self.points = points @@ -158,6 +170,22 @@ def __init__(self, ts, group_name, points=None, sc_points=None): self._ds = None self._last_datarec = [] + # optional interfaces to other SVP abstraction layers/device drivers + self.dc_measurement_device = None + self.hil = None + self.gridsim = None + if support_interfaces is not None: + if support_interfaces.get('pvsim') is not None: + self.dc_measurement_device = support_interfaces.get('pvsim') + elif support_interfaces.get('dcsim') is not None: + self.dc_measurement_device = support_interfaces.get('dcsim') + + if support_interfaces.get('hil') is not None: + self.hil = support_interfaces.get('hil') + + if support_interfaces.get('gridsim') is not None: + self.gridsim = support_interfaces.get('gridsim') + if self.points is None: self.points = dict(points_default) @@ -177,13 +205,14 @@ def _init_sc_points(self): for p in self.sc_data_points: self.data_points.append(p) self.sc[p] = 0 + # self.ts.log_debug('_init_sc_points datapoints = %s' % self.data_points) - self._ds = dataset.Dataset(self.data_points) + self._ds = dataset.Dataset(self.data_points, ts=self.ts) def _data_expand(self, data): if len(self.data_points) != len(data): raise DASError('Data/data point mismatch: %s %s' % (self.data_points, data)) - return dict(zip(self.data_points, data)) + return dict(list(zip(self.data_points, data))) def _timer_timeout(self, arg=None): self.data_sample() @@ -219,9 +248,11 @@ def data_capture(self, enable=True, channels=None): If sample_interval == 0, there will be no autonomous data captures and self.data_sample should be used to add data points to the capture """ + if self.device is not None: + self.sample_interval = self.device.sample_interval if enable is True: if self._capture is False: - self._ds = dataset.Dataset(self.data_points) + self._ds = dataset.Dataset(self.data_points, ts=self.ts) self._last_datarec = [] if self.sample_interval > 0: if self.sample_interval < MINIMUM_SAMPLE_PERIOD: @@ -345,10 +376,10 @@ def das_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - raise DASError('Error scanning module %s: %s' % (module_name, str(e))) + print(DASError('Error scanning module %s: %s' % (module_name, str(e)))) # scan for das modules on import das_scan() diff --git a/Lib/svpelab/das_chroma.py b/Lib/svpelab/das_chroma.py index c366b82..fbb031d 100644 --- a/Lib/svpelab/das_chroma.py +++ b/Lib/svpelab/das_chroma.py @@ -32,8 +32,8 @@ import os -import das -import device_chroma_dpm +from . import das +from . import device_chroma_dpm chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/das_csz_ezt-570i.py b/Lib/svpelab/das_csz_ezt-570i.py new file mode 100644 index 0000000..cd2f8ad --- /dev/null +++ b/Lib/svpelab/das_csz_ezt-570i.py @@ -0,0 +1,259 @@ +""" +Copyright (c) 2021, Sandia National Laboratories +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +# Driver for Cincinnati Sub-Zero (CSZ) EZT-570i Environmental Chamber Controller +# This uses the web server to scrape data. + +import os +import urllib3 +from bs4 import BeautifulSoup as soup +import re +import pprint +from collections import OrderedDict +import time + +try: + from . import das +except Exception as e: + print('Could not import das abstraction layer') + +csz_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'CSZ EZT-570i' +} + +def das_info(): + return csz_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = csz_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Web', + values=['Web', 'Modbus (unimplemented)', 'GPIB (unimplemented)']) + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['Web'], default='10.1.2.52') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['Web'], default=80) + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + + +GROUP_NAME = 'csz' + + +class DAS(das.DAS): + """ + Template for data acquisition (DAS) implementations. This class can be used as a base class or + independent data acquisition classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points, support_interfaces=None) + self.sample_interval = self._param_value('sample_interval') + self.params['comm'] = self._param_value('comm') + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['ip_port'] = self._param_value('ip_port') + self.params['sample_interval'] = self._param_value('sample_interval') + self.params['ts'] = ts + self.device = Device(self.params) + self.data_points = self.device.data_points + + if self.params['sample_interval'] < 50 and self.params['sample_interval'] is not 0: + raise das.DASError('Parameter error: sample interval must be at least 50 ms or 0 for manual sampling') + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +class DeviceError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass + + +class Device(object): + + def __init__(self, params): + self.params = params + self.conn = params.get('conn') + self.ip_addr = params.get('ip_addr') + self.ip_port = params.get('ip_port') + self.ts = params.get('ts') + self.sample_interval = params.get('sample_interval') + self.data_points = ['TIME', 'TEMP', 'TEMP_SETPOINT', 'HUMIDITY', 'HUMIDITY_SETPOINT', + 'PRODUCT', 'PRODUCT_SETPOINT', 'PROFILE_STATUS', 'PROFILE_START', 'PROFILE_END_ESTIMATE', + 'PROFILE_CURRENT_STEP', 'PROFILE_STEP_TIME_LEFT', 'PROFILE_WAIT_FOR_INPUT', + 'PROFILE_WAIT_SETPOINT', 'PROFILE_TEMP_SETPOINT', 'PROFILE_HUMIDITY_SETPOINT', + 'PROFILE_PRODUCT_SETPOINT'] + + def open(self): + pass + + def close(self): + pass + + def info(self): + return 'Cincinnati Sub-Zero (CSZ) EZT-570i Environmental Chamber Controller' + + def data_capture(self, enable=True): + pass + + def data_read(self): + + http = urllib3.PoolManager() + url = 'http://%s:%s/ezt.html' % (self.ip_addr, self.ip_port) + r = http.request('GET', url, preload_content=False) + r.release_conn() + # if self.ts is not None: + # self.ts.log(r.data) + # else: + # print(r.data) + + # html parsing + page_soup = soup(r.data, "html.parser") + # print(page_soup.prettify()) + + page_items = [] + for link in page_soup.find_all("td"): + # print("Inner Text: {}".format(link.text)) + page_items.append(format(link.text).lstrip('\n\r')) + + # dict with measurement name and value + try: + datadict = OrderedDict({ + 'time': time.time(), + 'temp': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[1]).group()), + 'temp_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[2]).group()), + 'humidity': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[4]).group()), + 'humidity_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[5]).group()), + 'product': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[7]).group()), + 'product_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[8]).group()), + 'profile_status': page_items[10], + 'profile_start': page_items[12], + 'profile_end_estimate': page_items[14], + 'profile_current_step': page_items[16], + 'profile_step_time_left': page_items[18], + 'profile_wait_for_input': page_items[20], + 'profile_wait_setpoint': page_items[22], + 'profile_temp_setpoint': page_items[24], + 'profile_humidity_setpoint': page_items[26], + 'profile_product_setpoint': page_items[28], + }) + except Exception as e: + self.ts.log_warning('Error Parsing EZT Web Page. Error: %s' % e) + datadict = OrderedDict({ + 'time': time.time(), + 'temp': None, + 'temp_setpoint': None, + 'humidity': None, + 'humidity_setpoint': None, + 'product': None, + 'product_setpoint': None, + 'profile_status': None, + 'profile_start': None, + 'profile_end_estimate': None, + 'profile_current_step': None, + 'profile_step_time_left': None, + 'profile_wait_for_input': None, + 'profile_wait_setpoint': None, + 'profile_temp_setpoint': None, + 'profile_humidity_setpoint': None, + 'profile_product_setpoint': None, + }) + + data = [] + for key, value in datadict.items(): + data.append(value) + + # self.ts.log_debug('Data:%s' % data) + # self.ts.log_debug('Data Points:%s' % self.data_points) + return data + + def capture(self, enable=None): + """ + Enable/disable capture. + """ + pass + + +if __name__ == "__main__": + + local_url = r'http://localhost:8000/ThermalChamber2.html' + + http = urllib3.PoolManager() + r = http.request('GET', local_url, preload_content=False) + r.release_conn() + print(r.data) + + # html parsing + page_soup = soup(r.data, "html.parser") + # data = open(local_url, "r").read() + # page_soup = soup(data, "html.parser") + # print(page_soup.prettify()) + + page_items = [] + for link in page_soup.find_all("td"): + # print("Inner Text: {}".format(link.text)) + page_items.append(format(link.text).lstrip('\n\r')) + + for i in range(len(page_items)): + print('%s: %s' % (i, page_items[i])) + + # dict with measurement name and value + data = OrderedDict({ + 'temp': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[1]).group()), + 'temp_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[2]).group()), + 'humidity': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[4]).group()), + 'humidity_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[5]).group()), + 'product': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[7]).group()), + 'product_setpoint': float(re.search(r"[+-]?\d+(?:\.\d+)?", page_items[8]).group()), + 'profile_status': page_items[10], + 'profile_start': page_items[12], + 'profile_end_estimate': page_items[14], + 'profile_current_step': page_items[16], + 'profile_step_time_left': page_items[18], + 'profile_wait_for_input': page_items[20], + 'profile_wait_setpoint': page_items[22], + 'profile_temp_setpoint': page_items[24], + 'profile_humidity_setpoint': page_items[26], + 'profile_product_setpoint': page_items[28], + }) + + pprint.pprint(data) + diff --git a/Lib/svpelab/das_dewetron.py b/Lib/svpelab/das_dewetron.py new file mode 100644 index 0000000..3d99e89 --- /dev/null +++ b/Lib/svpelab/das_dewetron.py @@ -0,0 +1,159 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from .device_das_dewetron import Device +from .das import DAS as MDAS + +dewetron_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Dewetron' +} + +def das_info(): + return dewetron_info + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = dewetron_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Network', values=['Network']) + info.param(pname('ip_addr'), label='DEWESoft NET IP Address', + active=pname('comm'), active_value=['Network'], default='127.0.0.1') + info.param(pname('ip_port'), label='DEWESoft NET Port', + active=pname('comm'), active_value=['Network'], default=8999) + + info.param(pname('deweproxy_ip_addr'), label='Binary Server IP Address', + active=pname('comm'), active_value=['Network'], default='127.0.0.1') + info.param(pname('deweproxy_ip_port'), label='Binary Server Port', + active=pname('comm'), active_value=['Network'], default=9000) + + info.param(pname('sample_interval'), label='SVP Sample Interval (ms)', default=1000) + info.param(pname('sample_interval_dewe'), label='Dewetron Sample Frequency (Hz)', default=5000) + + info.param(pname('AC_VRMS_1'), label='L1 Voltage RMS (V)', default='EUT/U_rms_L1') + info.param(pname('AC_VRMS_2'), label='L2 Voltage RMS (V)', default='EUT/U_rms_L2') + info.param(pname('AC_VRMS_3'), label='L3 Voltage RMS (V)', default='EUT/U_rms_L3') + info.param(pname('AC_IRMS_1'), label='L1 Current RMS (A)', default='EUT/I_rms_L1') + info.param(pname('AC_IRMS_2'), label='L2 Current RMS (A)', default='EUT/I_rms_L2') + info.param(pname('AC_IRMS_3'), label='L3 Current RMS (A)', default='EUT/I_rms_L3') + info.param(pname('AC_FREQ_1'), label='L1 Frequency (Hz)', default='EUT/Frequency') + info.param(pname('AC_FREQ_2'), label='L2 Frequency (Hz)', default='EUT/Frequency') + info.param(pname('AC_FREQ_3'), label='L3 Frequency (Hz)', default='EUT/Frequency') + info.param(pname('AC_P_1'), label='L1 Active Power (W)', default='EUT/P_L1') + info.param(pname('AC_P_2'), label='L2 Active Power (W)', default='EUT/P_L2') + info.param(pname('AC_P_3'), label='L3 Active Power (W)', default='EUT/P_L3') + info.param(pname('AC_S_1'), label='L1 Apparent Power (VA)', default='EUT/S_L1') + info.param(pname('AC_S_2'), label='L2 Apparent Power (VA)', default='EUT/S_L2') + info.param(pname('AC_S_3'), label='L3 Apparent Power (VA)', default='EUT/S_L3') + info.param(pname('AC_Q_1'), label='L1 Reactive Power (Var)', default='EUT/Q_L1') + info.param(pname('AC_Q_2'), label='L2 Reactive Power (Var)', default='EUT/Q_L1') + info.param(pname('AC_Q_3'), label='L3 Reactive Power (Var)', default='EUT/Q_L1') + info.param(pname('AC_PF_1'), label='L1 Power factor', default='EUT/PF_L1') + info.param(pname('AC_PF_2'), label='L2 Power factor', default='EUT/PF_L2') + info.param(pname('AC_PF_3'), label='L3 Power factor', default='EUT/PF_L3') + + info.param(pname('DC_V'), label='DC Voltage (V)', default='PV/U_rms_L1') + info.param(pname('DC_I'), label='DC Current (A)', default='PV/I_rms_L1') + info.param(pname('DC_P'), label='DC Power (W)', default='PV/P_L1') + + + + +GROUP_NAME = 'dewetron' + + +class DAS(MDAS): + """ + Template for data acquisition (DAS) implementations. This class can be used as a base class or + independent data acquisition classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name, points=None, sc_points=None): + MDAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) + + self.params['ts'] = ts + + self.params['sample_interval'] = self._param_value('sample_interval') + self.sample_interval = self.params['sample_interval'] + + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['ipport'] = self._param_value('ip_port') + + self.params['deweproxy_ip_addr'] = self._param_value('deweproxy_ip_addr') + self.params['deweproxy_ip_port'] = self._param_value('deweproxy_ip_port') + + + self.params['AC_VRMS_1'] = self._param_value('AC_VRMS_1') + self.params['AC_VRMS_2'] = self._param_value('AC_VRMS_2') + self.params['AC_VRMS_3'] = self._param_value('AC_VRMS_3') + self.params['AC_IRMS_1'] = self._param_value('AC_IRMS_1') + self.params['AC_IRMS_2'] = self._param_value('AC_IRMS_2') + self.params['AC_IRMS_3'] = self._param_value('AC_IRMS_3') + self.params['AC_FREQ_1'] = self._param_value('AC_FREQ_1') + self.params['AC_FREQ_2'] = self._param_value('AC_FREQ_2') + self.params['AC_FREQ_3'] = self._param_value('AC_FREQ_3') + self.params['AC_P_1'] = self._param_value('AC_P_1') + self.params['AC_P_2'] = self._param_value('AC_P_2') + self.params['AC_P_3'] = self._param_value('AC_P_3') + self.params['AC_S_1'] = self._param_value('AC_S_1') + self.params['AC_S_2'] = self._param_value('AC_S_2') + self.params['AC_S_3'] = self._param_value('AC_S_3') + self.params['AC_Q_1'] = self._param_value('AC_Q_1') + self.params['AC_Q_2'] = self._param_value('AC_Q_2') + self.params['AC_Q_3'] = self._param_value('AC_Q_3') + self.params['AC_PF_1'] = self._param_value('AC_PF_1') + self.params['AC_PF_2'] = self._param_value('AC_PF_2') + self.params['AC_PF_3'] = self._param_value('AC_PF_3') + self.params['DC_V'] = self._param_value('DC_V') + self.params['DC_I'] = self._param_value('DC_I') + self.params['DC_P'] = self._param_value('DC_P') + self.params['sample_interval_dewe'] = self._param_value('sample_interval_dewe') + + + + self.device = Device(self.params) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + + pass diff --git a/Lib/svpelab/das_elspec_g4420.py b/Lib/svpelab/das_elspec_g4420.py new file mode 100644 index 0000000..1709c13 --- /dev/null +++ b/Lib/svpelab/das_elspec_g4420.py @@ -0,0 +1,94 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_elspec_g4420 +from . import das + +elspec_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Elspec G4420' +} + +def das_info(): + return elspec_info + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = elspec_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Modbus TCP', values=['Modbus TCP']) + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['Modbus TCP'], default='1.1.1.39') + info.param(pname('ip_port'), label='IP Port', active=pname('comm'), active_value=['Modbus TCP'], default=502) + info.param(pname('ip_timeout'), label='IP Timeout', active=pname('comm'), active_value=['Modbus TCP'], default=5) + info.param(pname('slave_id'), label='Slave Id', active=pname('comm'), active_value=['Modbus TCP'], default=159) + + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + +GROUP_NAME = 'elspec_g4420' + + +class DAS(das.DAS): + + def __init__(self, ts, group_name, points=None, sc_points=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) + self.sample_interval = self._param_value('sample_interval') + + self.params['comm'] = self._param_value('comm') + if self.params['comm'] == 'Modbus TCP': + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['ip_port'] = self._param_value('ip_port') + self.params['ip_timeout'] = self._param_value('ip_timeout') + self.params['slave_id'] = self._param_value('slave_id') + + self.device = device_elspec_g4420.Device(self.params, ts) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + + if self.sample_interval < 50 and self.sample_interval is not 0: + raise das.DASError('Parameter error: sample interval must be at least 50ms') + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + + pass + + diff --git a/Lib/svpelab/das_manual.py b/Lib/svpelab/das_manual.py index 1239c61..c481737 100644 --- a/Lib/svpelab/das_manual.py +++ b/Lib/svpelab/das_manual.py @@ -32,8 +32,8 @@ import os -import device_das_manual -import das +from . import device_das_manual +from . import das manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -43,20 +43,68 @@ def das_info(): return manual_info +GROUP_NAME = 'manual' + def params(info, group_name): gname = lambda name: group_name + '.' + name pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name mode = manual_info['mode'] info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, + glob=True) + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + info.param(pname('chan_1'), label='Channel 1', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_1_label'), label='Channel 1 Label', default='1', active=pname('chan_1'), + active_value=['AC', 'DC']) + info.param(pname('chan_2'), label='Channel 2', default='Unused', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_2_label'), label='Channel 2 Label', default='2', active=pname('chan_2'), + active_value=['AC', 'DC']) + info.param(pname('chan_3'), label='Channel 3', default='Unused', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_3_label'), label='Channel 3 Label', default='3', active=pname('chan_3'), + active_value=['AC', 'DC']) -GROUP_NAME = 'manual' class DAS(das.DAS): - def __init__(self, ts, group_name, points=None, sc_points=None): - das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) - self.device = device_das_manual.Device() - self.data_points = self.device.data_points + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points, support_interfaces=support_interfaces) + self.params['ip_address'] = self._param_value('ip_address') + self.params['comm'] = self._param_value('comm') + self.params['wiring_system'] = self._param_value('wiring_system') + self.params['sample_interval'] = self._param_value('sample_interval') + self.params['timestamp'] = self._param_value('timestamp') + self.params['scale_i_inverse'] = self._param_value('scale_i_inverse') + + # create channel info for each channel from parameters + channels = [None] + for i in range(1, 8): + chan_type = self._param_value('chan_%d' % (i)) + chan_label = self._param_value('chan_%d_label' % (i)) + chan_ratio = self._param_value('chan_%d_i_ratio' % (i)) + if chan_label == 'None': + chan_label = '' + chan = {'type': chan_type, 'points': self.points.get(chan_type), 'label': chan_label, 'ratio': chan_ratio} + channels.append(chan) + self.params['channels'] = channels + + self.device = device_das_manual.Device(self.params) + self.data_points = self.device.data_points + ts.log('In the Report :') + ts.log('Voltage = 123') + ts.log('Current = 12') + ts.log('Active Power (P) = 12345') + ts.log('Reactive Power (Q) = 11111') + ts.log('Apparent Power (S) = 16609') + ts.log('Frequency = 67') + ts.log('Power Factor = 0.12') + ts.log('unassigned = 9991 (go to device_das_manual.py to add the missing measurement type)') # initialize soft channel points - self._init_sc_points() \ No newline at end of file + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__" : + pass \ No newline at end of file diff --git a/Lib/svpelab/das_opal.py b/Lib/svpelab/das_opal.py new file mode 100644 index 0000000..1a08aad --- /dev/null +++ b/Lib/svpelab/das_opal.py @@ -0,0 +1,94 @@ +""" + +All rights reserved. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_das_opal +from . import das + +opal_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Opal' +} + + +def das_info(): + return opal_info + + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = opal_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('rt_lab_version'), label='RT-LAB Version', default="2020.4") + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + info.param(pname('map'), label='Opal Analog Channel Map (e.g. simulinks blocks, etc,.)', default='IEEE1547_VRT') + info.param(pname('wfm_dir'), active_value="Yes", label='Waveform Directory', + default='C:\\Users\\DETLDAQ\\OPAL-RT\\ RT-LABv2019.1_Workspace\\ IEEE_1547.1_Phase_Jump\\models\\' + 'Phase_Jump_A_B_A\\phase_jump_a_b_a_sm_source\\OpREDHAWKtarget\\') + info.param(pname('wfm_chan_list'), label='Waveform Channel List', default='PhaseJump') + info.param(pname('data_name'), label='Waveform Data File Name (.mat)', default='Data.mat') + info.param(pname('sc_capture'), label='Capture data from the console?', default='No', values=['Yes', 'No']) + + +GROUP_NAME = 'opal' + + +class DAS(das.DAS): + + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points, + support_interfaces=support_interfaces) + self.params['ts'] = ts + self.params['map'] = self._param_value('map') + self.params['rt_lab_version'] = self._param_value('rt_lab_version') + self.params['sample_interval'] = self._param_value('sample_interval') + self.params['wfm_dir'] = self._param_value('wfm_dir') + self.params['wfm_chan_list'] = self._param_value('wfm_chan_list') + self.params['data_name'] = self._param_value('data_name') + self.params['sc_capture'] = self._param_value('sc_capture') + if self.hil is None: + ts.log_warning('No HIL support interface was provided to das_opal.py. It is recommended to provide the ' + 'hil, at minimum, using "daq = das.das_init(ts, support_interfaces=' + '{"hil": phil, "pvsim": pv})"') + self.params['hil'] = self.hil + self.params['gridsim'] = self.gridsim + self.params['dc_measurement_device'] = self.dc_measurement_device + + self.device = device_das_opal.Device(self.params) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + if self.params['sample_interval'] is not None: + if self.params['sample_interval'] < 50 and self.params['sample_interval'] is not 0: + raise das.DASError('Parameter error: sample interval must be at least 50 ms or 0 for manual sampling') + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def set_dc_measurement(self, obj=None): + """ + DEPRECATED + + In the event that DC measurements are taken from another device (e.g., a PV simulator) please add this + device to the das object + :param obj: The object (e.g., pvsim) that will gather the dc measurements + :return: None + """ + # self.ts.log_debug('device: %s, obj: %s' % (self.device, obj)) + self.device.set_dc_measurement(obj) + + +if __name__ == "__main__": + + pass + + diff --git a/Lib/svpelab/das_powerlogic_pm800.py b/Lib/svpelab/das_powerlogic_pm800.py index d85510a..ee9aebb 100644 --- a/Lib/svpelab/das_powerlogic_pm800.py +++ b/Lib/svpelab/das_powerlogic_pm800.py @@ -35,8 +35,8 @@ import os -import device_das_powerlogic_pm800 -import das +from . import device_das_powerlogic_pm800 +from . import das pm_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -86,10 +86,9 @@ def __init__(self, ts, group_name, points=None, sc_points=None): # initialize soft channel points self._init_sc_points() - if self.sample_interval < 50: + if self.sample_interval < 50 and self.sample_interval is not 0: raise das.DASError('Parameter error: sample interval must be at least 50ms') - def _param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) diff --git a/Lib/svpelab/das_px8000.py b/Lib/svpelab/das_px8000.py index 59b9448..c2cb3f4 100644 --- a/Lib/svpelab/das_px8000.py +++ b/Lib/svpelab/das_px8000.py @@ -32,8 +32,8 @@ import os -import device_px8000 -import das +from . import device_px8000 +from . import das px8000_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/das_pz4000.py b/Lib/svpelab/das_pz4000.py index f4e226a..7a988dd 100644 --- a/Lib/svpelab/das_pz4000.py +++ b/Lib/svpelab/das_pz4000.py @@ -1,7 +1,7 @@ import os -import das -import device_pz4000 +from . import das +from . import device_pz4000 diff --git a/Lib/svpelab/das_sandia_dsm.py b/Lib/svpelab/das_sandia_dsm.py index 95c86c5..a287a14 100644 --- a/Lib/svpelab/das_sandia_dsm.py +++ b/Lib/svpelab/das_sandia_dsm.py @@ -33,8 +33,8 @@ import os import script -import device_sandia_dsm -import das +from . import device_sandia_dsm +from . import das sandia_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -71,8 +71,8 @@ class DAS(das.DAS): def __init__(self, ts, group_name, points=None, sc_points=None): das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) - self.sample_interval = self._param_value('sample_interval') + self.params['sample_interval'] = self._param_value('sample_interval') self.params['dsm_method'] = self._param_value('dsm_method') self.params['dsm_id'] = self._param_value('node') self.params['comp'] = self._param_value('comp') diff --git a/Lib/svpelab/das_sandia_ni_pcie.py b/Lib/svpelab/das_sandia_ni_pcie.py index 6764bf9..09c46bb 100644 --- a/Lib/svpelab/das_sandia_ni_pcie.py +++ b/Lib/svpelab/das_sandia_ni_pcie.py @@ -33,8 +33,8 @@ """ import os -import device_das_sandia_ni_pcie -import das +from . import device_das_sandia_ni_pcie +from . import das ni_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/das_sandia_ni_pcie_daq7.py b/Lib/svpelab/das_sandia_ni_pcie_daq7.py new file mode 100644 index 0000000..cae3443 --- /dev/null +++ b/Lib/svpelab/das_sandia_ni_pcie_daq7.py @@ -0,0 +1,88 @@ +""" +Communications to NI PCIe Cards + +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_das7_sandia_ni_pcie +from . import das + +daq7_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Sandia DAQ7 (NI PCIe)' +} + +def das_info(): + return daq7_info + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = daq7_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + info.param(pname('sample_rate'), label='Sample rate of waveforms (Hz)', default=10000) + info.param(pname('n_cycles'), label='Number of cycles to capture', default=6) + +GROUP_NAME = 'sandia_daq7' + + +class DAS(das.DAS): + + def __init__(self, ts, group_name, points=None, sc_points=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) + self.sample_interval = self._param_value('sample_interval') + self.params['sample_interval'] = self._param_value('sample_interval') + self.params['sample_rate'] = self._param_value('sample_rate') + self.params['n_cycles'] = self._param_value('n_cycles') + self.params['ts'] = ts + + self.device = device_das7_sandia_ni_pcie.Device(self.params, ts) + self.data_points = [] + for key, value in self.device.points_map.items(): + self.data_points.append(key) + self.data_points = sorted(self.data_points) # alphabetize + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + + pass + + diff --git a/Lib/svpelab/das_sim.py b/Lib/svpelab/das_sim.py index d2f8b4f..76516f5 100644 --- a/Lib/svpelab/das_sim.py +++ b/Lib/svpelab/das_sim.py @@ -31,9 +31,8 @@ """ import os -import device_das_sim -import das -import script +from . import device_das_sim +from . import das sim_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -50,33 +49,65 @@ def params(info, group_name=None): info.param_add_value(gname('mode'), mode) info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode) - info.param(pname('data_file'), label='Data File (in SVP Files directory)', default='data.csv') - info.param(pname('use_timestamp'), label='Use Data File Timestamp', default='Enabled', values=['Enabled', - 'Disabled']) - info.param(pname('at_end'), label='At End of Data', default='Repeat last record', values=['Loop to start', - 'Repeat last record', - 'Return an error']) + info.param(pname('Sim_mode'), label='Simulation mode', default='Disabled', values=['Disabled', 'Random']) + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000, active=pname('Sim_mode'), + active_value='Random') + info.param(pname('chan_1'), label='Channel 1', default='AC', values=['AC', 'DC', 'Unused'], + active=pname('Sim_mode'), active_value='Random') + info.param(pname('chan_1_label'), label='Channel 1 Label', default='1', active=pname('chan_1'), + active_value=['AC', 'DC']) + info.param(pname('chan_2'), label='Channel 2', default='Unused', values=['AC', 'DC', 'Unused'], + active=pname('Sim_mode'), active_value='Random') + info.param(pname('chan_2_label'), label='Channel 2 Label', default='2', active=pname('chan_2'), + active_value=['AC', 'DC']) + info.param(pname('chan_3'), label='Channel 3', default='Unused', values=['AC', 'DC', 'Unused'], + active=pname('Sim_mode'), active_value='Random') + info.param(pname('chan_3_label'), label='Channel 3 Label', default='3', active=pname('chan_3'), + active_value=['AC', 'DC']) GROUP_NAME = 'sim' +class DASError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass + + class DAS(das.DAS): - def __init__(self, ts, group_name, points=None, sc_points=None): - das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) - data_file = self._param_value('data_file') - if data_file and data_file != 'None': - data_file = os.path.join(self.files_dir, data_file) - self.params['points'] = self.points - self.params['data_file'] = data_file - self.params['use_timestamp'] = self._param_value('use_timestamp') - self.params['at_end'] = self._param_value('at_end') - self.params['ts'] = self.ts - - self.ts.log('results_dir = %s' % (ts._results_dir)) + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points, support_interfaces=support_interfaces) + # create channel info for each channel from parameters + self.params['Sim_mode'] = self._param_value('Sim_mode') + self.params['sample_interval'] = self._param_value('sample_interval') + if self.params['Sim_mode'] == 'Random': + channels = [None] + for i in range(1, 8): + chan_type = self._param_value('chan_%d' % (i)) + chan_label = self._param_value('chan_%d_label' % (i)) + chan_ratio = self._param_value('chan_%d_i_ratio' % (i)) + if chan_label == 'None': + chan_label = '' + chan = {'type': chan_type, 'points': self.points.get(chan_type), 'label': chan_label, 'ratio': chan_ratio} + channels.append(chan) + + self.params['channels'] = channels + + ts.log('In the Report :') + ts.log('Voltage = 123') + ts.log('Current = 12') + ts.log('Active Power (P) = 12345') + ts.log('Reactive Power (Q) = 11111') + ts.log('Apparent Power (S) = 16609') + ts.log('Frequency = 67') + ts.log('Power Factor = 0.12') + ts.log('unassigned = 9991 (go to device_das_sim.py to add the missing measurement type)') + else: + raise DASError('You need to select Random as the Simulation mode') self.device = device_das_sim.Device(self.params) self.data_points = self.device.data_points - # initialize soft channel points self._init_sc_points() diff --git a/Lib/svpelab/das_tektronics_dpo3000.py b/Lib/svpelab/das_tektronics_dpo3000.py new file mode 100644 index 0000000..cb79235 --- /dev/null +++ b/Lib/svpelab/das_tektronics_dpo3000.py @@ -0,0 +1,140 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_tektronix_dpo3000 +from . import das + +dpo3000_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Tektronix DPO3000' +} + + +def das_info(): + return dpo3000_info + + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = dpo3000_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Network', values=['VISA']) + info.param(pname('visa_id'), label='visa_id', + active=pname('comm'), active_value=['VISA'], default='TCPIP::10.1.2.87::INSTR') + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=0) + + info.param(pname('trigger_chan'), label='Trigger Channel', default='Chan 1', + values=['Chan 1', 'Chan 2', 'Chan 3', 'Chan 4', 'Line']) + info.param(pname('trigger_level'), label='Trigger Level', default=0.) + info.param(pname('trigger_slope'), label='Rising or Falling Trigger', default='Rise', values=['Rise', 'Fall']) + + info.param(pname('chan_1'), label='Channel 1', default='Switch_Current', + values=['Switch_Current', 'Switch_Voltage', 'Bus_Voltage', 'Bus_Current', 'None']) + info.param(pname('chan_2'), label='Channel 2', default='Switch_Voltage', + values=['Switch_Current', 'Switch_Voltage', 'Bus_Voltage', 'Bus_Current', 'None']) + info.param(pname('chan_3'), label='Channel 3', default='Bus_Voltage', + values=['Switch_Current', 'Switch_Voltage', 'Bus_Voltage', 'Bus_Current', 'None']) + info.param(pname('chan_4'), label='Channel 4', default='None', + values=['Switch_Current', 'Switch_Voltage', 'Bus_Voltage', 'Bus_Current', 'None']) + + info.param(pname('vert_1'), label='Vertical Scale, Chan 1 (V/div)', default=5.) + info.param(pname('vert_2'), label='Vertical Scale, Chan 2 (V/div)', default=5.) + info.param(pname('vert_3'), label='Vertical Scale, Chan 3 (V/div)', default=0.5) + info.param(pname('vert_4'), label='Vertical Scale, Chan 4 (V/div)', default=0.5) + info.param(pname('horiz'), label='Horizontal Scale (s/div)', default=20e-6) + info.param(pname('sample_rate'), label='Sampling Rate (Hz)', default=2.5e9) + info.param(pname('length'), label='Data Length', default='1k', values=['1k', '10k', '100k', '1M', '5M']) + info.param(pname('save_wave'), label='Save Waveforms?', default='No', values=['Yes', 'No']) + + +GROUP_NAME = 'dpo3000' + + +class DAS(das.DAS): + """ + Template for data acquisition (DAS) implementations. This class can be used as a base class or + independent data acquisition classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name, points=None, sc_points=None, support_interfaces=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points, support_interfaces=None) + self.sample_interval = self._param_value('sample_interval') + + self.params['sample_interval'] = self.sample_interval + self.params['visa_id'] = self._param_value('visa_id') + self.params['comm'] = self._param_value('comm') + self.params['ts'] = ts + self.params['channel_types'] = [] + + # create channel info for each channel from parameters + for i in range(1, 5): + self.params['channel_types'].append(self._param_value('chan_%d' % i)) + + self.params['vertical_scale'] = [self._param_value('vert_1'), + self._param_value('vert_2'), + self._param_value('vert_3'), + self._param_value('vert_4')] + self.params['horiz_scale'] = self._param_value('horiz') + self.params['sample_rate'] = self._param_value('sample_rate') + self.params['save_wave'] = self._param_value('save_wave') + + if self._param_value('length') == '1k': + self.params['length'] = 1000 + if self._param_value('length') == '10k': + self.params['length'] = 10000 + if self._param_value('length') == '100k': + self.params['length'] = 100000 + if self._param_value('length') == '1M': + self.params['length'] = 1000000 + if self._param_value('length') == '5M': + self.params['length'] = 5000000 + + self.params['trig_chan'] = self._param_value('trigger_chan') + self.params['trig_level'] = self._param_value('trigger_level') + self.params['trig_slope'] = self._param_value('trigger_slope') + + self.device = device_tektronix_dpo3000.Device(self.params) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/das_typhoon.py b/Lib/svpelab/das_typhoon.py index c4c10c1..25eabdf 100644 --- a/Lib/svpelab/das_typhoon.py +++ b/Lib/svpelab/das_typhoon.py @@ -32,8 +32,8 @@ import os -import device_das_typhoon -import das +from . import device_das_typhoon +from . import das typhoon_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -51,6 +51,7 @@ def params(info, group_name=None): info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + info.param(pname('map'), label='Typhoon Analog Channel Map', default='ASGC') GROUP_NAME = 'typhoon' @@ -59,11 +60,21 @@ class DAS(das.DAS): def __init__(self, ts, group_name, points=None, sc_points=None): das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) - self.device = device_das_typhoon.Device() - self.sample_interval = self._param_value('sample_interval') + self.params['ts'] = ts + self.params['map'] = self._param_value('map') + self.params['sample_interval'] = self._param_value('sample_interval') - if self.sample_interval < 50: - raise das.DASError('Parameter error: sample interval must be at least 50 ms') + self.device = device_das_typhoon.Device(self.params) + self.data_points = self.device.data_points + + # self.wfm_channels = device_das_typhoon.wfm_channels + # self.wfm_typhoon_channels = device_das_typhoon.wfm_typhoon_channels + + # initialize soft channel points + self._init_sc_points() + + if self.sample_interval < 50 and self.sample_interval is not 0: + raise das.DASError('Parameter error: sample interval must be at least 50 ms or 0 for manual sampling') def _param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) diff --git a/Lib/svpelab/das_wt1600.py b/Lib/svpelab/das_wt1600.py new file mode 100644 index 0000000..11e8e91 --- /dev/null +++ b/Lib/svpelab/das_wt1600.py @@ -0,0 +1,134 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os + +from . import device_wt1600 +from . import das + +wt1600_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Yokogawa WT1600' +} + +def das_info(): + return wt1600_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = wt1600_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Network', values=['Network', 'VISA']) + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['Network'], default='192.168.0.10') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['Network'], default=10001) + info.param(pname('username'), label='Username', + active=pname('comm'), active_value=['Network'], default='anonymous') + info.param(pname('password'), label='Password', + active=pname('comm'), active_value=['Network'], default='') + info.param(pname('visa_id'), label='visa_id', + active=pname('comm'), active_value=['VISA'], default='GPIB0::13::INSTR') + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + + info.param(pname('chan_1'), label='Channel 1', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_2'), label='Channel 2', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_3'), label='Channel 3', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_4'), label='Channel 4', default='DC', values=['AC', 'DC', 'Unused']) + + info.param(pname('chan_1_label'), label='Channel 1 Label', default='1', active=pname('chan_1'), + active_value=['AC', 'DC']) + info.param(pname('chan_2_label'), label='Channel 2 Label', default='2', active=pname('chan_2'), + active_value=['AC', 'DC']) + info.param(pname('chan_3_label'), label='Channel 3 Label', default='3', active=pname('chan_3'), + active_value=['AC', 'DC']) + info.param(pname('chan_4_label'), label='Channel 4 Label', default='', active=pname('chan_4'), + active_value=['AC', 'DC']) + + ''' + info.param(pname('wiring_system'), label='Wiring System', default='1P2W', values=['1P2W', '1P3W', '3P3W', + '3P4W', '3P3W(3V3A)']) + ''' + # info.param(pname('ip_port'), label='IP Port', + # active=pname('comm'), active_value=['Network'], default=4944) + # info.param(pname('ip_timeout'), label='IP Timeout', + # active=pname('comm'), active_value=['Network'], default=5) + +GROUP_NAME = 'wt1600' + + +class DAS(das.DAS): + """ + Template for data acquisition (DAS) implementations. This class can be used as a base class or + independent data acquisition classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name, points=None, sc_points=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) + self.sample_interval = self._param_value('sample_interval') + + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['ipport'] = self._param_value('ip_port') + self.params['username'] = self._param_value('username') + self.params['password'] = self._param_value('password') + self.params['timeout'] = self._param_value('ip_timeout') + self.params['visa_id'] = self._param_value('visa_id') + self.params['comm'] = self._param_value('comm') + self.params['ts'] = ts + + # create channel info for each channel from parameters + channels = [None] + for i in range(1, 5): + chan_type = self._param_value('chan_%d' % (i)) + chan_label = self._param_value('chan_%d_label' % (i)) + if chan_label == 'None': + chan_label = '' + chan = {'type': chan_type, 'points': self.points.get(chan_type), 'label': chan_label} + channels.append(chan) + + self.params['channels'] = channels + self.device = device_wt1600.Device(self.params) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + + pass \ No newline at end of file diff --git a/Lib/svpelab/das_wt3000.py b/Lib/svpelab/das_wt3000.py new file mode 100644 index 0000000..f543b14 --- /dev/null +++ b/Lib/svpelab/das_wt3000.py @@ -0,0 +1,134 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os + +from . import device_wt3000 +from . import das + +wt3000_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Yokogawa WT3000' +} + +def das_info(): + return wt3000_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = wt3000_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('comm'), label='Communications Interface', default='Network', values=['Network', 'VISA']) + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['Network'], default='192.168.0.10') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['Network'], default=10001) + info.param(pname('username'), label='Username', + active=pname('comm'), active_value=['Network'], default='anonymous') + info.param(pname('password'), label='Password', + active=pname('comm'), active_value=['Network'], default='') + info.param(pname('visa_id'), label='visa_id', + active=pname('comm'), active_value=['VISA'], default='GPIB0::13::INSTR') + info.param(pname('sample_interval'), label='Sample Interval (ms)', default=1000) + + info.param(pname('chan_1'), label='Channel 1', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_2'), label='Channel 2', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_3'), label='Channel 3', default='AC', values=['AC', 'DC', 'Unused']) + info.param(pname('chan_4'), label='Channel 4', default='DC', values=['AC', 'DC', 'Unused']) + + info.param(pname('chan_1_label'), label='Channel 1 Label', default='1', active=pname('chan_1'), + active_value=['AC', 'DC']) + info.param(pname('chan_2_label'), label='Channel 2 Label', default='2', active=pname('chan_2'), + active_value=['AC', 'DC']) + info.param(pname('chan_3_label'), label='Channel 3 Label', default='3', active=pname('chan_3'), + active_value=['AC', 'DC']) + info.param(pname('chan_4_label'), label='Channel 4 Label', default='', active=pname('chan_4'), + active_value=['AC', 'DC']) + + ''' + info.param(pname('wiring_system'), label='Wiring System', default='1P2W', values=['1P2W', '1P3W', '3P3W', + '3P4W', '3P3W(3V3A)']) + ''' + # info.param(pname('ip_port'), label='IP Port', + # active=pname('comm'), active_value=['Network'], default=4944) + # info.param(pname('ip_timeout'), label='IP Timeout', + # active=pname('comm'), active_value=['Network'], default=5) + +GROUP_NAME = 'wt3000' + + +class DAS(das.DAS): + """ + Template for data acquisition (DAS) implementations. This class can be used as a base class or + independent data acquisition classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name, points=None, sc_points=None): + das.DAS.__init__(self, ts, group_name, points=points, sc_points=sc_points) + self.sample_interval = self._param_value('sample_interval') + + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['ipport'] = self._param_value('ip_port') + self.params['username'] = self._param_value('username') + self.params['password'] = self._param_value('password') + self.params['timeout'] = self._param_value('ip_timeout') + self.params['visa_id'] = self._param_value('visa_id') + self.params['comm'] = self._param_value('comm') + self.params['ts'] = ts + + # create channel info for each channel from parameters + channels = [None] + for i in range(1, 5): + chan_type = self._param_value('chan_%d' % (i)) + chan_label = self._param_value('chan_%d_label' % (i)) + if chan_label == 'None': + chan_label = '' + chan = {'type': chan_type, 'points': self.points.get(chan_type), 'label': chan_label} + channels.append(chan) + + self.params['channels'] = channels + self.device = device_wt3000.Device(self.params) + self.data_points = self.device.data_points + + # initialize soft channel points + self._init_sc_points() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + +if __name__ == "__main__": + + pass \ No newline at end of file diff --git a/Lib/svpelab/dataset.py b/Lib/svpelab/dataset.py index 28a0abd..9892467 100644 --- a/Lib/svpelab/dataset.py +++ b/Lib/svpelab/dataset.py @@ -29,7 +29,7 @@ Questions can be directed to support@sunspec.org """ - +import datetime class DatasetError(Exception): """ @@ -58,33 +58,55 @@ class DatasetError(Exception): A dataset consists of a set of time series points organized as parallel arrays and some additional optional properties. - Optional roperties: + Optional properties: Start time of dataset Sample rate of dataset (samples/sec) Trigger sample (record index into dataset) """ class Dataset(object): - def __init__(self, points=None, data=None, start_time=None, sample_rate=None, trigger_sample=None, params=None): + def __init__(self, points=None, data=None, start_time=None, sample_rate=None, trigger_sample=None, params=None, + ts=None): self.start_time = start_time # start time self.sample_rate = sample_rate # samples/second self.trigger_sample = trigger_sample # trigger sample self.points = points # point names self.data = data # data + self.ts = ts if points is None: self.points = [] if data is None: self.clear() + def point_data(self, point): + try: + idx = self.points.index(point) + except ValueError: + raise DatasetError('Data point not in dataset: %s' % point) + return self.data[idx] + def append(self, data): dlen = len(data) + # self.ts.log_debug('self.data=%s, data=%s' % (self.data, data)) if len(data) != len(self.data): raise DatasetError('Append record point mismatch, dataset contains %s points,' ' appended data contains %s points' % (len(self.data), dlen)) for i in range(dlen): try: - v = float(data[i]) + if data[i] is not None: + if data[i] is tuple: + self.ts.log_debug('tuple data point recorded: %s' % data) + v = float(data[i][0]) + elif isinstance(data[i], datetime.datetime): + epoch = datetime.datetime.utcfromtimestamp(0) + total_seconds = (data[i] - epoch).total_seconds() + # total_seconds will be in decimals (millisecond precision) + v = total_seconds + else: + v = float(data[i]) + else: + v = 'None' except ValueError: v = data[i] self.data[i].append(v) @@ -103,13 +125,17 @@ def clear(self): self.data.append([]) def to_csv(self, filename): - cols = range(len(self.data)) + cols = list(range(len(self.data))) if len(cols) > 0: f = open(filename, 'w') f.write('%s\n' % ', '.join(map(str, self.points))) - for i in xrange(len(self.data[0])): + for i in range(len(self.data[0])): d = [] for j in cols: + # self.ts.log_debug('data = %s' % self.data) + # self.ts.log_debug('point names = %s' % self.points) + # self.ts.log_debug('len(points) = %s, len(data) = %s' % (len(self.points), len(self.data))) + # self.ts.log_debug('j = %s, i = %i, self.data[j][i] = %s' % (j, i, self.data[j][i])) d.append(self.data[j][i]) f.write('%s\n' % ', '.join(map(str, d))) f.close() @@ -130,6 +156,15 @@ def from_csv(self, filename, sep=','): if len(data) > 0: self.append(data) f.close() + + def remove_none_row(self, filename, index): + import pandas as pd + import numpy as np + df = pd.read_csv(filename) + df[index].replace('None', np.nan, inplace=True) + df.dropna(subset=[index],inplace=True) + df.reset_index(inplace=True,drop=True) + df.to_csv(filename,index=False) if __name__ == "__main__": diff --git a/Lib/svpelab/dcsim.py b/Lib/svpelab/dcsim.py index d88adc7..e566988 100644 --- a/Lib/svpelab/dcsim.py +++ b/Lib/svpelab/dcsim.py @@ -55,7 +55,7 @@ def params(info, id=None, label='DC Simulator', group_name=None, active=None, ac info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) info.param(name('auto_config'), label='Configure dc simulator at beginning of test', default='Disabled', values=['Enabled', 'Disabled']) - for mode, m in dcsim_modules.iteritems(): + for mode, m in dcsim_modules.items(): m.params(info, group_name=group_name) DCSIM_DEFAULT_ID = 'dcsim' @@ -244,10 +244,10 @@ def dcsim_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - raise DCSimError('Error scanning module %s: %s' % (module_name, str(e))) + print(DCSimError('Error scanning module %s: %s' % (module_name, str(e)))) # scan for dcsim modules on import dcsim_scan() diff --git a/Lib/svpelab/dcsim_chroma_62000P.py b/Lib/svpelab/dcsim_chroma_62000P.py index 76f8bbb..4737452 100644 --- a/Lib/svpelab/dcsim_chroma_62000P.py +++ b/Lib/svpelab/dcsim_chroma_62000P.py @@ -34,8 +34,8 @@ import time import socket import serial -import visa -import dcsim +import pyvisa as visa +from . import dcsim chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -153,7 +153,7 @@ def cmd_serial(self, cmd_str): self.conn.flushInput() self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) # Serial queries for power supply @@ -179,7 +179,7 @@ def query_serial(self, cmd_str): raise dcsim.DCSimError('Timeout waiting for response') except dcsim.DCSimError: raise - except Exception, e: + except Exception as e: raise dcsim.DCSimError('Timeout waiting for response - More data problem') return resp @@ -195,7 +195,7 @@ def cmd_tcp(self, cmd_str): # print 'cmd> %s' % (cmd_str) self.conn.send(cmd_str) - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) # TCP queries for power supply and load @@ -214,7 +214,7 @@ def query_tcp(self, cmd_str): if d == '\n': #\r more_data = False break - except Exception, e: + except Exception as e: raise dcsim.DCSimError('Timeout waiting for response') return resp @@ -230,7 +230,7 @@ def query_usb(self, cmd_str): resp = self.conn.query(cmd_str) #self.ts.log_debug('cmd_str = %s, resp = %s' % (cmd_str, resp)) - except Exception, e: + except Exception as e: raise dcsim.DCSimError('Timeout waiting for response') return resp @@ -244,7 +244,7 @@ def cmd_usb(self, cmd_str): #self.conn.write('*RST\n') self.conn.write('*CLS\n') self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) # Commands for power supply @@ -258,7 +258,7 @@ def cmd(self, cmd_str): if len(resp) > 0: if resp[0] != '0': raise dcsim.DCSimError(resp + ' ' + self.cmd_str) - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) # Queries for power supply @@ -266,7 +266,7 @@ def query(self, cmd_str): # self.ts.log_debug('query cmd_str = %s' % cmd_str) try: resp = self._query(cmd_str).strip() - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) return resp @@ -342,7 +342,7 @@ def open(self): time.sleep(2) #self.cmd('CONFigure:REMote ON\n') - except Exception, e: + except Exception as e: raise dcsim.DCSimError(str(e)) def close(self): diff --git a/Lib/svpelab/dcsim_manual.py b/Lib/svpelab/dcsim_manual.py index 5de65ee..b3b6cfa 100644 --- a/Lib/svpelab/dcsim_manual.py +++ b/Lib/svpelab/dcsim_manual.py @@ -32,7 +32,7 @@ import os import time -import dcsim +from . import dcsim chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/der.py b/Lib/svpelab/der.py index 9fbc39d..3be6ee1 100644 --- a/Lib/svpelab/der.py +++ b/Lib/svpelab/der.py @@ -36,6 +36,7 @@ der_modules = {} + def params(info, id=None, label='DER', group_name=None, active=None, active_value=None): if group_name is None: group_name = DER_DEFAULT_ID @@ -46,13 +47,14 @@ def params(info, id=None, label='DER', group_name=None, active=None, active_valu name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) info.param(name('mode'), label='%s Mode' % label, default='Disabled', values=['Disabled']) - for mode, m in der_modules.iteritems(): + for mode, m in der_modules.items(): m.params(info, group_name=group_name) + DER_DEFAULT_ID = 'der' -def der_init(ts, id=None, group_name=None): +def der_init(ts, id=None, group_name=None, support_interfaces=None): """ Function to create specific der implementation instances. """ @@ -62,13 +64,16 @@ def der_init(ts, id=None, group_name=None): group_name += '.' + DER_DEFAULT_ID if id is not None: group_name = group_name + '_' + str(id) - print 'run group_name = %s' % group_name + print('run group_name = %s' % group_name) mode = ts.param_value(group_name + '.' + 'mode') sim = None if mode != 'Disabled': sim_module = der_modules.get(mode) if sim_module is not None: - sim = sim_module.DER(ts, group_name) + try: + sim = sim_module.DER(ts, group_name, support_interfaces=support_interfaces) + except TypeError as e: + sim = sim_module.DER(ts, group_name) else: raise DERError('Unknown DER system mode: %s' % mode) @@ -84,37 +89,17 @@ class DERError(Exception): class DER(object): """ - Template for grid simulator implementations. This class can be used as a base class or - independent grid simulator classes can be created containing the methods contained in this class. + Template for DER/EUT implementations. This class can be used as a base class or + independent DER classes can be created containing the methods contained in this class. """ - def __init__(self, ts, group_name): + def __init__(self, ts, group_name, support_interfaces=None): self.ts = ts self.group_name = group_name - - ''' - self.connect_settings = {'enable': False, - 'conn': True, - 'win_tms': 0, - 'rvrt_tms': 0} - - self.fixed_pf_params = {'enable': False, - 'pf': 1.0, - 'win_tms': 0, - 'rmp_tms': 0, - 'rvrt_tms': 0} - - self.max_power_params = {'enable': False, - 'wmax_pct': 100, - 'win_tms': 0, - 'rmp_tms': 0, - 'rvrt_tms': 0} - - self.volt_var_params = {'enable': False, - 'active_curve': 0, - 'max_curves': 1, - 'max_points'} - ''' + self.hil = None + if support_interfaces is not None: + if support_interfaces.get('hil') is not None: + self.hil = support_interfaces.get('hil') def config(self): """ Perform any configuration for the simulation based on the previously provided parameters. """ @@ -128,285 +113,455 @@ def close(self): """ Close any open communications resources associated with the grid simulator. """ pass - """ - WRtg - VARtg - VArRtgQ1 - VArRtgQ2 - VArRtgQ3 - VArRtgQ4 - ARtg - PFRtgQ1 - PFRtgQ2 - PFRtgQ3 - PFRtgQ4 - WHRtg - AhrRtg - MaxChaRte - MaxDisChaRte - """ def nameplate(self): + """ + returns a dict with the following keys: + WRtg + VARtg + VArRtgQ1 + VArRtgQ2 + VArRtgQ3 + VArRtgQ4 + ARtg + PFRtgQ1 + PFRtgQ2 + PFRtgQ3 + PFRtgQ4 + WHRtg + AhrRtg + MaxChaRte + MaxDisChaRte + """ + pass + + def measurements(self): + """ Get measurement data. + + Params: + A - Current + AphA - Current on Phase A + AphB - Current on Phase B + AphC - Current on Phase C + PPVphAB - Phase-phase voltage between A and B phases + PPVphBC - Phase-phase voltage between B and C phases + PPVphCA - Phase-phase voltage between C and A phases + PhVphA - Phase A voltage + PhVphB - Phase B voltage + PhVphC - Phase C voltage + W - Power + Hz - Frequency + VA - Apparent Power + VAr - Reactive Power + PF - Power factor (displacement power factor) + WH - Energy (watt-hours) + DCA - DC current + DCV - DC voltage + DCW - DC power + TmpCab - Cabinet temperature + TmpSnk - Heatsink temperature + TmpTrns - + TmpOt - + St - + StVnd - + Evt1 - + Evt2 - + EvtVnd1 - + EvtVnd2 - + EvtVnd3 - + EvtVnd4 - + + :return: Dictionary of measurement data + """ pass - """ - WMax - VRef - VRefOfs - VMax - VMin - VAMax - VArMaxQ1 - VArMaxQ2 - VArMaxQ3 - VArMaxQ4 - WGra - PFMinQ1 - PFMinQ2 - PFMinQ3 - PFMinQ4 - VArAct - """ def settings(self, params=None): + """ + Get/set DER settings. + + :param params: Dictionary of parameters to be updated. + Params keys: + WMax - Active power maximum + VRef - Reference voltage + VRefOfs - Reference voltage offset + VMax - Voltage maximum + VMin - Voltage minimum + VAMax - Apparent power maximum + VArMaxQ1, VArMaxQ2, VArMaxQ3, VArMaxQ4 - VAr maximum for each quadrant + WGra - Default active power ramp rate + PFMinQ1, PFMinQ2, PFMinQ3, PFMinQ4 + VArAct + + :return: Dictionary of active settings. + """ + pass def conn_status(self, params=None): """ Get status of controls (binary True if active). - :return: Dictionary of active controls. + + :return: binary of connection status """ pass def controls_status(self, params=None): """ Get status of controls (binary True if active). - :return: Dictionary of active controls. + :return: Dictionary of active controls. """ pass - """ - 'Conn': True/False - 'WinTms': 0 - 'RvrtTms': 0 - """ def connect(self, params=None): """ Get/set connect/disconnect function settings. - :param params: Dictionary of parameters. Following keys are supported: enable, conn, win_tms, rvrt_tms. - :return: Dictionary of active settings for fixed factor. - """ - - if params is None: - # get current settings - params = {} - else: - # apply params - pass + Params: + Conn - Connected (True/False) + WinTms - Randomized start time delay in seconds + RvrtTms - Reversion time in seconds - return params + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for connect. + """ + pass - """ - 'ModEna': True/False - 'PF': 1.0 - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - """ def fixed_pf(self, params=None): """ Get/set fixed power factor control settings. - :param params: Dictionary of parameters. Following keys are supported: enable, pf, win_tms, rmp_tms, rvrt_tms. + :param params: Dictionary of parameters. Following keys are supported: + 'Ena': True/False + 'PF': 1.0 + 'WinTms': 0 + 'RmpTms': 0 + 'RvrtTms': 0 :return: Dictionary of active settings for fixed factor. """ - if params is None: - params = self.fixed_pf_params - else: - self.fixed_pf_params = params - return params + pass - """ - 'ModEna': True/False - 'WMaxPct': 100 - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - """ def limit_max_power(self, params=None): + """ Get/set max active power control settings. - if params is None: - params = self.max_power_params - else: - self.max_power_params = params - return params + Params: + Ena - Enabled (True/False) + WMaxPct - Active power maximum as percentage of WMax + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for limit max power. + """ + pass - """ - 'ModEna': True/False - 'ActCrv': 0 - 'NCrv': 1 - 'NPt': 4 - 'ActPt': 4 - 'RmpTmsCv': 0 - 'RmpDecTmm': 0 - 'RmpIncrTmm': 0 - """ def volt_var(self, params=None): + """ Get/set volt/var control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/var control. + """ pass - """ - 'x': [] # %VRef - 'y': [] # units based on dep_ref - 'DepRef': 'var_max_pct', 'var_aval_pct', 'va_max_pct', 'w_max_pct' - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - 'RmpTmsCv': 0 - 'RmpDecTmm': 0 - 'RmpIncrTmm': 0 - """ def volt_var_curve(self, id, params=None): + """ Get/set volt/var curve + v [] - List of voltage curve points + var [] - List of var curve points based on DeptRef + DeptRef - Dependent reference type: 'VAR_MAX_PCT', 'VAR_AVAL_PCT', 'VA_MAX_PCT', 'W_MAX_PCT' + RmpTms - Ramp timer + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/var curve control. + """ pass - """ Example param dict - 'Ena': True - 'ActCrv': 1 - 'NCrv': 2 - 'NPt': 3 - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - """ def freq_watt(self, params=None): + """ Get/set freq/watt control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for freq/watt control. + """ pass - """ Example param dict - 'hz': [] - List of frequency curve points - 'w': [] - List of power curve points - 'CrvNam': 'VDE 4105' - Optional description for curve. (Max 16 chars) - 'RmpPT1Tms': 1 - The time of the PT1 in seconds (time to accomplish a change of 95%). - 'RmpDecTmm': 0 - Ramp decrement timer - 'RmpIncTmm': 0 - Ramp increment timer - 'RmpRsUp': 0 - The maximum rate at which the power may be increased after releasing the frozen value of - snap shot function. - 'SnptW': 0 - 1=enable snapshot/capture mode - 'WRef': 0 - Reference active power (default = WMax). - 'WRefStrHz': 0 - Frequency deviation from nominal frequency at the time of the snapshot to start constraining - power output. - 'WRefStopHz': 0 - Frequency deviation from nominal frequency at which to release the power output. - 'ReadOnly': 0 - 0 = READWRITE, 1 = READONLY - """ def freq_watt_curve(self, id, params=None): + """ Get/set freq/watt curve + hz [] - List of frequency curve points + w [] - List of power curve points + CrvNam - Optional description for curve. (Max 16 chars) + RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of + snap shot function. + SnptW - 1=enable snapshot/capture mode + WRef - Reference active power (default = WMax). + WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining + power output. + WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. + ReadOnly - 0 = READWRITE, 1 = READONLY + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for freq/watt curve. + """ pass - """ Example param dict - 'Ena': True - Enabled (True/False) - 'HysEna': 1 - Enable hysterisis (True/False) - 'WGra': 0.4 - The slope of the reduction in the maximum allowed watts output as a function of frequency. - 'HzStr': 0.2 - The frequency deviation from nominal frequency (ECPNomHz) at which a snapshot of the instantaneous - power output is taken to act as the CAPPED power level (PM) and above which reduction in power - output occurs. - 'HzStop': 1.4 - The frequency deviation from nominal frequency (ECPNomHz) at which curtailed power output may - return to normal and the cap on the power level value is removed. - 'HzStopWGra' : 1/300 - The maximum time-based rate of change at which power output returns to normal after having - been capped by an over frequency event. - """ def freq_watt_param(self, params=None): + """ Get/set frequency-watt with parameters + + Params: + Ena - Enabled (True/False) + HysEna - Enable hysteresis (True/False) + WGra - The slope of the reduction in the maximum allowed watts output as a function of frequency. + HzStr - The frequency deviation from nominal frequency (ECPNomHz) at which a snapshot of the instantaneous + power output is taken to act as the CAPPED power level (PM) and above which reduction in power + output occurs. + HzStop - The frequency deviation from nominal frequency (ECPNomHz) at which curtailed power output may + return to normal and the cap on the power level value is removed. + HzStopWGra - The maximum time-based rate of change at which power output returns to normal after having + been capped by an over frequency event. + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for frequency-watt with parameters control. + """ pass + def soft_start_ramp_rate(self, params=None): + pass - """ volt/watt control - 'ModEna': True/False - 'ActCrv': 0 - 'NCrv': 1 - 'NPt': 4 - 'WinTms': 0 - 'RvrtTms': 0 - 'RmpTms': 0 - 'curve': { - 'ActPt': 3 - 'v': [95, 101, 105] - 'w': [100, 100, 0] - 'DeptRef': 1 - 'RmpPt1Tms': 0 - 'RmpDecTmm': 0 - 'RmpIncTmm': 0 - } - """ - def volt_watt(self, params=None): + def ramp_rate(self, params=None): pass + def volt_watt(self, params=None): + """ Get/set volt/watt control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + curve - curve parameters in the repeating block in another dictionary with parameters: + v [] - List of voltage curve points (e.g., [95, 101, 105]) + w [] - List of watt curve points based on DeptRef (e.g., [100, 100, 0]) + DeptRef - Dependent reference type: 'W_MAX_PCT', 'W_AVAL_PCT' + RmpTms - Ramp timer + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/watt control. + """ + pass - """ - 'Q': 0 # %Qmax (positive is overexcited, negative is underexcited) - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - """ def reactive_power(self, params=None): + """ Set the reactive power + + Params: + Ena - Enabled (True/False) + VArPct_Mod - Reactive power mode + 'None' : 0, + 'WMax': 1, + 'VArMax': 2, + 'VArAval': 3, + VArWMaxPct - Reactive power in percent of WMax. (positive is overexcited, negative is underexcited) + VArMaxPct - Reactive power in percent of VArMax. (positive is overexcited, negative is underexcited) + VArAvalPct - Reactive power in percent of VArAval. (positive is overexcited, negative is underexcited) + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for Q control. + """ + pass - """ - 'P': 0 # %Wmax (positive is exporting (discharging), negative is importing (charging) power) - 'WinTms': 0 - 'RmpTms': 0 - 'RvrtTms': 0 - """ def active_power(self, params=None): - pass + """ Get/set active power of EUT - """ Get/set normal ramp rate - rate - Normal ramp rate in % rated current/sec - """ - def ramp_rate(self, rate=None): - pass + Params: + Ena - Enabled (True/False) + P - Active power in %Wmax (positive is exporting (discharging), negative is importing (charging) power) + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds - """ Get/set soft start ramp rate - rate - Soft start ramp rate in % rated current/sec - """ - def soft_start_ramp_rate(self, rate=None): + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HFRT control. + """ pass - """ Get/set storage parameters - 'WChaMax': 0 - Setpoint for maximum charge. - 'WChaGra': 0 - Setpoint for maximum charging rate. Default is MaxChaRte. - 'WDisChaGra': 0 - Setpoint for maximum discharge rate. Default is MaxDisChaRte. - 'StorCtl_Mod': 0 - Activate hold/discharge/charge storage control mode. Bitfield value. - 'VAChaMax': 0 - Setpoint for maximum charging VA. - 'MinRsvPct': 0 - Setpoint for minimum reserve for storage as a percentage of the nominal maximum storage. - 'ChaState' (Read only) - Currently available energy as a percent of the capacity rating. - 'StorAval' (Read only) - State of charge (ChaState) minus storage reserve (MinRsvPct) times capacity rating (AhrRtg). - 'InBatV' (Read only) - Internal battery voltage. - 'ChaSt' (Read only) - Charge status of storage device. Enumerated value. - 'OutWRte': 0 - Percent of max discharge rate. - 'InWRte': 0 - Percent of max charging rate. - 'InOutWRte_WinTms': 0 - Time window for charge/discharge rate change. - 'InOutWRte_RvrtTms': 0 - Timeout period for charge/discharge rate. - 'InOutWRte_RmpTms': 0 - Ramp time for moving from current setpoint to new setpoint. - """ def storage(self, params=None): + """ Get/set storage parameters + + Params: + WChaMax - Setpoint for maximum charge. + WChaGra - Setpoint for maximum charging rate. Default is MaxChaRte. + WDisChaGra - Setpoint for maximum discharge rate. Default is MaxDisChaRte. + StorCtl_Mod - Activate hold/discharge/charge storage control mode. Bitfield value. + VAChaMax - Setpoint for maximum charging VA. + MinRsvPct - Setpoint for minimum reserve for storage as a percentage of the nominal maximum storage. + ChaState (Read only) - Currently available energy as a percent of the capacity rating. + StorAval (Read only) - State of charge (ChaState) - (storage reserve (MinRsvPct) * capacity rating (AhrRtg)) + InBatV (Read only) - Internal battery voltage. + ChaSt (Read only) - Charge status of storage device. Enumerated value. + OutWRte - Percent of max discharge rate. + InWRte - Percent of max charging rate. + InOutWRte_WinTms - Time window for charge/discharge rate change. + InOutWRte_RvrtTms - Timeout period for charge/discharge rate. + InOutWRte_RmpTms - Ramp time for moving from current setpoint to new setpoint. + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings. + """ pass def frt_stay_connected_high(self, params=None): + """ Get/set high frequency ride through (must stay connected curve) + + Params: + curve: + t - Time point in the curve + Hz - Frequency point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HFRT control. + """ pass def frt_stay_connected_low(self, params=None): + """ Get/set high frequency ride through (must stay connected curve) + + Params: + curve: + t - Time point in the curve + Hz - Frequency point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LFRT control. + """ pass def frt_trip_high(self, params=None): + """ Get/set high frequency ride through (trip curve) + + Params: params = {'curve': 't': [299., 10.], 'Hz': [61.0, 61.8]} + curve: + t - Time point in the curve + Hz - Frequency point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HFT control. + """ pass def frt_trip_low(self, params=None): + """ Get/set lower frequency ride through (trip curve) + + Params: params = {'curve': 't': [299., 10.], 'Hz': [59.0, 58.2]} + curve: + t - Time point in the curve + Hz - Frequency point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LFT control. + """ pass def vrt_stay_connected_high(self, params=None): + """ Get/set high voltage ride through (must stay connected curve) + + Params: + curve: + t - Time point in the curve + v - voltage point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HVRT control. + """ pass def vrt_stay_connected_low(self, params=None): - pass + """ Get/set low voltage ride through (must stay connected curve) + + Params: + curve: + t - Time point in the curve + v - voltage point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LVRT control. + """ def vrt_trip_high(self, params=None): + """ Get/set high voltage ride through (trip curve) + + Params: params = {'curve': 't': [60., 10.], 'V': [110.0, 120.0]} + curve: + t - Time point in the curve + Hz - Voltage point in the curve % of Vnom + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HVT control. + """ pass def vrt_trip_low(self, params=None): + """ Get/set lower voltage ride through (trip curve) + + Params: params = {'curve': 't': [60., 10.], 'V': [110.0, 120.0]} + curve: + t - Time point in the curve + Hz - Voltage point in the curve % of Vnom + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LVT control. + """ pass + def watt_var(self, params=None): + """watt/var control + + :param params: Dictionary of parameters to be updated. + 'ModEna': True/False + 'ActCrv': 0 + 'NCrv': 1 + 'NPt': 4 + 'WinTms': 0 + 'RvrtTms': 0 + 'RmpTms': 0 + 'curve': { + 'ActPt': 3 + 'w': [50, 75, 100] + 'var': [0, 0, -100] + 'DeptRef': 1 + 'RmpPt1Tms': 0 + 'RmpDecTmm': 0 + 'RmpIncTmm': 0 + } + :return: Dictionary of active settings for volt_watt + """ + pass + + def deactivate_all_fct(self): + pass + + def der_scan(): global der_modules # scan all files in current directory that match der_*.py @@ -421,6 +576,7 @@ def der_scan(): m = importlib.import_module(module_name) if hasattr(m, 'der_info'): info = m.der_info() + print('DER Info %s' % info) mode = info.get('mode') # place module in module dict if mode is not None: @@ -428,10 +584,10 @@ def der_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - raise DERError('Error scanning module %s: %s' % (module_name, str(e))) + print(DERError('Error scanning module %s: %s' % (module_name, str(e)))) # scan for der modules on import der_scan() diff --git a/Lib/svpelab/der1547.py b/Lib/svpelab/der1547.py new file mode 100644 index 0000000..46d5d52 --- /dev/null +++ b/Lib/svpelab/der1547.py @@ -0,0 +1,937 @@ +""" +Copyright (c) 2017, Sandia National Laboratories and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org + +Acronyms in this document: + - RofA: Range of Adjustability + - OLRT: Open Loop Response Time + - ER: Evaluated Range + - AS: Applied Setting + +""" + +import sys +import os +import glob +import importlib + +der1547_modules = {} + + +def params(info, id=None, label='DER1547', group_name=None, active=None, active_value=None): + """ + Defining the parameters when der1547 is used in SVP scripts + """ + if group_name is None: + group_name = DER1547_DEFAULT_ID + else: + group_name += '.' + DER1547_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + name = lambda name: group_name + '.' + name + info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) + info.param(name('mode'), label='%s Mode' % label, default='Disabled', values=['Disabled']) + for mode, m in list(der1547_modules.items()): + m.params(info, group_name=group_name) + + +DER1547_DEFAULT_ID = 'der1547' + + +def der1547_init(ts, id=None, group_name=None): + """ + Function to create specific der1547 implementation instances. + """ + if group_name is None: + group_name = DER1547_DEFAULT_ID + else: + group_name += '.' + DER1547_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + print(('run group_name = %s' % group_name)) + mode = ts.param_value(group_name + '.' + 'mode') + sim = None + if mode != 'Disabled': + sim_module = der1547_modules.get(mode) + if sim_module is not None: + sim = sim_module.DER1547(ts, group_name) + else: + raise DER1547Error('Unknown DER1547 system mode: %s' % mode) + + return sim + + +class DER1547Error(Exception): + """ + Exception to wrap all der1547 generated exceptions. + """ + pass + + +class DER1547(object): + """ + Template for grid simulator implementations. This class can be + used as a base class or independent grid simulator classes can be + created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name): + self.ts = ts + self.group_name = group_name + + def config(self): + """ Perform any configuration for the simulation + based on the previously provided parameters. """ + pass + + def open(self): + """ Open the communications resources associated with the DER. """ + pass + + def close(self): + """ Close any open communications resources associated with the DER. """ + pass + + def info(self): + """ + :return: string with information on the IEEE 1547 DER type. + """ + pass + + def get_nameplate(self): + """ + Get Nameplate information - See IEEE 1547-2018 Table 28 + ______________________________________________________________________________________________________________ + Parameter params dict key Units + ______________________________________________________________________________________________________________ + Active power rating at unity power factor np_p_max kW + (nameplate active power rating) + Active power rating at specified over-excited np_p_max_over_pf kW + power factor + Specified over-excited power factor np_over_pf Decimal + Active power rating at specified under-excited np_p_max_under_pf kW + power factor + Specified under-excited power factor np_under_pf Decimal + Apparent power maximum rating np_va_max kVA + Normal operating performance category np_normal_op_cat str + e.g., CAT_A-CAT_B + Abnormal operating performance category np_abnormal_op_cat str + e.g., CAT_II-CAT_III + Intentional Island Category (optional) np_intentional_island_cat str + e.g., UNCAT-INT_ISLAND_CAP-BLACK_START-ISOCH + Reactive power injected maximum rating np_q_max_inj kVAr + Reactive power absorbed maximum rating np_q_max_abs kVAr + Active power charge maximum rating np_p_max_charge kW + Apparent power charge maximum rating np_apparent_power_charge_max kVA + AC voltage nominal rating np_ac_v_nom Vac + AC voltage maximum rating np_ac_v_max_er_max Vac + AC voltage minimum rating np_ac_v_min_er_min Vac + Supported control mode functions np_supported_modes dict + e.g., {'fixed_pf': True 'volt_var': False} with keys: + Supports Low Voltage Ride-Through Mode: 'lv_trip' + Supports High Voltage Ride-Through Mode: 'hv_trip' + Supports Low Freq Ride-Through Mode: 'lf_trip' + Supports High Freq Ride-Through Mode: 'hf_trip' + Supports Active Power Limit Mode: 'max_w' + Supports Volt-Watt Mode: 'volt_watt' + Supports Frequency-Watt Curve Mode: 'freq_watt' + Supports Constant VArs Mode: 'fixed_var' + Supports Fixed Power Factor Mode: 'fixed_pf' + Supports Volt-VAr Control Mode: 'volt_var' + Supports Watt-VAr Mode: 'watt_var' + Reactive susceptance that remains connected to np_reactive_susceptance Siemens + the Area EPS in the cease to energize and trip + state + Maximum resistance (R) between RPA and POC. np_remote_meter_resistance Ohms + (unsupported in 1547) + Maximum reactance (X) between RPA and POC. np_remote_meter_reactance Ohms + (unsupported in 1547) + Manufacturer np_manufacturer str + Model np_model str + Serial number np_serial_num str + Version np_fw_ver str + + :return: dict with keys shown above. + """ + + ''' + Table 28 - Nameplate information + ________________________________________________________________________________________________________________ + Parameter Description + ________________________________________________________________________________________________________________ + 1. Active power rating at unity power factor Active power rating in watts at unity power factor + (nameplate active power rating) + 2. Active power rating at specified over-excited Active power rating in watts at specified over-excited + power factor power factor + 3. Specified over-excited power factor Over-excited power factor as described in 5.2 + 4. Active power rating at specified under-excited Active power rating in watts at specified under-excited + power factor power factor + 5. Specified under-excited power factor Under-excited power factor as described in 5.2 + 6. Apparent power maximum rating Maximum apparent power rating in voltamperes + 7. Normal operating performance category Indication of reactive power and voltage/power control + capability. (Category A/B as described in 1.4) + 8. Abnormal operating performance category Indication of voltage and frequency ride-through + capability Category I, II, or III, as described in 1.4 + 9. Reactive power injected maximum rating Maximum injected reactive power rating in vars + 10. Reactive power absorbed maximum rating Maximum absorbed reactive power rating in vars + 11. Active power charge maximum rating Maximum active power charge rating in watts + 12. Apparent power charge maximum rating Maximum apparent power charge rating in voltamperes. May + differ from the apparent power maximum rating + 13. AC voltage nominal rating Nominal AC voltage rating in RMS volts + 14. AC voltage maximum rating Maximum AC voltage rating in RMS volts + 15. AC voltage minimum rating Minimum AC voltage rating in RMS volts + 16. Supported control mode functions Indication of support for each control mode function + 17. Reactive susceptance that remains connected to Reactive susceptance that remains connected to the Area + the Area EPS in the cease to energize and trip EPS in the cease to energize and trip state + state + 18. Manufacturer Manufacturer + 19. Model Model + 20. Serial number Serial number + 21. Version Version + ''' + pass + + def get_configuration(self): + """ + Get configuration information in the 1547 DER. Each rating in Table 28 may have an associated configuration + setting that represents the as-configured value. If a configuration setting value is different from the + corresponding nameplate value, the configuration setting value shall be used as the rating within the DER. + + :return: params dict with keys shown in nameplate. + """ + return None + + def set_configuration(self, params=None): + """ + Set configuration information. params are those in get_nameplate(). + """ + pass + + def configuration(self, params=None): + if params is None: + return self.get_configuration() + else: + return self.set_configuration(params=params) + + def get_settings(self): + """ + Get configuration information in the 1547 DER. Each rating in Table 28 may have an associated configuration + setting that represents the as-configured value. If a configuration setting value is different from the + corresponding nameplate value, the configuration setting value shall be used as the rating within the DER. + + :return: params dict with keys shown in nameplate. + """ + return None + + def set_settings(self, params=None): + """ + Set configuration information. params are those in get_nameplate(). + """ + return self.set_configuration(params) + + def settings(self, params=None): + if params is None: + return self.get_settings() + else: + return self.set_settings(params=params) + + def get_monitoring(self): + """ + This information is indicative of the present operating conditions of the + DER. This information may be read. + + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Active Power mn_w kW + Reactive Power mn_var kVAr + Voltage (list) mn_v V-N list + Single phase devices: [V] + 3-phase devices: [V1, V2, V3] + Frequency mn_hz Hz + + Operational State mn_st bool + 'On': True, DER is operating (e.g., generating) + 'Off': False, DER is not operating + + Connection State mn_conn bool + 'Connected': DER connected + 'Disconnected': DER not connected + + DER State (not in IEEE 1547.1) mn_der_st dict of bools + {'mn_der_st_local': System in local/maintenance state # DNP3 points + 'mn_der_st_lockout': System locked out + 'mn_der_st_starting': Start command has been received + 'mn_der_st_stopping': Emergency Stop command has been received + 'mn_der_st_started': Started + 'mn_der_st_stopped': Stopped + 'mn_der_st_permission_to_start': Start Permission Granted + 'mn_der_st_permission_to_stop': Stop Permission Granted + 'mn_der_st_connected_idle': Idle-Connected + 'mn_der_st_connected_generating': On-Connected + 'mn_der_st_connected_charging': On-Charging-Connected + 'mn_der_st_off_available': Off-Available + 'mn_der_st_off_not_available': Off-Not-Available + 'mn_der_st_switch_closed_status': Switch Closed + 'mn_der_st_switch_closed_movement': Switch Moving} + 'mn_der_st_off': OFF # SunSpec Points + 'mn_der_st_sleeping': SLEEPING + 'mn_der_st_mppt': MPPT + 'mn_der_st_throttled': THROTTLED (curtailed) + 'mn_der_st_shutting_down': SHUTTING_DOWN + 'mn_der_st_fault': FAULT + 'mn_der_st_standby': STANDBY + + Alarm Status mn_alrm dict of bools + Reported Alarm Status matches the device + present alarm condition for alarm and no + alarm conditions. For test purposes only, the + DER manufacturer shall specify at least one + way an alarm condition that is supported in + the protocol being tested can be set and + cleared. + {'mn_alm_system_comm_error': System Communication Error # Start of DNP3 alarms (BI0-9) + 'mn_alm_priority_1': System Has Priority 1 Alarms + 'mn_alm_priority_2': System Has Priority 2 Alarms + 'mn_alm_priority_3': System Has Priority 3 Alarms + 'mn_alm_storage_chg_max': Storage State of Charge at Maximum. Maximum Usable State of Charge reached. + 'mn_alm_storage_chg_high': Storage State of Charge is Too High. Maximum Reserve reached. + 'mn_alm_storage_chg_low': Storage State of Charge is Too Low. Minimum Reserve reached. + 'mn_alm_storage_chg_depleted': Storage State of Charge is Depleted. Minimum Usable State of Charge Reached. + 'mn_alm_internal_temp_high': Storage Internal Temperature is Too High + 'mn_alm_internal_temp_low': Storage External (Ambient) Temperature is Too High + 'mn_alm_ground_fault': Ground Fault # Start of SunSpec Errors + 'mn_alm_over_dc_volt': DC Over Voltage + 'mn_alm_disconn_open': Disconnect Open + 'mn_alm_dc_disconn_open': DC Disconnect Open + 'mn_alm_grid_disconn': Grid Disconnect + 'mn_alm_cabinet_open': Cabinet Open + 'mn_alm_manual_shutdown': Manual Shutdown + 'mn_alm_over_temp': Over Temperature + 'mn_alm_over_freq': Frequency Above Limit + 'mn_alm_under_freq': Frequency Under Limit + 'mn_alm_over_volt': AC Voltage Above Limit + 'mn_alm_under_volt': AC Voltage Under Limit + 'mn_alm_fuse': Blown String Fuse On Input + 'mn_alm_under_temp': Under Temperature + 'mn_alm_mem_or_comm': Generic Memory Or Communication Error (Internal) + 'mn_alm_hdwr_fail': Hardware Test Failure + 'mn_alm_mfr_alrm': Manufacturer Alarm + 'mn_alm_over_cur': Over Current # IEEE 2030.5/CSIP Alarms (not covered in the above) + 'mn_alm_imbalance_volt': Voltage Imbalance + 'mn_alm_imbalance_cur': Current Imbalance + 'mn_alm_local_emgcy': Local Emergency + 'mn_alm_remote_emgcy': Remote Emergency + 'mn_alm_input_power': Low Input Power + 'mn_alm_phase_rotation': Phase Rotation} + + Operational State of Charge (not required in 1547) mn_soc_pct pct + + :return: dict with keys shown above. + """ + pass + + def get_const_pf(self): + """ + Get Constant Power Factor Mode control settings. IEEE 1547-2018 Table 30. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Setting (RofA not specified in const_pf_abs_er_min decimal + 1547) + Constant Power Factor Absorbing Setting const_pf_abs decimal + Constant Power Factor Setting (RofA not specified in const_pf_abs_er_max decimal + 1547) + Constant Power Factor Setting (RofA not specified in const_pf_inj_er_min decimal + 1547) + Constant Power Factor Injecting Setting const_pf_inj decimal + Constant Power Factor Setting (RofA not specified in const_pf_inj_er_max decimal + 1547) + Maximum response time to maintain constant power const_pf_olrt_er_min s + factor. (Not in 1547) + Maximum response time to maintain constant power const_pf_olrt s + factor. (Not in 1547) + Maximum Response time to maintain constant power const_pf_olrt_er_max s + factor. (Not in 1547) + + :return: dict with keys shown above. + """ + return None + + def set_const_pf(self, params=None): + """ + Set Constant Power Factor Mode control settings. + """ + pass + + # volt_var redirects + def get_volt_var(self): + return get_qv() + + def set_volt_var(self, params=None): + return set_qv(params=params) + + def volt_var(self, params=None): + if params is None: + return get_volt_var() + else: + return set_volt_var(params=params) + + def get_qv(self): + """ + Get Q(V) parameters. [Volt-Var] + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref Min (RofA not specified in 1547) qv_vref_er_min V p.u. + Vref (0.95-1.05) qv_vref V p.u. + Vref Max (RofA not specified in 1547) qv_vref_er_max V p.u. + + Autonomous Vref Adjustment Enable qv_vref_auto_mode bool (True=Enabled) + Vref adjustment time Constant (RofA not specified qv_vref_olrt_er_min s + in 1547) + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Vref adjustment time Constant (RofA not specified qv_vref_olrt_er_max s + in 1547) + + Q(V) Curve Point V1-4 Range of Adjustability (Min) qv_curve_v_er_min V p.u. + (RofA not specified in 1547) (list) + Q(V) Curve Point V1-4 (list, e.g., [95, 99, 101, 105]) qv_curve_v_pts V p.u. + Q(V) Curve Point V1-4 Range of Adjustability (Max) qv_curve_v_er_max V p.u. + (RofA not specified in 1547) (list) + + Q(V) Curve Point Q1-4 Range of Adjustability (Min) qv_curve_q_er_min VAr p.u. + (RofA not specified in 1547) (list) + Q(V) Curve Point Q1-4 (list) qv_curve_q_pts VAr p.u. + Q(V) Curve Point Q1-4 Range of Adjustability (Max) qv_curve_q_er_max VAr p.u. + (RofA not specified in 1547) (list) + + Q(V) Open Loop Response Time (RofA not specified in 1547) qv_olrt_er_min s + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + Q(V) Open Loop Response Time (RofA not specified in 1547) qv_olrt_er_max s + + :return: dict with keys shown above. + """ + return None + + def set_qv(self, params=None): + """ + Set Q(V) parameters. [Volt-Var] + """ + pass + + # watt_var redirects + def get_watt_var(self): + return get_qp() + + def set_watt_var(self, params=None): + return set_qp(params=params) + + def watt_var(self, params=None): + if params is None: + return get_watt_var() + else: + return set_watt_var(params=params) + + def get_qp(self): + """ + Get Q(P) parameters. [Watt-Var] - IEEE 1547 Table 32 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Active Power-Reactive Power (Watt-VAr) Enable qp_mode_enable bool + P-Q curve P1-3 Generation (RofA not Specified in 1547) qp_curve_p_gen_pts_er_min P p.u. + P-Q curve P1-3 Generation Setting (list) qp_curve_p_gen_pts P p.u. + P-Q curve P1-3 Generation (RofA not Specified in 1547) qp_curve_p_gen_pts_er_max P p.u. + + P-Q curve Q1-3 Generation (RofA not Specified in 1547) qp_curve_q_gen_pts_er_min VAr p.u. + P-Q curve Q1-3 Generation Setting (list) qp_curve_q_gen_pts VAr p.u. + P-Q curve Q1-3 Generation (RofA not Specified in 1547) qp_curve_q_gen_pts_er_max VAr p.u. + + P-Q curve P1-3 Load (RofA not Specified in 1547) qp_curve_p_load_pts_er_min P p.u. + P-Q curve P1-3 Load Setting (list) qp_curve_p_load_pts P p.u. + P-Q curve P1-3 Load (RofA not Specified in 1547) qp_curve_p_load_pts_er_max P p.u. + + P-Q curve Q1-3 Load (RofA not Specified in 1547) qp_curve_q_load_pts_er_min VAr p.u. + P-Q curve Q1-3 Load Setting (list) qp_curve_q_load_pts VAr p.u. + P-Q curve Q1-3 Load (RofA not Specified in 1547) qp_curve_q_load_pts_er_max VAr p.u. + + :return: dict with keys shown above. + """ + return None + + def set_qp(self, params=None): + """ + Set Q(P) parameters. [Watt-Var] + """ + pass + + # volt_watt redirects + def get_volt_watt(self): + return get_pv() + + def set_volt_watt(self, params=None): + return set_pv(params=params) + + def volt_watt(self, params=None): + if params is None: + return get_volt_watt() + else: + return set_volt_watt(params=params) + + def get_pv(self): + """ + Get P(V), Voltage-Active Power (Volt-Watt), Parameters + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Active Power Mode Enable pv_mode_enable bool + P(V) Curve Point V1-2 Min (RofA not specified in 1547) pv_curve_v_pts_er_min V p.u. + P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. + P(V) Curve Point V1-2 Max (RofA not specified in 1547) pv_curve_v_pts_er_max V p.u. + + P(V) Curve Point P1-2 Min (RofA not specified in 1547) pv_curve_p_pts_er_min P p.u. + P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. + P(V) Curve Point P1-2 Max (RofA not specified in 1547) pv_curve_p_pts_er_max P p.u. + + P(V) Curve Point P1-P'2 Min (RofA not specified in 1547) pv_curve_p_bidrct_pts_er_min P p.u. + P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. + P(V) Curve Point P1-P'2 Max (RofA not specified in 1547) pv_curve_p_bidrct_pts_er_max P p.u. + + P(V) Open Loop Response time min (RofA not specified pv_olrt_er_min s + in 1547) + P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + P(V) Open Loop Response time max (RofA not specified pv_olrt_er_max s + in 1547) + + :return: dict with keys shown above. + """ + return None + + def set_pv(self, params=None): + """ + Set P(V), Voltage-Active Power (Volt-Watt), Parameters + """ + pass + + def get_const_q(self): + """ + Get Constant Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Constant Reactive Power Mode Enable const_q_mode_enable bool (True=Enabled) + Constant Reactive Power Excitation (not specified in const_q_mode_excitation str ('inj', 'abs') + 1547) + Constant Reactive power setting (See Table 7) const_q VAr p.u. + Constant Reactive Power (RofA not specified in 1547) const_q_abs_er_max VAr p.u. + Absorbing Reactive Power Setting. Per unit value + based on NP Qmax Abs. Negative signs should not be + used but if present indicate absorbing VAr. + Constant Reactive Power (RofA not specified in 1547) const_q_inj_er_max VAr p.u. + Injecting Reactive Power (minimum RofA) Per unit + value based on NP Qmax Inj. Positive signs should + not be used but if present indicate Injecting VAr. + Maximum Response Time to maintain constant reactive const_q_olrt_er_min s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt_er_max s + power(not specified in 1547) + + :return: dict with keys shown above. + """ + pass + + def set_const_q(self, params=None): + """ + Set Constant Reactive Power Mode + """ + pass + + def get_p_lim(self): + """ + Get Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power Min p_lim_w_er_min P p.u. + Maximum Active Power p_lim_w P p.u. + Maximum Active Power Max p_lim_w_er_max P p.u. + """ + pass + + def set_p_lim(self, params=None): + """ + Get Limit maximum active power. + """ + pass + + # freq_watt redirects + def get_freq_watt(self): + return get_pf() + + def set_freq_watt(self, params=None): + return set_pf(params=params) + + def freq_watt(self, params=None): + if params is None: + return get_freq_watt() + else: + return set_freq_watt(params=params) + + def get_pf(self): + """ + Get P(f), Frequency-Active Power Mode Parameters - IEEE 1547 Table 38 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable pf_mode_enable bool (True=Enabled) + P(f) Overfrequency Droop dbOF RofA min pf_dbof_er_min Hz + P(f) Overfrequency Droop dbOF Setting pf_dbof Hz + P(f) Overfrequency Droop dbOF RofA max pf_dbof_er_max Hz + + P(f) Underfrequency Droop dbUF RofA min pf_dbuf_er_min Hz + P(f) Underfrequency Droop dbUF Setting pf_dbuf Hz + P(f) Underfrequency Droop dbUF RofA max pf_dbuf_er_max Hz + + P(f) Overfrequency Droop kOF RofA min pf_kof_er_min unitless + P(f) Overfrequency Droop kOF Setting pf_kof unitless + P(f) Overfrequency Droop kOF RofA max pf_kof_er_max unitless + + P(f) Underfrequency Droop kUF RofA min pf_kuf_er_min unitless + P(f) Underfrequency Droop kUF Setting pf_kuf unitless + P(f) Underfrequency Droop kUF RofA Max pf_kuf_er_max unitless + + P(f) Open Loop Response Time RofA min pf_olrt_er_min s + P(f) Open Loop Response Time Setting pf_olrt s + P(f) Open Loop Response Time RofA max pf_olrt_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_pf(self, params=None): + """ + Set P(f), Frequency-Active Power Mode Parameters + """ + pass + + def get_es_permit_service(self): + """ + Get Permit Service Mode Parameters - IEEE 1547 Table 39 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Permit service es_permit_service bool (True=Enabled) + ES Voltage Low (RofA not specified in 1547) es_v_low_er_min V p.u. + ES Voltage Low Setting es_v_low V p.u. + ES Voltage Low (RofA not specified in 1547) es_v_low_er_max V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_min V p.u. + ES Voltage High Setting es_v_high V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_max V p.u. + ES Frequency Low (RofA not specified in 1547) es_f_low_er_min Hz + ES Frequency Low Setting es_f_low Hz + ES Frequency Low (RofA not specified in 1547) es_f_low_er_max Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_min Hz + ES Frequency High Setting es_f_high Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_max Hz + ES Randomized Delay es_randomized_delay bool (True=Enabled) + ES Delay (RofA not specified in 1547) es_delay_er_min s + ES Delay Setting es_delay s + ES Delay (RofA not specified in 1547) es_delay_er_max s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_min %/s + ES Ramp Rate Setting es_ramp_rate %/s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_max %/s + + :return: dict with keys shown above. + """ + pass + + def set_es_permit_service(self, params=None): + """ + Set Permit Service Mode Parameters + """ + pass + + def get_ui(self): + """ + Get Unintentional Islanding Parameters + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Unintentional Islanding Mode (enabled/disabled). This ui_mode_enable bool + function is enabled by default, and disabled only by + request from the Area EPS Operator. + UI is always on in 1547 BUT 1547.1 says turn it off + for some testing + Unintential Islanding methods supported. Where multiple ui_capability_er list str + modes are supported place in a list. + UI BLRC = Balanced RLC, + UI PCPST = Powerline conducted, + UI PHIT = Permissive Hardware-input, + UI RMIP = Reverse/min relay. Methods other than UI + BRLC may require supplemental comissioning tests. + e.g., ['UI_BLRC', 'UI_PCPST', 'UI_PHIT', 'UI_RMIP'] + + :return: dict with keys shown above. + """ + pass + + def set_ui(self): + """ + Get Unintentional Islanding Parameters + """ + + def get_ov(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV Trip Curve Point OV_V1-3 (see Tables 11-13) ov_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV Trip Curve Point OV_V1-3 Setting (list) ov_trip_v_pts V p.u. + HV Trip Curve Point OV_V1-3 (RofA not specified in 1547) ov_trip_v_pts_er_max V p.u. + HV Trip Curve Point OV_T1-3 (see Tables 11-13) ov_trip_t_pts_er_min s + (RofA not specified in 1547) + HV Trip Curve Point OV_T1-3 Setting (list) ov_trip_t_pts s + HV Trip Curve Point OV_T1-3 (RofA not specified in 1547) ov_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_ov(self, params=None): + """ + Set Overvoltage Trip Parameters - IEEE 1547 Table 35 + """ + pass + + def get_uv(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV Trip Curve Point UV_V1-3 (see Tables 11-13) uv_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV Trip Curve Point UV_V1-3 Setting (list) uv_trip_v_pts V p.u. + LV Trip Curve Point UV_V1-3 (RofA not specified in 1547) uv_trip_v_pts_er_max V p.u. + LV Trip Curve Point UV_T1-3 (see Tables 11-13) uv_trip_t_pts_er_min s + (RofA not specified in 1547) + LV Trip Curve Point UV_T1-3 Setting (list) uv_trip_t_pts s + LV Trip Curve Point UV_T1-3 (RofA not specified in 1547) uv_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uv(self, params=None): + """ + Set Undervoltage Trip Parameters - IEEE 1547 Table 35 + """ + pass + + def get_of(self, params=None): + """ + Get Overfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + OF Trip Curve Point OF_F1-3 (see Tables 11-13) of_trip_f_pts_er_min Hz + (RofA not specified in 1547) + OF Trip Curve Point OF_F1-3 Setting of_trip_f_pts Hz + OF Trip Curve Point OF_F1-3 (RofA not specified in 1547) of_trip_f_pts_er_max Hz + OF Trip Curve Point OF_T1-3 (see Tables 11-13) of_trip_t_pts_er_min s + (RofA not specified in 1547) + OF Trip Curve Point OF_T1-3 Setting of_trip_t_pts s + OF Trip Curve Point OF_T1-3 (RofA not specified in 1547) of_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_of(self, params=None): + """ + Set Overfrequency Trip Parameters - IEEE 1547 Table 37 + """ + pass + + def get_uf(self, params=None): + """ + Get Underfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + UF Trip Curve Point UF_F1-3 (see Tables 11-13) uf_trip_f_pts_er_min Hz + (RofA not specified in 1547) + UF Trip Curve Point UF_F1-3 Setting uf_trip_f_pts Hz + UF Trip Curve Point UF_F1-3 (RofA not specified in 1547) uf_trip_f_pts_er_max Hz + UF Trip Curve Point UF_T1-3 (see Tables 11-13) uf_trip_t_pts_er_min s + (RofA not specified in 1547) + UF Trip Curve Point UF_T1-3 Setting uf_trip_t_pts s + UF Trip Curve Point UF_T1-3 (RofA not specified in 1547) uf_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uf(self, params=None): + """ + Set Underfrequency Trip Parameters - IEEE 1547 Table 37 + """ + pass + + def get_ov_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV MC Curve Point OV_V1-3 (see Tables 11-13) ov_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV MC Curve Point OV_V1-3 Setting ov_mc_v_pts V p.u. + HV MC Curve Point OV_V1-3 (RofA not specified in 1547) ov_mc_v_pts_er_max V p.u. + HV MC Curve Point OV_T1-3 (see Tables 11-13) ov_mc_t_pts_er_min s + (RofA not specified in 1547) + HV MC Curve Point OV_T1-3 Setting ov_mc_t_pts s + HV MC Curve Point OV_T1-3 (RofA not specified in 1547) ov_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_ov_mc(self, params=None): + """ + Set Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + pass + + def get_uv_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV MC Curve Point UV_V1-3 (see Tables 11-13) uv_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV MC Curve Point UV_V1-3 Setting uv_mc_v_pts V p.u. + LV MC Curve Point UV_V1-3 (RofA not specified in 1547) uv_mc_v_pts_er_max V p.u. + LV MC Curve Point UV_T1-3 (see Tables 11-13) uv_mc_t_pts_er_min s + (RofA not specified in 1547) + LV MC Curve Point UV_T1-3 Setting uv_mc_t_pts s + LV MC Curve Point UV_T1-3 (RofA not specified in 1547) uv_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uv_mc(self, params=None): + """ + Set Undervoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + pass + + def set_cease_to_energize(self, params=None): + """ + + A DER can be directed to cease to energize and trip by changing the Permit service setting to “disabled” as + described in IEEE 1574 Section 4.10.3. + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Cease to energize and trip cease_to_energize bool (True=Enabled) + + """ + return self.set_es_permit_service(params={'es_permit_service': params['cease_to_energize']}) + + # Additional functions outside of IEEE 1547-2018 + def get_conn(self): + """ + Get Connection - DER Connect/Disconnect Switch + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Connect/Disconnect Enable conn bool (True=Enabled) + """ + pass + + def set_conn(self, params=None): + """ + Set Connection + """ + pass + + def set_error(self, params=None): + """ + Set Error, for testing Monitoring Data in DER + + error = set error + """ + pass + + +def der1547_scan(): + global der1547_modules + # scan all files in current directory that match der1547_*.py + package_name = '.'.join(__name__.split('.')[:-1]) + files = glob.glob(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'der1547_*.py')) + for f in files: + module_name = None + try: + module_name = os.path.splitext(os.path.basename(f))[0] + if package_name: + module_name = package_name + '.' + module_name + m = importlib.import_module(module_name) + if hasattr(m, 'der1547_info'): + info = m.der1547_info() + print('DER 1547 Info %s' % info) + mode = info.get('mode') + # place module in module dict + if mode is not None: + der1547_modules[mode] = m + else: + if module_name is not None and module_name in sys.modules: + del sys.modules[module_name] + except Exception as e: + if module_name is not None and module_name in sys.modules: + del sys.modules[module_name] + raise DER1547Error('Error scanning module %s: %s' % (module_name, str(e))) + +# scan for der1547 modules on import +der1547_scan() + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/der1547_dnp3.py b/Lib/svpelab/der1547_dnp3.py new file mode 100644 index 0000000..aa67fee --- /dev/null +++ b/Lib/svpelab/der1547_dnp3.py @@ -0,0 +1,2503 @@ +''' +DER1547 methods defined for the DNP3 devices +''' + +import os +from . import der1547 +import svpdnp3.device_der_dnp3 as dnp3_agent +import subprocess +import socket + +dnp3_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'DNP3' +} + +false = False # Don't remove - required for eval of read_outstation data +true = True # Don't remove - required for eval of read_outstation data +null = None # Don't remove - required for eval of read_outstation data + +# Independent (X-Value) Units for Curve. Enumeration: (AO247) +# <0> Curve disabled +# <1> Not applicable / Unknown +# <4> Time +# <29> Voltage +# <33> Frequency +# <38> Watts +# <23> Celsius Temperature +# <100> Price in hundredths of +# local currency +# <129> Percent Voltage +# <133> Percent Frequency +# <138> Percent Watts +# <233> Frequency Deviation +# <234+> Other +X_ENUM = {0: 'Disabled', + 1: 'NA', + 4: 'Time', + 29: 'Voltage', + 33: 'Frequency', + 38: 'Watts', + 23: 'Temperature', + 100: 'Price', + 129: 'Volt_Pct', + 133: 'Freq_Pct', + 138: 'Watts_Pct', + 233: 'Freq_Delta', + 234: 'Other', + 'Disabled': 0, + 'NA': 1, + 'Time': 4, + 'Voltage': 29, + 'Frequency': 33, + 'Watts': 38, + 'Temperature': 23, + 'Price': 100, + 'Volt_Pct': 129, + 'Freq_Pct': 133, + 'Watts_Pct': 138, + 'Freq_Delta': 233, + 'Other': 233} + +# Dependent (Y-Value) Units for Curve. Enumeration: (AO248) +# <0> Curve disabled +# <1> Not applicable / unknown +# <2> VArs as percent of max VArs (VARMax) +# <3> VArs as percent of max available VArs (VArAval) +# <4> Vars as percent of max Watts (Wmax) – not used +# <5> Watts as percent of max Watts (Wmax) +# <6> Watts as percent of frozen active power (DeptSnptRef) +# <7> Power Factor in EEI notation +# <8> Volts as a percent of the nominal voltage (VRef) +# <9> Frequency as a percent of the nominal grid frequency (ECPNomHz) +# <99+> Other +Y_ENUM = {0: 'Disabled', + 1: 'NA', + 2: 'VArMax', + 3: 'VArAval', + 4: 'VArWmax', + 5: 'WMaxPct', + 6: 'WMaxPctFrozen', + 7: 'PF', + 8: 'Volt_Pct', + 9: 'Freq_Pct', + 99: 'Other', + 'Disabled': 0, + 'NA': 1, + 'VArMax': 2, + 'VArAval': 3, + 'VArWmax': 4, + 'WMaxPct': 5, + 'WMaxPctFrozen': 6, + 'PF': 7, + 'Volt_Pct': 8, + 'Freq_Pct': 9, + 'Other': 99} + +# Curve Mode Type. Enumeration: (AO245) +# <0> Curve disabled +# <1> Not applicable / Unknown +# <2> Volt-Var modes +# <3> Frequency-Watt mode +# <4> Watt-VAr mode +# <5> Voltage-Watt modes +# <6> Remain Connected +# <7> Temperature mode +# <8> Pricing signal mode +# High Voltage ride-through curves +# <9> HVRT Must Trip +# <10> HVRT Momentary Cessation +# Low Voltage ride-through curves +# <11> LVRT Must Trip +# <12> LVRT Momentary Cessation +# High Frequency ride-through curves +# <13> HFRT Must Trip +# <14> HFRT Momentary Cessation +# Low Frequency ride-through curves +# <15> LFRT Must Trip +# <16> LFRT Mandatory Operation + +CURVE_MODE = {0: 'Disabled', + 1: 'NA', + 2: 'VV', + 3: 'FW', + 4: 'WV', + 5: 'VW', + 6: 'RemainConnected', + 7: 'TempMode', + 8: 'HVRT_Price', + 9: 'HVRT_Trip', + 10: 'HVRT_MC', + 11: 'LVRT_Trip', + 12: 'LVRT_MC', + 13: 'HFRT_Trip', + 14: 'HFRT_MC', + 15: 'LFRT_Trip', + 16: 'LFRT_MO', + 'Disabled': 0, + 'NA': 1, + 'VV': 2, + 'FW': 3, + 'WV': 4, + 'VW': 5, + 'RemainConnected': 6, + 'TempMode': 7, + 'HVRT_Price': 8, + 'HVRT_Trip': 9, + 'HVRT_MC': 10, + 'LVRT_Trip': 11, + 'LVRT_MC': 12, + 'HFRT_Trip': 13, + 'HFRT_MC': 14, + 'LFRT_Trip': 15, + 'LFRT_MO': 16} + +# TODO include the full 100 DNP3 points dynamically +MAX_DNP_CURVE_PTS = 8 # needs to match the curve_read points +CURVE_PT_NAMES = ['x%d' % (x+1) for x in range(MAX_DNP_CURVE_PTS)] + ['y%d' % (x+1) for x in range(MAX_DNP_CURVE_PTS)] + + +def der1547_info(): + return dnp3_info + + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = dnp3_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('simulated_outstation'), label='Simulated Outstation?', default='Yes', values=['Yes', 'No']) + info.param(pname('sim_type'), label='Type of DER Simulation?', default='EPRI DER Simulator', + values=['EPRI DER Simulator'], active=pname('simulated_outstation'), active_value='Yes') + info.param(pname('auto_config'), label='Automate Configuration?', default='Yes', + values=['Yes', 'No'], active=pname('simulated_outstation'), active_value='Yes') + info.param(pname('dbus_ena'), label='Enable DBus?', default='No', values=['Yes', 'No'], + active=pname('sim_type'), active_value='EPRI DER Simulator') + info.param(pname('path_to_dbus'), label='Path to DBUS_CMD.exe', + default=r'C:\Users\DETLDAQ\Desktop\EPRISimulator\Setup\DBUS_CMD.exe', + active=pname('dbus_ena'), active_value='Yes') + info.param(pname('path_to_py'), label='Path to SimController.py', + default=r'C:\Users\DETLDAQ\Desktop\EPRISimulator\Setup\SimController.py', + active=pname('sim_type'), active_value='EPRI DER Simulator') + info.param(pname('path_to_exe'), label='Path to DERSimulator.exe', + default=r'C:\Users\DETLDAQ\Desktop\EPRISimulator\Setup\epri-der-sim-0.1.0.6\ + epri-der-sim-0.1.0.6\DERSimulator.exe', + active=pname('sim_type'), active_value='EPRI DER Simulator') + info.param(pname('irr_csv'), label='Irradiance csv filename. (Use "None" for no load.)', + default=r'None', active=pname('sim_type'), active_value='EPRI DER Simulator') + + info.param(pname('ipaddr'), label='Agent IP Address', default='127.0.0.1') + info.param(pname('ipport'), label='Agent IP Port', default=10000) + info.param(pname('out_ipaddr'), label='Outstation IP Address', default='127.0.0.1') + info.param(pname('out_ipport'), label='Outstation IP Port', default=20000) + info.param(pname('outstation_addr'), label='Outstation Local Address', default=100) + info.param(pname('master_addr'), label='Master Local Address', default=101) + info.param(pname('scan_time'), label='Scan Time', default=2) + info.param(pname('oid'), label='OID', default=1) + info.param(pname('rid'), label='Request ID', default=1234) + + +GROUP_NAME = 'dnp3' + +class DER1547(der1547.DER1547): + + def __init__(self, ts, group_name): + der1547.DER1547.__init__(self, ts, group_name) + self.outstation = None + self.simulated_outstation = self.param_value('simulated_outstation') + if self.simulated_outstation == 'Yes': + self.sim_type = self.param_value('sim_type') + self.auto_config = self.param_value('auto_config') + self.irr_csv = self.param_value('irr_csv') + + self.ipaddr = self.param_value('ipaddr') + self.ipport = self.param_value('ipport') + self.out_ipaddr = self.param_value('out_ipaddr') + self.out_ipport = self.param_value('out_ipport') + self.outstation_addr = self.param_value('outstation_addr') + self.master_addr = self.param_value('master_addr') + self.scan_time = self.param_value('scan_time') + self.oid = self.param_value('oid') + self.rid = self.param_value('rid') + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.start_agent() + self.ts.sleep(6) + self.ts.log('Adding outstation: %s' % self.add_out()) + self.ts.log('DNP3 Agent Status: %s' % self.status()) + + if self.simulated_outstation == 'Yes': + if self.auto_config == 'Yes': + if self.sim_type == 'EPRI DER Simulator': + from pywinauto.application import Application + + # Configure EPRI DER Simulator + self.ts.log('Running EPRI DER Simulator Setup. Please wait...') + if self.param_value('dbus_ena') == 'Yes': + os.system(r'start cmd /k "' + self.param_value('path_to_dbus') + '"') + self.ts.sleep(1) + # This currently runs in Python 3.7 + os.system(r'start cmd /k C:\Python37\python.exe "' + self.param_value('path_to_py') + '"') + self.ts.sleep(1) + + try: + # connect to DER Simulator app for control + app = Application(backend="uia").connect(title_re="DER Simulator") + except Exception as e: + self.ts.log('Starting DER Simulator') + der_sim_start_cmd = r'start cmd /k "' + self.param_value('path_to_exe') + '"' + self.ts.log_debug('Using: %s' % der_sim_start_cmd) + os.system(der_sim_start_cmd) + # sleep 10 seconds to wait for DER Simulator to start + self.ts.sleep(10) + self.ts.log('Connecting to DER Simulator') + app = Application(backend="uia").connect(title_re="DER Simulator") + + ''' Connect to DNP3 Master''' + self.ts.log('Clicking DERMS') + app['DER Simulator'].DERMS.click() # click the DERMS button + + # create irradiance profile + if self.irr_csv is not r'None': + self.ts.log('Clicking ENV') + app['DER Simulator'].ENV.click() # click into ENV button + self.ts.sleep(0.5) # sleep to permit the stop to operate + + self.ts.log('Browsing to File') + app['Environment Settings'].Browse.click() # click Browse button + self.ts.sleep(0.5) # sleep to permit the stop to operate + + # add csv file to File name: edit box; assumes this file will be local to Browse button + # default location + self.ts.log('Entering File Name') + app['Environment Settings'].Open.child_window(title="File name:", control_type="Edit").\ + set_edit_text(self.irr_csv) + self.ts.sleep(0.5) # sleep to permit the stop to operate + self.ts.log('Confirming File Name') + app['Environment Settings'].Open.OpenButton3.click() + self.ts.sleep(0.5) # sleep to permit the stop to operate + + # check if Frequency and Voltage buttons are checked; if so, uncheck + self.ts.log('Unchecking Freq Toggle') + if app['Environment Settings'].Frequency.get_toggle_state(): + app['Environment Settings'].Frequency.toggle() + self.ts.log('Unchecking Voltage Toggle') + if app['Environment Settings'].Voltage.get_toggle_state(): + app['Environment Settings'].Voltage.toggle() + self.ts.sleep(0.5) # sleep to permit the stop to operate + + self.ts.log('Clicking csv file import and closing') + app['Environment Settings'].Import.click() # import the CSV and close the dialog + app['Environment Settings'].Close.click() # import the CSV and close the dialog + self.ts.sleep(0.5) # sleep to permit the stop to operate + + # DBus connection for HIL environments + if self.param_value('dbus_ena') == 'Yes': + self.ts.log('Clicking Co-Sim button') + app['DER Simulator']['Co-Sim'].click() + self.ts.sleep(0.5) # sleep to permit the stop to operate + + # set number of components to 3 and start DBus Client + self.ts.log('Setting DBus Components to 3') + app['DBus Settings']['Number of ComponentsEdit'].set_edit_text(r'3') + self.ts.sleep(0.5) # sleep to permit the stop to operate + + self.ts.log('Starting DBus') + app['DBus Settings']['Start DBus\r\nClientButton'].click() + self.ts.sleep(0.5) # sleep to permit the stop to operate + + self.ts.log('Closing DBus') + app['DBus Settings'].Close.click() + + + def add_out(self): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + outstation = agent.add_outstation(self.out_ipaddr, self.out_ipport, self.outstation_addr, + self.master_addr, self.scan_time) + return outstation + + def del_out(self): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + outstation = agent.delete_outstation(self.oid, self.rid) + return outstation + + def status(self): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + try: + agent.connect(self.ipaddr, self.ipport) + except Exception as e: + self.ts.log_warning('Agent Status Error: %s' % e) + return 'No Agent' + agent_stat = agent.status(self.rid) + res = eval(agent_stat[1:-1]) + return res + + def scan(self, scan_type): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + agent_scan = agent.scan_outstation(self.oid, self.rid, scan_type) + res = eval(agent_scan[1:-1]) + return res + + def start_agent(self): + """ + Starts the DNP3 agent in a subprocess. This agent acts as middleman between SVP and the DNP3 outstation. + + :return: None + """ + running = True + + try: + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + status = eval(agent.status(self.rid)[1:-1]) + + if status == 'ERROR': + self.ts.log_warning('Unable to start the agent. Another process may be running at the configured ' + 'IP address and port') + + except socket.error as e: + running = False + + if not running: + self.ts.log('dnp3_agent is not running - attempting to start agent in new cmd window') + file_path = os.path.abspath(__file__ + '\\..\\..\\svpdnp3\\dnp3_agent.exe') + self.ts.log_debug('file_path: %s' % file_path) + in_new_window = True + if in_new_window: + win_command = file_path + ' -ip ' + self.ipaddr + ' -p ' + str(self.ipport) + # self.ts.log_debug('win_command: %s' % win_command) + subprocess.Popen(win_command, creationflags=subprocess.CREATE_NEW_CONSOLE) + else: # hidden process + args = [file_path, '-ip', self.ipaddr, '-p', str(self.ipport)] + subprocess.Popen(args) + + else: + self.ts.log_error("The system doesn't support this agent") + + def stop_agent(self): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + agent_end = agent.stop_agent(self.rid) + os.system('taskkill /F /IM dnp3_agent.exe') + return agent_end + + def info(self): + return 'DNP3 der1547 instantiation.' + + def read_dnp3_point_map(self, map_dict): + """ + Read outstation points + + Translates a DNP3 mapping dict into a point map dict + + points dictionary of format: {'ai': {'PT1': None, 'PT2': None}, 'bi': {'PT3': None}} for the read func. + + :param map_dict: point map in the following format + monitoring_data = {'mn_active_power': {'ai': {'537': None}}, + 'mn_reactive_power': {'ai': {'541': None}}, + 'mn_voltage': {'ai': {'547': None}}, + 'mn_frequency': {'ai': {'536': None}}, + 'mn_operational_state_of_charge': {'ai': {'48': None}}} + + :return: return_dict = {'mn_active_power': 10000., 'mn_reactive_power': 6542., ...} + """ + points = {'ai': {}, 'bi': {}} + + # self.ts.log_debug('map_dict.items(): %s' % map_dict.items()) + for key, values in list(map_dict.items()): + keys1 = list(values.keys()) # ['ai', 'ai', ....] + val = list(values.values()) # [{'537': None}, {'541': None}, ....] + for i in val: + keys2 = list(i.keys()) # ['537', '541'] + for x in keys1: # ['ai', 'ai', ....] + for y in keys2: # ['537', '541'] + if x == 'ai': + points['ai'][y] = None + elif x == 'bi': + points['bi'][y] = None + + # Read Outstation Points + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + out_read = agent.read_outstation(self.oid, self.rid, points) + response = eval(out_read[1:-1]) + + # self.ts.log(response) + # response = {'params': {'ai': {'4': {'value': 10000000.0, 'flags': 1, 'present': True}, + # '6': {'value': None, 'flags': 2, 'present': True}, + # '8': {'value': None, 'flags': 2, 'present': True}, + # '9': {'value': None, 'flags': 2, 'present': True}, + # '11': {'value': None, 'flags': 2, 'present': True}, + # '14': {'value': 10000000.0, 'flags': 1, 'present': True}, + # '22': {'value': 2.0, 'flags': 1, 'present': True},... + + # Populate return dict + return_dict = {} + for key, val in map_dict.items(): + return_dict[key] = None + + if 'params' in list(response.keys()): + for params, ios in list(response.items()): + io = list(ios.keys()) # ['ai', 'ai', ....] + num = list(ios.values()) # {'4': {'value': 10000000.0, 'flags': 1, 'present': True}, ... + for i in num: # {'4': {'value': 10000000.0, 'flags': 1, 'present': True}, ... + keys2 = list(i.keys()) # ['4', '6', ... ] + for x in io: # ['ai', 'ai', ....] + for y in keys2: # ['537', '541'] + for param_name, decoder in map_dict.items(): # ['mn_active_power', 'mn_reactive_power',...] + try: + dummy = decoder[x][y] # trigger the exception if the param is wrong [x][y] + # self.ts.log_debug('MATCH! param_name=%s' % param_name) + if x == 'ai': + # self.ts.log_debug('x = %s, y = %s, ios[x][y]=%s' % (x, y, ios['ai'][y])) + return_dict[param_name] = ios['ai'][y]['value'] + elif x == 'bi': + # self.ts.log_debug('x = %s, y = %s, ios[x][y]=%s' % (x, y, ios['bi'][y])) + return_dict[param_name] = ios['bi'][y]['value'] + except KeyError as e: + # self.ts.log_debug('No Match! param_name=%s' % param_name) + pass + + # scaling will happen back in main method + # self.ts.log_debug('return_dict = %s' % return_dict) + return return_dict + + def write_dnp3_point_map(self, map_dict, write_pts, debug=False): + """ + Write points to the outstation. + + The method translates a DNP3 mapping dict into a point map dict for writing to the outstation. + This method moves the 'enable' parameter to the end of the write list. + + Either exclude_list or include_list should be None. + + :param map_dict: point map in the following format + map_dict = {'pf_enable': {'bo': {'28': None}}, + 'pf': {'ao': {'210': None}}, + 'pf_excitation': {'bo': {'10': None}}} + :param write_pts: params dict {'pf_enable': True, 'pf': 0.85} + :return: dictionary of format: {'pf_enable': 'SUCCESS, 'pf_excitation': 'NOT_WRITTEN'}. + """ + + points = {'ao': {}, 'bo': {}} + point_name = [] + pt_coords = [] + + # self.ts.log_debug('WRITE map_dict: %s' % map_dict) + ena_point = None # ensure the enable point is at the end of the list + ena_val = None # ensure the enable point is at the end of the list + for key, value in list(map_dict.items()): + if 'ena' in key: # typically "enable" string in the key + ena_point = key + ena_val = value + else: + point_name.append(key) # ['pf', ...] + pt_coords.append(value) # [{'bo': {'28': None}}, {'ao': {'210': None}}, ...] + if ena_point is not None and ena_val is not None: + point_name.append(ena_point) # ['pf', ..., 'pf_enable'] + pt_coords.append(ena_val) # [{'ao': {'210': None}}, ..., {'bo': {'28': None}}] + + if debug: + self.ts.log_debug('point_name: %s' % point_name) + self.ts.log_debug('pt_coords: %s' % pt_coords) + + for x in range(0, len(point_name)): # ['pf', ..., 'pf_enable'] + key = list(map_dict[point_name[x]].keys()) # ['bo', 'ao', ...] + val = list(map_dict[point_name[x]].values()) # [{'28': None}, {'210': None}, ...] + for i in key: # ['bo', 'ao', ...] + for j in val: # [{'28': None}, {'210': None}, ...] + key2 = list(j.keys()) # ['28', '210', ...] + for y in key2: # ['28', '210', ...] + try: + # self.ts.log_debug('write_pts: %s' % write_pts) + # self.ts.log_debug('write_pts[point_name[x]]: %s' % write_pts[point_name[x]]) + points[i][y] = write_pts[point_name[x]] # {'ai': {'1': None, '2': None}, } + # self.ts.log_debug('points: %s' % points) + except KeyError as e: + # self.ts.log_error('Writing points %s. No key or value for: %s' % (write_pts, e)) + pass + if debug: + self.ts.log_debug('points: %s' % points) + + # write points + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + write_output = agent.write_outstation(self.oid, self.rid, points) + response = eval(write_output[1:-1]) + # response: {'params': {'points': {'ao': {'88': {'status': 'SUCCESS'}}, + # 'bo': {'17': {'state': 'SUCCESS'}}}}} + + write_status = write_pts.copy() # initialize with keys from params + write_status = dict.fromkeys(write_status, None) # set all values to None + # self.ts.log_debug('write_status: %s' % write_status) + + if 'params' in list(response.keys()): + for params, pts in response.items(): + points = list(pts.values()) # {'ao': {'88': {'status': 'SUCCESS'}}, 'bo': {'17': {'state': 'SUCCESS'}}} + # self.ts.log_debug('points = %s' % points) + for io_dict in points: # ios = ['ao', 'bo', ... ], nums = [{'88': {'status': 'SUCCESS'}}] + # self.ts.log_debug('io_dict = %s' % (io_dict)) + for io, num_data in io_dict.items(): + for num in num_data: + # self.ts.log_debug('io = %s, num = %s' % (io, num)) + for param_name, decoder in map_dict.items(): # ['mn_active_power', 'mn_reactive_power',...] + try: + dummy = decoder[io][num] # trigger the exception if the param is wrong [x][y] + if debug: + self.ts.log_debug('MATCH! param_name=%s' % param_name) + self.ts.log_debug('io = %s, num = %s, num_data[num]=%s' % + (io, num, num_data[num])) + if 'status' in num_data[num]: + write_status[param_name] = num_data[num]['status'] + # elif 'state' in num_data[num]: + # write_status[param_name] = num_data[num]['state'] + else: + write_status[param_name] = 'UNKNOWN' + except KeyError as e: + if debug: + self.ts.log_debug('No Match! param_name=%s' % param_name) + pass + + # Remove None write statuses from unused write_points, e.g., lists like 'pv_curve_v_pts" + for k, v in list(write_status.items()): + if v is None: + del write_status[k] + + # self.ts.log_debug('OUTPUT write_status: %s' % write_status) + return write_status + + def build_sub_dict(self, dictionary=None, new_name=None, keys=None): + """ + Set dict values into sub dict for easier visualization and analysis, for instance, + + a = {'a': 1, 'b': 2, 'c': 3, 'd': 4} + a = build_sub_dict(dictionary=a, new_name='sub', keys=['a', 'd']) + a --> {'sub': {'a': 1, 'd': 4}, 'b': 2, 'c': 3} + + :param dictionary: origional dict + :param new_name: dictionary key + :param keys: keys from the original dict to be moved under the new_name + + :return: new dictionary with internal dict + """ + + if dictionary is None or new_name is None or keys is None: + self.ts.log_warning('build_sub_dict did not have all the necessary parameters. Returning None.') + return dictionary + + # restructure with dict hierarchy + dictionary[new_name] = {} + for key in keys: + if key in dictionary: + dictionary[new_name][key] = dictionary[key] + dictionary.pop(key) # remove the original key-value pair + else: + dictionary[new_name][key] = None + # self.ts.log_warning('Setting [%s][%s] to None' % (new_name, key)) + + return dictionary + + def get_nameplate(self): + """ + Get Nameplate information - See IEEE 1547-2018 Table 28 + ______________________________________________________________________________________________________________ + Parameter params dict key Units + ______________________________________________________________________________________________________________ + Active power rating at unity power factor np_p_max kW + (nameplate active power rating) + Active power rating at specified over-excited np_p_max_over_pf kW + power factor + Specified over-excited power factor np_over_pf Decimal + Active power rating at specified under-excited np_p_max_under_pf kW + power factor + Specified under-excited power factor np_under_pf Decimal + Apparent power maximum rating np_va_max kVA + Normal operating performance category np_normal_op_cat str + e.g., CAT_A-CAT_B + Abnormal operating performance category np_abnormal_op_cat str + e.g., CAT_II-CAT_III + Intentional Island Category (optional) np_intentional_island_cat str + e.g., UNCAT-INT_ISLAND_CAP-BLACK_START-ISOCH + Reactive power injected maximum rating np_q_max_inj kVAr + Reactive power absorbed maximum rating np_q_max_abs kVAr + Active power charge maximum rating np_p_max_charge kW + Apparent power charge maximum rating np_apparent_power_charge_max KVA + AC voltage nominal rating np_ac_v_nom Vac + AC voltage maximum rating np_ac_v_max_er_max Vac + AC voltage minimum rating np_ac_v_min_er_min Vac + Supported control mode functions np_supported_modes (dict) str list + e.g., {'fixed_pf': True 'volt_var': False} with keys: + Supports Low Voltage Ride-Through Mode: 'lv_trip' + Supports High Voltage Ride-Through Mode: 'hv_trip' + Supports Low Freq Ride-Through Mode: 'lf_trip' + Supports High Freq Ride-Through Mode: 'hf_trip' + Supports Active Power Limit Mode: 'max_w' + Supports Volt-Watt Mode: 'volt_watt' + Supports Frequency-Watt Curve Mode: 'freq_watt' + Supports Constant VArs Mode: 'fixed_var' + Supports Fixed Power Factor Mode: 'fixed_pf' + Supports Volt-VAr Control Mode: 'volt_var' + Supports Watt-VAr Mode: 'watt_var' + Reactive susceptance that remains connected to np_reactive_susceptance Siemens + the Area EPS in the cease to energize and trip + state + Maximum resistance (R) between RPA and POC. np_remote_meter_resistance Ohms + (unsupported in 1547) + Maximum reactance (X) between RPA and POC. np_remote_meter_reactance Ohms + (unsupported in 1547) + Manufacturer np_manufacturer str + Model np_model str + Serial number np_serial_num str + Version np_fw_ver str + + :return: dict with keys shown above. + """ + + # Read Outstation Points + dnp3_pts = {**nameplate_data.copy(), **nameplate_support.copy()} + nameplate_pts = self.read_dnp3_point_map(dnp3_pts) + # self.ts.log_debug('nameplate_pts = %s' % nameplate_pts) + + # Scaling + if nameplate_pts['np_p_max'] is not None: + nameplate_pts['np_p_max'] /= 1000. # kW + if nameplate_pts['np_p_max_over_pf'] is not None: + nameplate_pts['np_p_max_over_pf'] /= 1000. # kW + if nameplate_pts['np_p_max_under_pf'] is not None: + nameplate_pts['np_p_max_under_pf'] /= 1000. # kW + if nameplate_pts['np_va_max'] is not None: + nameplate_pts['np_va_max'] /= 1000. # kVA + if nameplate_pts['np_q_max_inj'] is not None: + nameplate_pts['np_q_max_inj'] /= 1000. # kVAr + if nameplate_pts['np_q_max_abs'] is not None: + nameplate_pts['np_q_max_abs'] /= 1000. # kVAr + if nameplate_pts['np_p_max_charge'] is not None: + nameplate_pts['np_p_max_charge'] /= 1000. # kW + if nameplate_pts['np_apparent_power_charge_max'] is not None: + nameplate_pts['np_apparent_power_charge_max'] /= 1000. # kVA + + #<0> unknown, <1> Category A, <2> Category B + if nameplate_pts['np_normal_op_cat'] == 1: + nameplate_pts['np_normal_op_cat'] = 'CAT_A' + elif nameplate_pts['np_normal_op_cat'] == 2: + nameplate_pts['np_normal_op_cat'] = 'CAT_B' + else: + nameplate_pts['np_normal_op_cat'] = 'Unknown' + + nameplate_pts = self.build_sub_dict(dictionary=nameplate_pts, + new_name='np_support_dnp3', + keys=list(nameplate_support.keys())) + + ctrl_modes = {} # rename points with abstraction layer names + ctrl_modes['max_w'] = nameplate_pts['np_support_dnp3']['np_support_limit_watt'] + ctrl_modes['fixed_w'] = nameplate_pts['np_support_dnp3']['np_support_chg_dischg'] + ctrl_modes['fixed_var'] = nameplate_pts['np_support_dnp3']['np_support_constant_vars'] + ctrl_modes['fixed_pf'] = nameplate_pts['np_support_dnp3']['np_support_fixed_pf'] + ctrl_modes['volt_var'] = nameplate_pts['np_support_dnp3']['np_support_volt_var_control'] + ctrl_modes['freq_watt'] = nameplate_pts['np_support_dnp3']['np_support_freq_watt'] + ctrl_modes['dyn_react_curr'] = nameplate_pts['np_support_dnp3']['np_support_dynamic_reactive_current'] + ctrl_modes['lv_trip'] = nameplate_pts['np_support_dnp3']['np_support_volt_ride_through'] + ctrl_modes['hv_trip'] = nameplate_pts['np_support_dnp3']['np_support_volt_ride_through'] + ctrl_modes['watt_var'] = nameplate_pts['np_support_dnp3']['np_support_watt_var'] + ctrl_modes['volt_watt'] = nameplate_pts['np_support_dnp3']['np_support_volt_watt'] + ctrl_modes['lf_trip'] = nameplate_pts['np_support_dnp3']['np_support_freq_ride_through'] + ctrl_modes['hf_trip'] = nameplate_pts['np_support_dnp3']['np_support_freq_ride_through'] + nameplate_pts['np_supported_modes'] = ctrl_modes + del nameplate_pts['np_support_dnp3'] # remove dnp3 keys + # Unused points + # np_support_coordinated_chg_dischg + # np_support_active_pwr_response_1 + # np_support_active_pwr_response_2 + # np_support_active_pwr_response_3 + # np_support_automation_generation_control + # np_support_active_pwr_smoothing + # np_support_dynamic_volt_watt + # np_support_freq_watt_curve + # np_support_pf_correction + # np_support_pricing + + return nameplate_pts + + def get_settings(self): + """ + Get settings information + + :return: params dict with keys shown in nameplate. + """ + return self.get_nameplate() + + def set_settings(self, params=None): + """ + Set settings information + + :return: params dict with keys shown in nameplate. + """ + return self.set_configuration(params) + + def get_configuration(self): + """ + Get configuration information + + :return: params dict with keys shown in nameplate. + """ + return self.get_nameplate() + + def set_configuration(self, params=None): + """ + Set configuration information. params are those in get_nameplate(). + """ + + # nameplate_pts = {**nameplate_data_write.copy(), **nameplate_support_write.copy()} + # results = self.write_dnp3_point_map(map_dict=nameplate_pts, write_pts=params) + self.ts.log_warning('NO DNP3 APP NOTE AO/BO CONFIGURATION POINTS EXIST!') + return {} # no write BO/AO for nameplate points in DNP3 + + def get_monitoring(self): + """ + This information is indicative of the present operating conditions of the + DER. This information may be read. + + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Active Power mn_w kW + Reactive Power mn_var kVAr + Voltage (list) mn_v V-N list + Single phase devices: [V] + 3-phase devices: [V1, V2, V3] + Frequency mn_hz Hz + + Operational State mn_st bool + 'On': True, DER operating (e.g., generating) + 'Off': False, DER not operating + + Connection State mn_conn bool + 'Connected': True, DER connected + 'Disconnected': False, DER not connected + + DER OP State (not in IEEE 1547.1) mn_der_op_details dict of bools + 'mn_op_local': System in local/maintenance state + 'mn_op_lockout': System locked out + 'mn_op_starting': Start command has been received + 'mn_op_stopping': Emergency Stop command has been received + 'mn_op_started': Started + 'mn_op_stopped': Stopped + 'mn_op_permission_to_start': Start Permission Granted + 'mn_op_permission_to_stop': Stop Permission Granted} + + DER CONN State (not in IEEE 1547.1) mn_der_conn_details dict of bools + 'mn_conn_connected_idle': Idle-Connected + 'mn_conn_connected_generating': On-Connected + 'mn_conn_connected_charging': On-Charging-Connected + 'mn_conn_off_available': Off-Available + 'mn_conn_off_not_available': Off-Not-Available + 'mn_conn_switch_closed_status': Switch Closed + 'mn_conn_switch_closed_movement': Switch Moving} + + Alarm Status mn_alrm dict of bools + Reported Alarm Status matches the device + present alarm condition for alarm and no + alarm conditions. For test purposes only, the + DER manufacturer shall specify at least one + way an alarm condition that is supported in + the protocol being tested can be set and + cleared. + {'mn_alm_system_comm_error': System Communication Error + 'mn_alm_priority_1': System Has Priority 1 Alarms + 'mn_alm_priority_2': System Has Priority 2 Alarms + 'mn_alm_priority_3': System Has Priority 3 Alarms + 'mn_alm_storage_chg_max': Storage State of Charge at Maximum. Maximum Usable State of Charge reached. + 'mn_alm_storage_chg_high': Storage State of Charge is Too High. Maximum Reserve reached. + 'mn_alm_storage_chg_low': Storage State of Charge is Too Low. Minimum Reserve reached. + 'mn_alm_storage_chg_depleted': Storage State of Charge is Depleted. Minimum Usable State of Charge Reached. + 'mn_alm_internal_temp_high': Storage Internal Temperature is Too High + 'mn_alm_internal_temp_low': Storage External (Ambient) Temperature is Too High} + + Operational State of Charge (not required in 1547) mn_soc_pct pct + + :return: dict with keys shown above. + """ + + # Read Outstation Points + dnp3_pts = {**monitoring_data.copy(), **operational_state.copy(), + **connection_state.copy(), **alarm_state.copy()} + monitoring_pts = self.read_dnp3_point_map(dnp3_pts) + + # Scaling + if monitoring_pts.get('mn_w') is not None: + monitoring_pts['mn_w'] /= 1000. # kW + if monitoring_pts.get('mn_var') is not None: + monitoring_pts['mn_var'] /= 1000. # kVar + if monitoring_pts.get('mn_v') is not None: + monitoring_pts['mn_v'] = [monitoring_pts['mn_v'] / 10.] # V + if monitoring_pts.get('mn_hz') is not None: + monitoring_pts['mn_hz'] /= 100. + + # Build hierarchy + monitoring_pts = self.build_sub_dict(monitoring_pts, new_name='mn_der_op_details', keys=list(operational_state.keys())) + monitoring_pts = self.build_sub_dict(monitoring_pts, new_name='mn_der_conn_details', keys=list(connection_state.keys())) + + if monitoring_pts['mn_der_op_details']['mn_op_started']: + monitoring_pts['mn_st'] = True + else: + monitoring_pts['mn_st'] = False + + if monitoring_pts['mn_der_conn_details']['mn_conn_connected_idle'] or \ + monitoring_pts['mn_der_conn_details']['mn_conn_connected_generating'] or \ + monitoring_pts['mn_der_conn_details']['mn_conn_connected_charging'] or \ + monitoring_pts['mn_der_conn_details']['mn_conn_switch_closed_status']: + monitoring_pts['mn_conn'] = True + else: + monitoring_pts['mn_conn'] = False + + monitoring_pts = self.build_sub_dict(monitoring_pts, new_name='mn_alrm', keys=list(alarm_state.keys())) + + return monitoring_pts + + def get_const_pf(self): + """ + Get Constant Power Factor Mode control settings. IEEE 1547-2018 Table 30. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Absorbing Setting const_pf_abs decimal + Constant Power Factor Injecting Setting const_pf_inj decimal + Maximum response time to maintain constant power const_pf_olrt s + factor. (Not in 1547) + + :return: dict with keys shown above. + + Sign convention + Generating/Discharging (+) PFExt = BO10 = <0> Injecting VArs Q1 PF setpoint = AO210 + Generating/Discharging(+) PFExt = BO10 = <1> Asorbing VArs Q4 PF setpoint = AO210 + Charging (-) PFExt = BO11 = <0> Injecting VArs Q2 PF setpoint = AO211 + Charging (-) PFExt = BO11 = <1> Absorbing VArs Q3 PF setpoint = AO211 + """ + + pf_pts = self.read_dnp3_point_map(fixed_pf.copy()) + # self.ts.log_debug('pf_pts = %s' % pf_pts) + + # Scale and put values in 1547 keys + if 'const_pf_excitation' in pf_pts: + if pf_pts['const_pf_excitation'] is None: + self.ts.log_warning('No excitation provided by DER. Using PF sign to determine excitation.') + der_excite = False + else: + der_excite = True + if pf_pts['const_pf_excitation']: + pf_pts['const_pf_excitation'] = 'abs' + else: + pf_pts['const_pf_excitation'] = 'inj' + + # get PFs and place in active power injection and absorption keys + if 'const_pf' in pf_pts: + # Generating PF + pf_pts['const_pf_inj'] = abs(pf_pts['const_pf']) # pf (Q1 pos, Q4 neg) + if not der_excite: + if pf_pts['const_pf'] > 0: + pf_pts['const_pf_excitation'] = 'abs' + else: + pf_pts['const_pf_excitation'] = 'inj' + # TODO - Charging PF + # pf_pts['const_pf_abs'] = abs(pf_pts['const_pf']) # pf (Q2 ?, Q3 ?) + # if not der_excite: + # if pf_pts['const_pf'] > 0: + # pf_pts['const_pf_excitation'] = 'abs' + # else: + # pf_pts['const_pf_excitation'] = 'inj' + + del pf_pts['const_pf'] + + return pf_pts + + def set_const_pf(self, params=None): + """ + Set Constant Power Factor Mode control settings. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Absorbing W Setting const_pf_abs VAr p.u + Constant Power Factor Injecting W Setting const_pf_inj VAr p.u + Maximum response time to maintain constant power const_pf_olrt s + factor. (Not in 1547) + + :return: dict with keys shown above. + + Sign convention + Generating/Discharging (+) PFExt = BO10 = <0> Injecting VArs Q1 PF setpoint = AO210 + Generating/Discharging(+) PFExt = BO10 = <1> Asorbing VArs Q4 PF setpoint = AO210 + Charging (-) PFExt = BO11 = <0> Injecting VArs Q2 PF setpoint = AO211 + Charging (-) PFExt = BO11 = <1> Absorbing VArs Q3 PF setpoint = AO211 + """ + pf_pts = {**fixed_pf_write.copy()} + + if 'const_pf_excitation' in params: + if params['const_pf_excitation'] == 'inj': + params['const_pf_excitation'] = False + elif params['const_pf_excitation'] == 'abs': + params['const_pf_excitation'] = True + else: + self.ts.log_warning('const_pf_excitation is not "inj" or "abs"') + else: + if 'const_pf_mode_enable' in params: + if params['const_pf_mode_enable']: + self.ts.log_warning('No const_pf_excitation provided. Assuming absorption (positive value).') + + # Apply scaling and overload the PF value + if 'const_pf_abs' in params: + if params['const_pf_abs'] < -1. or params['const_pf_abs'] > 1.: # should be 0.0 to 1.0 + self.ts.log_warning('const_pf_abs value outside of -1 to 1 pf') + # overloading this parameter point (both const_pf_inj and const_pf_abs map here) + params['const_pf'] = int(params['const_pf_abs']*100.) # from pf to pf*100, excit handles sign + del params['const_pf_abs'] + + if 'const_pf_inj' in params: + if params['const_pf_inj'] < -1. or params['const_pf_inj'] > 1.: # should be 0.0 to 1.0 + self.ts.log_warning('const_pf_inj value outside of -1 to 1 pf') + # overloading this parameter point (both const_pf_inj and const_pf_abs map here) + params['const_pf'] = int(params['const_pf_inj']*100.) # from pf to pf*100, excit handles sign + del params['const_pf_inj'] + + # self.ts.log_debug('pf_pts = %s, params = %s' % (pf_pts, params)) + const_pf_results = self.write_dnp3_point_map(map_dict=pf_pts, write_pts=params) + self.ts.log_debug('PF results: %s' % const_pf_results) + + return params + + def get_qv(self): + """ + Get Q(V), Volt-Var, Voltage-Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref (0.95-1.05) qv_vref V p.u. + Autonomous Vref Adjustment Enable qv_vref_auto_mode bool (True=Enabled) + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Q(V) Curve Point V1-4 (list), [0.95, 0.99, 1.01, 1.05] qv_curve_v_pts V p.u. + Q(V) Curve Point Q1-4 (list), [1., 0., 0., -1.] qv_curve_q_pts VAr p.u. + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + """ + + volt_var_pts = volt_var_data.copy() + volt_var_pts.update(curve_read.copy()) + + resp = self.read_dnp3_point_map(volt_var_pts) + resp = self.build_sub_dict(resp, new_name='qv_curve_dnp3_data', keys=list(curve_read.keys())) + + resp['qv_curve_v_pts'] = [] + resp['qv_curve_q_pts'] = [] + for pt in range(int(resp['qv_curve_dnp3_data']['no_of_points'])): + resp['qv_curve_v_pts'].append(resp['qv_curve_dnp3_data']['x%d' % (pt + 1)] / 1000.) # from 10*pct to pu + resp['qv_curve_q_pts'].append(resp['qv_curve_dnp3_data']['y%d' % (pt + 1)] / 1000.) # from 10*pct to pu + + # remove redundant or unused curve points + [resp['qv_curve_dnp3_data'].pop(key) for key in CURVE_PT_NAMES] + + resp['qv_curve_dnp3_data']['x_value'] = X_ENUM.get(resp['qv_curve_dnp3_data']['x_value']) + resp['qv_curve_dnp3_data']['y_value'] = Y_ENUM.get(resp['qv_curve_dnp3_data']['y_value']) + resp['qv_curve_dnp3_data']['curve_mode_type'] = CURVE_MODE.get(resp['qv_curve_dnp3_data']['curve_mode_type']) + + # self.ts.log_debug('VV read resp: %s' % resp) + return resp + + def set_qv(self, params=None): + """ + Set Q(V), Volt-Var + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref (0.95-1.05) qv_vref V p.u. + Autonomous Vref Adjustment Enable qv_vref_auto_mode str + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Q(V) Curve Point V1-4 (list), [0.95, 0.99, 1.01, 1.05] qv_curve_v_pts V p.u. + Q(V) Curve Point Q1-4 (list), [1., 0., 0., -1.] qv_curve_q_pts VAr p.u. + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + ______________________________________________________________________________________________________________ + """ + + volt_var_pts = volt_var_write.copy() + volt_var_curve_pts = curve_write.copy() + volt_var_pts.update(volt_var_curve_pts) + + if 'qv_curve_v_pts' in params or 'qv_curve_q_pts' in params: + params['curve_index'] = '1' + params['curve_edit_selector'] = '1' + params['curve_mode_type'] = CURVE_MODE['VV'] # Curve Mode Type (2 = Volt-var) + params['no_of_points'] = len(params['qv_curve_v_pts']) + params['x_value'] = X_ENUM['Volt_Pct'] # Voltage + params['y_value'] = Y_ENUM['VArMax'] # % DNP3 App Note Table 53 for details + for pt in range(len(params['qv_curve_v_pts'])): + params['x%d' % (pt + 1)] = params['qv_curve_v_pts'][pt]*1000. # from pu to 10*pct + for pt in range(len(params['qv_curve_q_pts'])): + params['y%d' % (pt + 1)] = params['qv_curve_q_pts'][pt]*1000. # from pu to 10*pct + + # Write the VV points + vv_write = self.write_dnp3_point_map(volt_var_pts, params, debug=False) + self.ts.log_debug('VV results: %s' % vv_write) + + return params + + def get_const_q(self): + """ + Get Constant Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Constant Reactive Power Mode Enable const_q_mode_enable bool (True=Enabled) + Constant Reactive Power Excitation (not specified in const_q_mode_excitation str ('inj', 'abs') + 1547) + Constant Reactive power setting (See Table 7) const_q VAr p.u. + Constant Reactive Power (RofA not specified in 1547) const_q_abs_er_max VAr p.u. + Absorbing Reactive Power Setting. Per unit value + based on NP Qmax Abs. Negative signs should not be + used but if present indicate absorbing VAr. + Constant Reactive Power (RofA not specified in 1547) const_q_inj_er_max VAr p.u. + Injecting Reactive Power (minimum RofA) Per unit + value based on NP Qmax Inj. Positive signs should + not be used but if present indicate Injecting VAr. + Maximum Response Time to maintain constant reactive const_q_olrt_er_min s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt_er_max s + power(not specified in 1547) + + :return: dict with keys shown above. + """ + + # Read Outstation Points + dnp3_pts = reactive_power_data.copy() + reactive_power_pts = self.read_dnp3_point_map(dnp3_pts) + + if reactive_power_pts['const_q'] is not None: + reactive_power_pts['const_q'] /= 1000. # scaling from pct*10 to pu + + return reactive_power_pts + + def set_const_q(self, params=None): + """ + This information is used to update functional and mode settings for the + Constant Reactive Power Mode. This information may be written. + """ + + reactive_power_pts = reactive_power_write.copy() + return reactive_power_pts + + def get_conn(self): + """ + Get Connection + + conn = bool for connection + """ + + # Read Outstation Points + dnp3_pts = conn_data.copy() + return self.read_dnp3_point_map(dnp3_pts) + + def set_conn(self, params=None): + """ + This information is used to update functional and mode settings for the + Constant Reactive Power Mode. This information may be written. + + conn = bool for connection + """ + + dnp3_pts = conn_write.copy() + conn_results = self.write_dnp3_point_map(dnp3_pts, write_pts=params) + self.ts.log_debug('Conn results: %s' % conn_results) + + return params + + def get_pf(self): + """ + Get P(f), Frequency-Active Power Mode Parameters - IEEE 1547 Table 38 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable pf_mode_enable bool (True=Enabled) + P(f) Overfrequency Droop dbOF Setting pf_dbof Hz + P(f) Underfrequency Droop dbUF Setting pf_dbuf Hz + P(f) Overfrequency Droop kOF Setting pf_kof unitless + P(f) Underfrequency Droop kUF Setting pf_kuf unitless + P(f) Open Loop Response Time Setting pf_olrt s + + :return: dict with keys shown above. + """ + freq_watt_pts = freq_watt_data.copy() + return self.read_dnp3_point_map(freq_watt_pts) + + def set_pf(self, params=None): + """ + Set Frequency-Active Power Mode. + """ + freq_watt_pts = freq_watt_write.copy() + pf_results = self.write_dnp3_point_map(freq_watt_pts, params) + self.ts.log_debug('PF results: %s' % pf_results) + + return params + + def get_p_lim(self): + """ + Get Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power Min p_lim_w_er_min P p.u. + Maximum Active Power p_lim_w P p.u. + Maximum Active Power Max p_lim_w_er_max P p.u. + """ + + # Read Outstation Points + dnp3_pts = limit_max_power_data.copy() + limit_max_power_pts = self.read_dnp3_point_map(dnp3_pts) + + if limit_max_power_pts['p_lim_w'] is not None: + limit_max_power_pts['p_lim_w'] /= 1000. # scaling from pct*10 to pu + + return limit_max_power_pts + + def set_p_lim(self, params=None): + """ + Set Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power Min p_lim_w_er_min P p.u. + Maximum Active Power p_lim_w P p.u. + Maximum Active Power Max p_lim_w_er_max P p.u. + """ + + limit_max_power_pts = limit_max_power_write.copy() + + # Apply scaling + if 'p_lim_w' in params: + if params['p_lim_w'] < -1. or params['p_lim_w'] > 1.: + self.ts.log_warning('p_lim_w value outside of -1 to 1 pu') + params['p_lim_w'] = int(params['p_lim_w']*1000.) # from pu to pct*10 + + lap_results = self.write_dnp3_point_map(limit_max_power_pts, write_pts=params) + self.ts.log_debug('LAP write: %s' % lap_results) + + return params + + def get_qp(self): + """ + Get Q(P) parameters. [Watt-Var] + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Active Power-Reactive Power (Watt-VAr) Enable qp_mode_enable bool + P-Q curve P1-3 Generation Setting (list) qp_curve_p_gen_pts P p.u. + P-Q curve Q1-3 Generation Setting (list) qp_curve_q_gen_pts VAr p.u. + P-Q curve P1-3 Load Setting (list) qp_curve_p_load_pts P p.u. + P-Q curve Q1-3 Load Setting (list) qp_curve_q_load_pts VAr p.u. + + """ + watt_var_pts = watt_var_data.copy() + watt_var_pts.update(curve_read.copy()) + + resp = self.read_dnp3_point_map(watt_var_pts) + resp = self.build_sub_dict(resp, new_name='qp_curve_dnp3_data', keys=list(curve_read.keys())) + + resp['qp_curve_p_gen_pts'] = [] + resp['qp_curve_q_gen_pts'] = [] + resp['qp_curve_p_load_pts'] = [] + resp['qp_curve_q_load_pts'] = [] + + if int(resp['qp_curve_dnp3_data']['no_of_points']) == 0: # if no points, don't get the curves + return resp + + for pt in range(int(resp['qp_curve_dnp3_data']['no_of_points'])): + # TODO: manage multiple curves + if resp['qp_curve_dnp3_data']['x%d' % (pt + 1)] > 0: + resp['qp_curve_p_gen_pts'].append(resp['qp_curve_dnp3_data']['x%d' % (pt + 1)] / 1000.) # 10*pct to pu + resp['qp_curve_q_gen_pts'].append(resp['qp_curve_dnp3_data']['y%d' % (pt + 1)] / 1000.) # 10*pct to pu + else: + resp['qp_curve_p_load_pts'].append(resp['qp_curve_dnp3_data']['x%d' % (pt + 1)] / 1000.) # 10*pct to pu + resp['qp_curve_q_load_pts'].append(resp['qp_curve_dnp3_data']['y%d' % (pt + 1)] / 1000.) # 10*pct to pu + + # remove redundant or unused curve points + [resp['qp_curve_dnp3_data'].pop(key) for key in CURVE_PT_NAMES] + + resp['qp_curve_dnp3_data']['x_value'] = X_ENUM.get(resp['qp_curve_dnp3_data']['x_value']) + resp['qp_curve_dnp3_data']['y_value'] = Y_ENUM.get(resp['qp_curve_dnp3_data']['y_value']) + resp['qp_curve_dnp3_data']['curve_mode_type'] = CURVE_MODE.get(resp['qp_curve_dnp3_data']['curve_mode_type']) + + # self.ts.log_debug('WV read resp: %s' % resp) + return resp + + def set_qp(self, params=None): + """ + Set Q(P) parameters. [Watt-Var] + """ + watt_var_pts = watt_var_write.copy() + watt_var_curve_pts = curve_write.copy() + watt_var_pts.update(watt_var_curve_pts) + + if 'pv_curve_p_pts' in params or 'pv_curve_q_pts' in params: + params['curve_index'] = '1' + params['curve_edit_selector'] = '1' + params['curve_mode_type'] = CURVE_MODE['WV'] + params['no_of_points'] = len(params['pv_curve_v_pts']) + params['x_value'] = X_ENUM['Volt_Pct'] # Voltage + params['y_value'] = Y_ENUM['WMaxPct'] # DNP3 App Note Table 53 for details + # TODO: work with multiple curves + for pt in range(len(params['qp_curve_p_gen_pts'])): + params['x%d' % (pt + 1)] = params['qp_curve_p_gen_pts'][pt]*1000. # from pu to 10*pct + for pt in range(len(params['qp_curve_q_gen_pts'])): + params['y%d' % (pt + 1)] = params['qp_curve_q_gen_pts'][pt]*1000. # from pu to 10*pct + for pt in range(len(params['qp_curve_p_load_pts'])): + params['x%d' % (pt + 1)] = params['qp_curve_p_load_pts'][pt]*1000. # from pu to 10*pct + for pt in range(len(params['qp_curve_q_load_pts'])): + params['y%d' % (pt + 1)] = params['qp_curve_q_load_pts'][pt]*1000. # from pu to 10*pct + + # Write the Q(P) points + qp_write = self.write_dnp3_point_map(watt_var_pts, params, debug=False) + self.ts.log_debug('Q(P) write: %s' % qp_write) + + return params + + def get_pv(self, params=None): + """ + Get P(V), Voltage-Active Power (Volt-Watt), Parameters + """ + + volt_watt_pts = volt_watt_data.copy() + volt_watt_pts.update(curve_read.copy()) + + resp = self.read_dnp3_point_map(volt_watt_pts) + resp = self.build_sub_dict(resp, new_name='pv_curve_dnp3_data', keys=list(curve_read.keys())) + + resp['pv_curve_v_pts'] = [] + resp['pv_curve_p_pts'] = [] + + if int(resp['pv_curve_dnp3_data']['no_of_points']) == 0: # if no points, don't get the curves + return resp + + for pt in range(int(resp['pv_curve_dnp3_data']['no_of_points'])): + resp['pv_curve_v_pts'].append(resp['pv_curve_dnp3_data']['x%d' % (pt + 1)] / 1000.) # from 10*pct to pu + resp['pv_curve_p_pts'].append(resp['pv_curve_dnp3_data']['y%d' % (pt + 1)] / 1000.) # from 10*pct to pu + + # remove redundant or unused curve points + [resp['pv_curve_dnp3_data'].pop(key) for key in CURVE_PT_NAMES] + + resp['pv_curve_dnp3_data']['x_value'] = X_ENUM.get(resp['pv_curve_dnp3_data']['x_value']) + resp['pv_curve_dnp3_data']['y_value'] = Y_ENUM.get(resp['pv_curve_dnp3_data']['y_value']) + resp['pv_curve_dnp3_data']['curve_mode_type'] = CURVE_MODE.get(resp['pv_curve_dnp3_data']['curve_mode_type']) + + # self.ts.log_debug('VV read resp: %s' % resp) + return resp + + def set_pv(self, params=None): + """ + Set P(V), Voltage-Active Power (Volt-Watt), Parameters + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Active Power Mode Enable pv_mode_enable bool + P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. + P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. + P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. + P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + + :return: dict with keys shown above. + """ + volt_watt_pts = volt_watt_write.copy() + volt_watt_curve_pts = curve_write.copy() + volt_watt_pts.update(volt_watt_curve_pts) + + if 'pv_curve_v_pts' in params or 'pv_curve_p_pts' in params: + params['curve_index'] = '1' + params['curve_edit_selector'] = '1' + params['curve_mode_type'] = CURVE_MODE['WV'] + params['no_of_points'] = len(params['pv_curve_v_pts']) + params['x_value'] = X_ENUM['Volt_Pct'] # Voltage + params['y_value'] = Y_ENUM['WMaxPct'] # DNP3 App Note Table 53 for details + for pt in range(len(params['pv_curve_v_pts'])): + params['x%d' % (pt + 1)] = params['pv_curve_v_pts'][pt]*1000. # from pu to 10*pct + for pt in range(len(params['pv_curve_p_pts'])): + params['y%d' % (pt + 1)] = params['pv_curve_p_pts'][pt]*1000. # from pu to 10*pct + + # Write the P(V) points + pv_write = self.write_dnp3_point_map(volt_watt_pts, params, debug=False) + self.ts.log_debug('P(V) write: %s' % pv_write) + + return params + + def set_volt_trip(self, params=None): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + point_name = [] + pt_value = [] + + for key, value in list(params.items()): + point_name.append(key) + pt_value.append(value) + + points = {'ao': {}} + + points_ht = {'ao': {}} + points_hv = {'ao': {}} + points_lt = {'ao': {}} + points_lv = {'ao': {}} + i = 0 + j = 0 + k = 0 + n = 0 + + for x in range(0, len(point_name)): + if point_name[x] == 'hv': + ht_pts = pt_value[x]['t'] + hv_pts = pt_value[x]['v'] + if point_name[x] == 'lv': + lt_pts = pt_value[x]['t'] + lv_pts = pt_value[x]['v'] + + no_points_hv = len(ht_pts) + no_points_lv = len(lt_pts) + hv_trip_points = {'ao': {'23': '4', '244': '4', '245': '9', '246': no_points_hv, '247': '4', '248': '8'}} + lv_trip_points = {'ao': {'24': '5', '244': '5', '245': '11', '246': no_points_lv, '247': '4', '248': '8'}} + hvrt_curve_settings = agent.write_outstation(self.oid, self.rid, hv_trip_points) + lvrt_curve_settings = agent.write_outstation(self.oid, self.rid, lv_trip_points) + + for x in range(249, 248 + (no_points_hv * 2), 2): + points_ht['ao'][str(x)] = str(ht_pts[i]) + i += 1 + + for y in range(250, 249 + (no_points_hv * 2), 2): + points_hv['ao'][str(y)] = str(hv_pts[j]) + j += 1 + + for x in range(249, 248 + (no_points_lv * 2), 2): + points_lt['ao'][str(x)] = str(lt_pts[k]) + k += 1 + + for y in range(250, 249 + (no_points_lv * 2), 2): + points_lv['ao'][str(y)] = str(lv_pts[n]) + n += 1 + + curve_write_ht = agent.write_outstation(self.oid, self.rid, points_ht) + curve_write_hv = agent.write_outstation(self.oid, self.rid, points_hv) + curve_write_lt = agent.write_outstation(self.oid, self.rid, points_lt) + curve_write_lv = agent.write_outstation(self.oid, self.rid, points_lv) + + res1 = eval(curve_write_ht[1:-1]) + res2 = eval(curve_write_hv[1:-1]) + res3 = eval(curve_write_lt[1:-1]) + res4 = eval(curve_write_lv[1:-1]) + + enable_pt = {'bo': {'12': True}} + + # Writing the HV Trip Enable + vrt_w = agent.write_outstation(self.oid, self.rid, enable_pt) + + if 'params' in list(res1.keys()): + resp1 = res1['params']['points'] + ht_key = list(points_ht['ao'].keys()) + for i in range(0, no_points_hv): + points['HT-Point%s' % str(i + 1)] = resp1['ao'][ht_key[i]] + + if 'params' in list(res2.keys()): + resp2 = res2['params']['points'] + hv_key = list(points_hv['ao'].keys()) + for i in range(0, no_points_hv): + points['HV-Point%s' % str(i + 1)] = resp2['ao'][hv_key[i]] + + if 'params' in list(res3.keys()): + resp3 = res3['params']['points'] + lt_key = list(points_lt['ao'].keys()) + for i in range(0, no_points_lv): + points['LT-Point%s' % str(i + 1)] = resp3['ao'][lt_key[i]] + + if 'params' in list(res4.keys()): + resp4 = res4['params']['points'] + lv_key = list(points_lv['ao'].keys()) + for i in range(0, no_points_lv): + points['LV-Point%s' % str(i + 1)] = resp4['ao'][lv_key[i]] + + return points + + def set_freq_trip(self, params=None): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + point_name = [] + pt_value = [] + + for key, value in list(params.items()): + point_name.append(key) + pt_value.append(value) + + points = {'ao': {}} + + points_ht = {'ao': {}} + points_hf = {'ao': {}} + points_lt = {'ao': {}} + points_lf = {'ao': {}} + i = 0 + j = 0 + k = 0 + n = 0 + + for x in range(0, len(point_name)): + if point_name[x] == 'hf': + ht_pts = pt_value[x]['t'] + hf_pts = pt_value[x]['f'] + if point_name[x] == 'lf': + lt_pts = pt_value[x]['t'] + lf_pts = pt_value[x]['f'] + + no_points_hf = len(ht_pts) + no_points_lf = len(lt_pts) + hf_trip_points = {'ao': {'28': '6', '244': '6', '245': '13', '246': no_points_hf, '247': '4', '248': '9'}} + lf_trip_points = {'ao': {'29': '7', '244': '7', '245': '15', '246': no_points_lf, '247': '4', '248': '9'}} + hfrt_curve_settings = agent.write_outstation(self.oid, self.rid, hf_trip_points) + lfrt_curve_settings = agent.write_outstation(self.oid, self.rid, lf_trip_points) + + for x in range(249, 248 + (no_points_hf * 2), 2): + points_ht['ao'][str(x)] = str(ht_pts[i]) + i += 1 + + for y in range(250, 249 + (no_points_hf * 2), 2): + points_hf['ao'][str(y)] = str(hf_pts[j]) + j += 1 + + for x in range(249, 248 + (no_points_lf * 2), 2): + points_lt['ao'][str(x)] = str(lt_pts[k]) + k += 1 + + for y in range(250, 249 + (no_points_lf * 2), 2): + points_lf['ao'][str(y)] = str(lf_pts[n]) + n += 1 + + curve_write_ht = agent.write_outstation(self.oid, self.rid, points_ht) + curve_write_hf = agent.write_outstation(self.oid, self.rid, points_hf) + curve_write_lt = agent.write_outstation(self.oid, self.rid, points_lt) + curve_write_lf = agent.write_outstation(self.oid, self.rid, points_lf) + + res1 = eval(curve_write_ht[1:-1]) + res2 = eval(curve_write_hf[1:-1]) + res3 = eval(curve_write_lt[1:-1]) + res4 = eval(curve_write_lf[1:-1]) + + enable_pt = {'bo': {'13': True}} + + # Writing the HV Trip Enable + frt_w = agent.write_outstation(self.oid, self.rid, enable_pt) + + if 'params' in list(res1.keys()): + resp1 = res1['params']['points'] + ht_key = list(points_ht['ao'].keys()) + for i in range(0, no_points_hf): + points['HT-Point%s' % str(i + 1)] = resp1['ao'][ht_key[i]] + + if 'params' in list(res2.keys()): + resp2 = res2['params']['points'] + hf_key = list(points_hf['ao'].keys()) + for i in range(0, no_points_hf): + points['HF-Point%s' % str(i + 1)] = resp2['ao'][hf_key[i]] + + if 'params' in list(res3.keys()): + resp3 = res3['params']['points'] + lt_key = list(points_lt['ao'].keys()) + for i in range(0, no_points_lf): + points['LT-Point%s' % str(i + 1)] = resp3['ao'][lt_key[i]] + + if 'params' in list(res4.keys()): + resp4 = res4['params']['points'] + lf_key = list(points_lf['ao'].keys()) + for i in range(0, no_points_lf): + points['LF-Point%s' % str(i + 1)] = resp4['ao'][lf_key[i]] + + return points + + def set_volt_cessation(self, params=None): + agent = dnp3_agent.AgentClient(self.ipaddr, self.ipport) + agent.connect(self.ipaddr, self.ipport) + point_name = [] + pt_value = [] + + for key, value in list(params.items()): + point_name.append(key) + pt_value.append(value) + + points = {'ao': {}} + + points_ht = {'ao': {}} + points_hv = {'ao': {}} + points_lt = {'ao': {}} + points_lv = {'ao': {}} + i = 0 + j = 0 + k = 0 + n = 0 + + for x in range(0, len(point_name)): + if point_name[x] == 'hv': + ht_pts = pt_value[x]['t'] + hv_pts = pt_value[x]['v'] + if point_name[x] == 'lv': + lt_pts = pt_value[x]['t'] + lv_pts = pt_value[x]['v'] + + no_points_hv = len(ht_pts) + no_points_lv = len(lt_pts) + hv_trip_points = {'ao': {'25': '8', '244': '8', '245': '10', '246': no_points_hv, '247': '4', '248': '8'}} + lv_trip_points = {'ao': {'26': '9', '244': '9', '245': '12', '246': no_points_lv, '247': '4', '248': '8'}} + hvrt_curve_settings = agent.write_outstation(self.oid, self.rid, hv_trip_points) + lvrt_curve_settings = agent.write_outstation(self.oid, self.rid, lv_trip_points) + + for x in range(249, 248 + (no_points_hv * 2), 2): + points_ht['ao'][str(x)] = str(ht_pts[i]) + i += 1 + + for y in range(250, 249 + (no_points_hv * 2), 2): + points_hv['ao'][str(y)] = str(hv_pts[j]) + j += 1 + + for x in range(249, 248 + (no_points_lv * 2), 2): + points_lt['ao'][str(x)] = str(lt_pts[k]) + k += 1 + + for y in range(250, 249 + (no_points_lv * 2), 2): + points_lv['ao'][str(y)] = str(lv_pts[n]) + n += 1 + + curve_write_ht = agent.write_outstation(self.oid, self.rid, points_ht) + curve_write_hv = agent.write_outstation(self.oid, self.rid, points_hv) + curve_write_lt = agent.write_outstation(self.oid, self.rid, points_lt) + curve_write_lv = agent.write_outstation(self.oid, self.rid, points_lv) + + res1 = eval(curve_write_ht[1:-1]) + res2 = eval(curve_write_hv[1:-1]) + res3 = eval(curve_write_lt[1:-1]) + res4 = eval(curve_write_lv[1:-1]) + + enable_pt = {'bo': {'12': True}} + + # Writing the HV Trip Enable + vrt_w = agent.write_outstation(self.oid, self.rid, enable_pt) + + if 'params' in list(res1.keys()): + resp1 = res1['params']['points'] + ht_key = list(points_ht['ao'].keys()) + for i in range(0, no_points_hv): + points['HT-Point%s' % str(i + 1)] = resp1['ao'][ht_key[i]] + + if 'params' in list(res2.keys()): + resp2 = res2['params']['points'] + hv_key = list(points_hv['ao'].keys()) + for i in range(0, no_points_hv): + points['HV-Point%s' % str(i + 1)] = resp2['ao'][hv_key[i]] + + if 'params' in list(res3.keys()): + resp3 = res3['params']['points'] + lt_key = list(points_lt['ao'].keys()) + for i in range(0, no_points_lv): + points['LT-Point%s' % str(i + 1)] = resp3['ao'][lt_key[i]] + + if 'params' in list(res4.keys()): + resp4 = res4['params']['points'] + lv_key = list(points_lv['ao'].keys()) + for i in range(0, no_points_lv): + points['LV-Point%s' % str(i + 1)] = resp4['ao'][lv_key[i]] + + return points + + def get_es_permit_service(self): + """ + Get Permit Service Mode Parameters - IEEE 1547 Table 39 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Permit service es_permit_service bool (True=Enabled) + ES Voltage Low (RofA not specified in 1547) es_v_low_er_min V p.u. + ES Voltage Low Setting es_v_low V p.u. + ES Voltage Low (RofA not specified in 1547) es_v_low_er_max V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_min V p.u. + ES Voltage High Setting es_v_high V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_max V p.u. + ES Frequency Low (RofA not specified in 1547) es_f_low_er_min Hz + ES Frequency Low Setting es_f_low Hz + ES Frequency Low (RofA not specified in 1547) es_f_low_er_max Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_min Hz + ES Frequency High Setting es_f_high Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_max Hz + ES Randomized Delay es_randomized_delay bool (True=Enabled) + ES Delay (RofA not specified in 1547) es_delay_er_min s + ES Delay Setting es_delay s + ES Delay (RofA not specified in 1547) es_delay_er_max s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_min %/s + ES Ramp Rate Setting es_ramp_rate %/s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_max %/s + + :return: dict with keys shown above. + """ + pass + + def set_es_permit_service(self, params=None): + """ + Set Permit Service Mode Parameters + """ + return params + + def get_ui(self): + """ + Get Unintentional Islanding Parameters + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Unintentional Islanding Mode (enabled/disabled). This ui_mode_enable bool + function is enabled by default, and disabled only by + request from the Area EPS Operator. + UI is always on in 1547 BUT 1547.1 says turn it off + for some testing + Unintential Islanding methods supported. Where multiple ui_capability_er list str + modes are supported place in a list. + UI BLRC = Balanced RLC, + UI PCPST = Powerline conducted, + UI PHIT = Permissive Hardware-input, + UI RMIP = Reverse/min relay. Methods other than UI + BRLC may require supplemental comissioning tests. + e.g., ['UI_BLRC', 'UI_PCPST', 'UI_PHIT', 'UI_RMIP'] + + :return: dict with keys shown above. + """ + pass + + def set_ui(self, params=None): + """ + Get Unintentional Islanding Parameters + """ + + return params + + def get_ov(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV Trip Curve Point OV_V1-3 (see Tables 11-13) ov_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV Trip Curve Point OV_V1-3 Setting ov_trip_v_pts V p.u. + HV Trip Curve Point OV_V1-3 (RofA not specified in 1547) ov_trip_v_pts_er_max V p.u. + HV Trip Curve Point OV_T1-3 (see Tables 11-13) ov_trip_t_pts_er_min s + (RofA not specified in 1547) + HV Trip Curve Point OV_T1-3 Setting ov_trip_t_pts s + HV Trip Curve Point OV_T1-3 (RofA not specified in 1547) ov_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_ov(self, params=None): + """ + Set Overvoltage Trip Parameters - IEEE 1547 Table 35 + """ + return params + + def get_uv(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV Trip Curve Point UV_V1-3 (see Tables 11-13) uv_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV Trip Curve Point UV_V1-3 Setting uv_trip_v_pts V p.u. + LV Trip Curve Point UV_V1-3 (RofA not specified in 1547) uv_trip_v_pts_er_max V p.u. + LV Trip Curve Point UV_T1-3 (see Tables 11-13) uv_trip_t_pts_er_min s + (RofA not specified in 1547) + LV Trip Curve Point UV_T1-3 Setting uv_trip_t_pts s + LV Trip Curve Point UV_T1-3 (RofA not specified in 1547) uv_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uv(self, params=None): + """ + Set Undervoltage Trip Parameters - IEEE 1547 Table 35 + """ + return params + + def get_of(self, params=None): + """ + Get Overfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + OF Trip Curve Point OF_F1-3 (see Tables 11-13) of_trip_f_pts_er_min Hz + (RofA not specified in 1547) + OF Trip Curve Point OF_F1-3 Setting of_trip_f_pts Hz + OF Trip Curve Point OF_F1-3 (RofA not specified in 1547) of_trip_f_pts_er_max Hz + OF Trip Curve Point OF_T1-3 (see Tables 11-13) of_trip_t_pts_er_min s + (RofA not specified in 1547) + OF Trip Curve Point OF_T1-3 Setting of_trip_t_pts s + OF Trip Curve Point OF_T1-3 (RofA not specified in 1547) of_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_of(self, params=None): + """ + Set Overfrequency Trip Parameters - IEEE 1547 Table 37 + """ + return params + + def get_uf(self, params=None): + """ + Get Underfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + UF Trip Curve Point UF_F1-3 (see Tables 11-13) uf_trip_f_pts_er_min Hz + (RofA not specified in 1547) + UF Trip Curve Point UF_F1-3 Setting uf_trip_f_pts Hz + UF Trip Curve Point UF_F1-3 (RofA not specified in 1547) uf_trip_f_pts_er_max Hz + UF Trip Curve Point UF_T1-3 (see Tables 11-13) uf_trip_t_pts_er_min s + (RofA not specified in 1547) + UF Trip Curve Point UF_T1-3 Setting uf_trip_t_pts s + UF Trip Curve Point UF_T1-3 (RofA not specified in 1547) uf_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uf(self, params=None): + """ + Set Underfrequency Trip Parameters - IEEE 1547 Table 37 + """ + return params + + def get_ov_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV MC Curve Point OV_V1-3 (see Tables 11-13) ov_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV MC Curve Point OV_V1-3 Setting ov_mc_v_pts V p.u. + HV MC Curve Point OV_V1-3 (RofA not specified in 1547) ov_mc_v_pts_er_max V p.u. + HV MC Curve Point OV_T1-3 (see Tables 11-13) ov_mc_t_pts_er_min s + (RofA not specified in 1547) + HV MC Curve Point OV_T1-3 Setting ov_mc_t_pts s + HV MC Curve Point OV_T1-3 (RofA not specified in 1547) ov_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_ov_mc(self, params=None): + """ + Set Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + return params + + def get_uv_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV MC Curve Point UV_V1-3 (see Tables 11-13) uv_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV MC Curve Point UV_V1-3 Setting uv_mc_v_pts V p.u. + LV MC Curve Point UV_V1-3 (RofA not specified in 1547) uv_mc_v_pts_er_max V p.u. + LV MC Curve Point UV_T1-3 (see Tables 11-13) uv_mc_t_pts_er_min s + (RofA not specified in 1547) + LV MC Curve Point UV_T1-3 Setting uv_mc_t_pts s + LV MC Curve Point UV_T1-3 (RofA not specified in 1547) uv_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uv_mc(self, params=None): + """ + Set Undervoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + return params + + def set_cease_to_energize(self, params=None): + """ + + A DER can be directed to cease to energize and trip by changing the Permit service setting to “disabled” as + described in IEEE 1574 Section 4.10.3. + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Cease to energize and trip cease_to_energize bool (True=Enabled) + + """ + return self.set_es_permit_service(params={'es_permit_service': params['cease_to_energize']}) + + ''' Unused + def get_curve_settings(self): + """ + Get DNP3 Curve Points + + :return: curve read dict + """ + curve_read_pts = curve_read.copy() + curve_setting_read = self.read_dnp3_point_map(curve_read_pts) + + return curve_setting_read + + def set_curve_settings(self, params=None): + """ + Set DNP3 Curve Points + + :return: write results dict + """ + curve_write_pts = curve_write.copy() + write = self.write_dnp3_point_map(curve_write_pts, params) + self.ts.log_debug('Curve write results: %s' % write) + + return write + ''' + + +""" ************** Dictionaries for different der1547_dnp3 methods **************** """ + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Nameplate Information (Energy) +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Active Power Rating at Unity Power Factor Watts n/a AI4 +Active Power Rating at Specified Watts n/a AI6 - AI7 +Over-excited Power Factor +Specified Over-excited Power Factor Unitless n/a AI8 +Active Power Rating at Specified Watts n/a AI9 - AI10 +Underexcited Power Factor +Specified Under-excited Power Factor Unitless n/a AI11 +Reactive Power Injected Maximum Rating VArs n/a AI12 +Reactive Power Absorbed Maximum Rating VArs n/a AI13 +Active Power Charge Maximum Rating Watts n/a AI5 +Apparent Power Charge Maximum Rating VA n/a AI15 +Storage Actual Capacity Wh n/a AI16 + +Category: Nameplate Information (RMS) +AC Voltage Nominal Rating RMS Volts n/a AI29 - AI30 +AC Voltage Maximum Rating RMS Volts n/a AI3 +AC Voltage Minimum Rating RMS Volts n/a AI2 +AC Current Maximum Rating RMS Amperes n/a AI19 - AI20 + +Category: Namplate Information (other) +Supported Control Mode Functions List of Yes/No n/a BI31 - BI51 +Normal operating performance category A/B n/a AI22 +Abnormal operating performance category I/II/III n/a AI23 +Reactive Susceptance that remains connected Siemens n/a AI21 +Manufacturer Text n/a Refer to 2.4.1 +Model Text n/a Refer to 2.4.1 +Serial Number Text n/a Refer to 2.4.1 +Version Text n/a Refer to 2.4.1 +''' + + +nameplate_data = {'np_p_max': {'ai': {'4': None}}, + 'np_p_max_over_pf': {'ai': {'6': None}}, + 'np_over_pf': {'ai': {'8': None}}, + 'np_p_max_under_pf': {'ai': {'9': None}}, + 'np_under_pf': {'ai': {'11': None}}, + 'np_va_max': {'ai': {'14': None}}, + 'np_normal_op_cat': {'ai': {'22': None}}, + 'np_abnormal_op_cat': {'ai': {'23': None}}, + # 'np_intentional_island_cat': {'ai': {'23': None}}, + 'np_q_max_inj': {'ai': {'12': None}}, + 'np_q_max_abs': {'ai': {'13': None}}, + 'np_p_max_charge': {'ai': {'5': None}}, + 'np_apparent_power_charge_max': {'ai': {'15': None}}, + 'np_ac_v_nom': {'ai': {'29': None}}, + 'np_ac_v_min_er_min': {'ai': {'2': None}}, + 'np_ac_v_max_er_max': {'ai': {'3': None}}, + # 'np_remote_meter_resistance': {'ai': {'3': None}}, + # 'np_remote_meter_reactance': {'ai': {'3': None}}, + # 'np_manufacturer': {'ai': {'3': None}}, + # 'np_model': {'ai': {'3': None}}, + # 'np_serial_num': {'ai': {'3': None}}, + # 'np_fw_ver': {'ai': {'3': None}}, + 'np_reactive_susceptance': {'ai': {'21': None}}} + +nameplate_support = {'np_support_volt_ride_through': {'bi': {'31': None}}, + 'np_support_freq_ride_through': {'bi': {'32': None}}, + 'np_support_dynamic_reactive_current': {'bi': {'33': None}}, + 'np_support_dynamic_volt_watt': {'bi': {'34': None}}, + 'np_support_freq_watt': {'bi': {'35': None}}, + 'np_support_limit_watt': {'bi': {'36': None}}, + 'np_support_chg_dischg': {'bi': {'37': None}}, + 'np_support_coordinated_chg_dischg': {'bi': {'38': None}}, + 'np_support_active_pwr_response_1': {'bi': {'39': None}}, + 'np_support_active_pwr_response_2': {'bi': {'40': None}}, + 'np_support_active_pwr_response_3': {'bi': {'41': None}}, + 'np_support_automation_generation_control': {'bi': {'42': None}}, + 'np_support_active_pwr_smoothing': {'bi': {'43': None}}, + 'np_support_volt_watt': {'bi': {'44': None}}, + 'np_support_freq_watt_curve': {'bi': {'45': None}}, + 'np_support_constant_vars': {'bi': {'46': None}}, + 'np_support_fixed_pf': {'bi': {'47': None}}, + 'np_support_volt_var_control': {'bi': {'48': None}}, + 'np_support_watt_var': {'bi': {'49': None}}, + 'np_support_pf_correction': {'bi': {'50': None}}, + 'np_support_pricing': {'bi': {'51': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Monitored Information +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Active Power Watts n/a AI537 +Reactive Power VArs n/a AI541 +Voltage Volts n/a AI547 - AI553 +Current Amps n/a AI554 - AI556 +Frequency Hz n/a AI536 +Operational State / Connection Status On/Off/others… n/a BI10 - BI24 +Alarm Status Alarm / No-Alarm n/a BI0 - BI9 +Operational State of Charge Percent n/a AI48 +''' + +""" +This information is indicative of the present operating conditions of the +DER. This information may be read. + +______________________________________________________________________________________________________________ +Parameter params dict key units +______________________________________________________________________________________________________________ +Active Power mn_w kW +Reactive Power mn_var kVAr +Voltage (list) mn_v V-N list + Single phase devices: [V] + 3-phase devices: [V1, V2, V3] +Frequency mn_hz Hz + +Operational State mn_st bool + +Connection State mn_conn bool + +Alarm Status mn_alrm dict of bools + Reported Alarm Status matches the device + present alarm condition for alarm and no + alarm conditions. For test purposes only, the + DER manufacturer shall specify at least one + way an alarm condition that is supported in + the protocol being tested can be set and + cleared. + {'mn_alm_system_comm_error': System Communication Error + 'mn_alm_priority_1': System Has Priority 1 Alarms + 'mn_alm_priority_2': System Has Priority 2 Alarms + 'mn_alm_priority_3': System Has Priority 3 Alarms + 'mn_alm_storage_chg_max': Storage State of Charge at Maximum. Maximum Usable State of Charge reached. + 'mn_alm_storage_chg_high': Storage State of Charge is Too High. Maximum Reserve reached. + 'mn_alm_storage_chg_low': Storage State of Charge is Too Low. Minimum Reserve reached. + 'mn_alm_storage_chg_depleted': Storage State of Charge is Depleted. Minimum Usable State of Charge Reached. + 'mn_alm_internal_temp_high': Storage Internal Temperature is Too High + 'mn_alm_internal_temp_low': Storage External (Ambient) Temperature is Too High} + +Operational State of Charge (not required in 1547) mn_soc_pct pct + +:return: dict with keys shown above. +""" + +monitoring_data = {'mn_w': {'ai': {'537': None}}, + 'mn_var': {'ai': {'541': None}}, + 'mn_v': {'ai': {'547': None}}, + 'mn_hz': {'ai': {'536': None}}, + 'mn_soc_pct': {'ai': {'48': None}}, + 'mn_conn': {'bi': {'23': None}}} + +""" +BI10 System Is In Local State. System has been locked by a local operator which prevents other operators from +executing commands. Note: Local State is also sometimes referred to as Maintenance State. Local State overrides +Lockout State. 1 = System in local state + +BI11 System Is In Lockout State. System has been locked by an operator such that other perators may not +execute commands. Lockout State is also sometimes referred to as Blocked State. 1 = System locked out + +BI12 System Is Starting Up. Set to 1 when a BO "System Initiate Start-up Sequence" command has been received. +1 = Start command has been received. + +BI13 System Is Stopping. Set to 1 when an BO "System Execute Stop" command has been received. +1 = Emergency Stop command has been received. + +BI14 System is Started (Return to Service). If any of the DER Units are started, then true. DER Units in the +maintenance operational state are excluded. 1 = Started + +BI15 System is Stopped (Cease to Energize). If all of the DER Units are stopped, then true. DER Units in the +maintenance operational state are excluded. 1 = Stopped + +BI16 System Permission to Start Status. 1 = Start Permission Granted + +BI17 System Permission to Stop Status. 1 = Stop Permission Granted + +BI18 DER is Connected and Idle. 1 = Idle-Connected + +BI19 DER is Connected and Generating. 1 = On-Connected + +BI20 DER is Connected and Charging. 1 = On-Charging-Connected + +BI21 DER is Off but Available to Start. 1 = Off-Available + +BI22 DER is Off and Not Available to Start. 1 = Off-Not-Available + +BI23 DER Connect/Disconnect Switch Closed Status. 1 = Closed + +BI24 DER Connect/Disconnect Switch Movement Status. 1 = Moving + +BI25 Islanded Mode. Determines how the DER behaves when in an Islanded configuration. + <0> Isochronous Mode. DER attempts to control voltage and frequency independent of configured curves and settings + up to the limits of the machine's capabilities in order to achieve the AO Reference Voltage and AO nominal frequency. + <1> Droop Mode. DER acts as a follower using Volt/VAR and Freq/Watt curves. + +""" + +operational_state = {'mn_op_local': {'bi': {'10': None}}, + 'mn_op_lockout': {'bi': {'11': None}}, + 'mn_op_starting': {'bi': {'12': None}}, + 'mn_op_stopping': {'bi': {'13': None}}, + 'mn_op_started': {'bi': {'14': None}}, + 'mn_op_stopped': {'bi': {'15': None}}, + 'mn_op_permission_to_start': {'bi': {'16': None}}, + 'mn_op_permission_to_stop': {'bi': {'17': None}}} + +connection_state = {'mn_conn_connected_idle': {'bi': {'18': None}}, + 'mn_conn_connected_generating': {'bi': {'19': None}}, + 'mn_conn_connected_charging': {'bi': {'20': None}}, + 'mn_conn_off_available': {'bi': {'21': None}}, + 'mn_conn_off_not_available': {'bi': {'22': None}}, + 'mn_conn_switch_closed_status': {'bi': {'23': None}}, + 'mn_conn_switch_closed_movement': {'bi': {'24': None}}} +""" +BI0 - System Communication Error +BI1 - System Has Priority 1 Alarms +BI2 - System Has Priority 2 Alarms +BI3 - System Has Priority 3 Alarms +BI4 - Storage State of Charge at Maximum. Maximum Usable State of Charge reached. +BI5 - Storage State of Charge is Too High. Maximum Reserve Percentage (of usable capacity) reached. +BI6 - Storage State of Charge is Too Low. Minimum Reserve Percentage (of usable capacity) reached. +BI7 - Storage State of Charge is Depleted. Minimum Usable State of Charge Reached. +BI8 - Storage Internal Temperature is Too High +BI9 - Storage External (Ambient) Temperature is Too High +""" + +alarm_state = {'mn_alm_system_comm_error': {'bi': {'0': None}}, + 'mn_alm_priority_1': {'bi': {'1': None}}, + 'mn_alm_priority_2': {'bi': {'2': None}}, + 'mn_alm_priority_3': {'bi': {'3': None}}, + 'mn_alm_storage_chg_max': {'bi': {'4': None}}, + 'mn_alm_storage_chg_high': {'bi': {'5': None}}, + 'mn_alm_storage_chg_low': {'bi': {'6': None}}, + 'mn_alm_storage_chg_depleted': {'bi': {'7': None}}, + 'mn_alm_internal_temp_high': {'bi': {'8': None}}, + 'mn_alm_internal_temp_low': {'bi': {'9': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Constant Power Factor Mode +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Constant Power Factor Enable On/Off BO28 BI80 +Constant Power Factor Unitless AO210 - AO211 AI288 - AI289 +Constant Power Factor Excitation Over/Under BO10 - BO11 BI29 - BI30 +''' + +fixed_pf = {'const_pf_mode_enable': {'bi': {'80': None}}, + # 'const_pf_abs': {'ai': {'288': None}}, + # 'const_pf_inj': {'ai': {'288': None}}, + 'const_pf': {'ai': {'288': None}}, + 'const_pf_excitation': {'bi': {'29': None}}} + +fixed_pf_write = {'const_pf_mode_enable': {'bo': {'28': None}}, + # 'const_pf_abs': {'ao': {'210': None}}, + # 'const_pf_inj': {'ao': {'210': None}}, + 'const_pf': {'ao': {'210': None}}, + 'const_pf_excitation': {'bo': {'10': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Volt-VAr Mode +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Voltage-Reactive Power (Volt-VAr) Enable On/Off BO29 BI81 +VRef (Reference Voltage) Volts AO0 - AO1 AI29 - AI30 +Autonomous VRef Adjustment Enable On/Off BO41 BI93 +VRef Adjustment Time Constant Seconds AO220 AI300 +V/Q Curve Points (x,y) Volts, VArs AO217, AO244-AO448 AI303 +Open Loop Response Time Seconds AO218 - AO219 AI298 - AI299 + +Step Description Optionality Function Codes Type Point Read-back pt +1. Set priority of this mode Optional Direct Operate / Response AO AO212 AI290 +2. Set the enabling time window Optional Direct Operate / Response AO AO213 AI291 +3. Set the enabling ramp time Optional Direct Operate / Response AO AO214 AI292 +4. Set the enabling reversion timeout Optional Direct Operate / Response AO AO215 AI293 +5. Identify the meter used to measure + the voltage. By default this is the + System Meter (ID = 0) Optional Direct Operate / Response AO AO216 AI294 +6. If using a fixed Voltage reference: + Set the reference voltage if + it has not already been set Optional Direct Operate / Response AO AO0 AI29 +7. If using a fixed Voltage reference: + Set the reference voltage offset + if it has not already been set Optional Direct Operate / Response AO AO1 AI30 +8. If autonomously adjusting the + Voltage reference, set the time + constant for the lowpass filter Optional Direct Operate / Response AO AO220 AI300 +9. If autonomously adjusting the + Voltage reference, enable + autonomous adjustment Optional Direct Operate / Response BO BO41 BI93 + +10. Select which curve to edit Optional Direct Operate / Response AO AO244 AI328 +11. Specify the Curve Mode Type as + <2> Volt-VAr mode Optional Direct Operate / Response AO AO245 AI329 +12. Specify that the Independent + (XValue) units are <129> Percent + Voltage Optional Direct Operate / Response AO AO247 AI331 +13. Specify the Dependent (Y-Value) + units as described in Table 53. Optional Direct Operate / Response AO AO248 AI332 +14. Set percent voltage (X-Values) + for each curve point Optional Direct Operate / Response AO AO249,..AI333,.. +15. Set percent VArs (Y-Values) for + each curve point Optional Direct Operate / Response AO AO250,..AI334,.. +16. Set number of points used for + the curve. Optional Direct Operate / Response AO AO246 AI330 +17. Set the time constant for the + output of the curve Optional Direct Operate / Response AO AO218-9 AI298-9 +18. Identify the index of the curve + being used Optional Direct Operate / Response AO AO217 AI297 + +19. Enable the Volt-VAr Control + Mode Required Select/Response, Op/Resp BO BO29 BI48 +20. Read the adjusted reference + voltage, if it is not fixed Optional Read Class 1/2/3 AI296 +21. Read the measured Voltage Optional Read Class 1/2/3 AI295 +22. Read the attempted VArs Optional Read Class 1/2/3 AI301 +23. Read the actual VArs (if using +system meter) Optional Read Class 1/2/3 AI541 +______________________________________________________________________________________________________________ + +der1547 Abstraction: +______________________________________________________________________________________________________________ +Parameter params dict key units +______________________________________________________________________________________________________________ +Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) +Vref (0.95-1.05) qv_vref V p.u. +Autonomous Vref Adjustment Enable qv_vref_auto_mode str +Vref adjustment time Constant (300-5000) qv_vref_olrt s +Q(V) Curve Point V1-4 (list, e.g., [95, 99, 101, 105]) qv_curve_v_pts V p.u. +Q(V) Curve Point Q1-4 (list) qv_curve_q_pts VAr p.u. +Q(V) Open Loop Response Time Setting (1-90) qv_olrt s +______________________________________________________________________________________________________________ +''' + +volt_var_data = {'qv_mode_enable': {'bi': {'81': None}}, + 'qv_vref': {'ai': {'29': None}}, + 'qv_vref_auto_mode': {'bi': {'93': None}}, + 'qv_vref_olrt': {'ai': {'300': None}}, + 'qv_olrt': {'ai': {'298': None}}} + +volt_var_write = {'qv_mode_enable': {'bo': {'29': None}}, + 'qv_vref': {'ao': {'0': None}}, + 'qv_vref_auto_mode': {'bo': {'41': None}}, + 'qv_vref_olrt': {'ao': {'220': None}}, + 'qv_olrt': {'ao': {'218': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Constant VAr Mode +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Constant Reactive Power Mode Enable On/Off BO27 BI79 +Constant Reactive Power VArs AO203 AI281 +''' + +""" +Get Constant Reactive Power Mode +______________________________________________________________________________________________________________ +Parameter params dict key units +______________________________________________________________________________________________________________ +Constant Reactive Power Mode Enable const_q_mode_enable bool (True=Enabled) +Constant Reactive Power Excitation (not specified in const_q_mode_excitation str ('inj', 'abs') + 1547) +Constant Reactive power setting (See Table 7) const_q VAr p.u. +Constant Reactive Power (RofA not specified in 1547) const_q_abs_er_max VAr p.u. + Absorbing Reactive Power Setting. Per unit value + based on NP Qmax Abs. Negative signs should not be + used but if present indicate absorbing VAr. +Constant Reactive Power (RofA not specified in 1547) const_q_inj_er_max VAr p.u. + Injecting Reactive Power (minimum RofA) Per unit + value based on NP Qmax Inj. Positive signs should + not be used but if present indicate Injecting VAr. +Maximum Response Time to maintain constant reactive const_q_olrt_er_min s + power (not specified in 1547) +Maximum Response Time to maintain constant reactive const_q_olrt s + power (not specified in 1547) +Maximum Response Time to maintain constant reactive const_q_olrt_er_max s + power(not specified in 1547) + +:return: dict with keys shown above. +""" + +reactive_power_data = {'const_q_mode_enable': {'bi': {'79': None}}, + 'const_q': {'ai': {'281': None}}} + +reactive_power_write = {'const_q_mode_enable': {'bo': {'27': None}}, + 'const_q': {'ao': {'203': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Frequency Droop (Frequency-Watt) +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Over-frequency Droop Deadband DBOF Hz AO62 - AO63 AI121 - AI122 +Under-frequency Droop Deadband DBUF Hz AO66 - AO67 AI125 - AI126 +Over-frequency Droop Slope KOF Watts per Hz AO64 - AO65 AI123 - AI124 +Under-frequency Droop Slope KUF Watts per Hz AO68 - AO69 AI127 - AI128 +Open Loop Response Time Seconds AO72 - AO73 AI131 - AI132 + +AI122 = Frequency-Watt High Stopping Frequency +AI126 = Frequency-Watt Low Stopping +... + +Get P(f), Frequency-Active Power Mode Parameters - IEEE 1547 Table 38 +______________________________________________________________________________________________________________ +Parameter params dict key units +______________________________________________________________________________________________________________ +Frequency-Active Power Mode Enable pf_mode_enable bool (True=Enabled) +P(f) Overfrequency Droop dbOF Setting pf_dbof Hz +P(f) Underfrequency Droop dbUF Setting pf_dbuf Hz +P(f) Overfrequency Droop kOF Setting pf_kof unitless +P(f) Underfrequency Droop kUF Setting pf_kuf unitless +P(f) Open Loop Response Time Setting pf_olrt s +''' + +freq_watt_data = {'pf_mode_enable': {'bi': {'78': None}}, + 'pf_dbof': {'ai': {'121': None}}, + 'pf_dbuf': {'ai': {'125': None}}, + 'pf_kof': {'ai': {'123': None}}, + 'pf_kuf': {'ai': {'127': None}}, + 'pf_olrt': {'ai': {'131': None}}} + +freq_watt_write = {'pf_mode_enable': {'ao': {'121': None}}, + 'pf_dbof': {'ao': {'62': None}}, + 'pf_dbuf': {'ao': {'66': None}}, + 'pf_kof': {'ao': {'64': None}}, + 'pf_kuf': {'ao': {'68': None}}, + 'pf_olrt': {'ao': {'72': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Active Power +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Limit Active Power Enable On/Off BO17 BI69 +Limit Mode Maximum Active Power Watts (pct) AO87 - AO88 AI148 - AI149 +''' + +limit_max_power_data = {#'p_lim_mode_enable_charging': {'bi': {'70': None}}, + 'p_lim_mode_enable': {'bi': {'69': None}}, + #'p_lim_w_charging': {'ai': {'148': None}}, + 'p_lim_w': {'ai': {'149': None}}} + +limit_max_power_write = {'p_lim_mode_enable': {'bo': {'17': None}}, + 'p_lim_w': {'ao': {'88': None}}} + + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Enter Service +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Permit service Enabled/Disabled BO3 BI16 +ES Voltage High Percent Nominal AO6 AI50 +ES Voltage Low Percent Nominal AO7 AI51 +ES Frequency High Hz AO8 AI52 +ES Frequency Low Hz AO9 AI53 +ES Delay Seconds AO10 AI54 +ES Randomized Delay Seconds AO11 AI55 +ES Ramp Rate Seconds AO12 AI56 +''' + +enter_service_data = {'es_permit_service': {'bi': {'16': None}}, + 'es_volt_high': {'ai': {'50': None}}, + 'es_volt_low': {'ai': {'51': None}}, + 'es_freq_high': {'ai': {'52': None}}, + 'es_freq_low': {'ai': {'53': None}}, + 'es_delay': {'ai': {'54': None}}, + 'es_randomized_delay': {'ai': {'55': None}}, + 'es_ramp_rate': {'ai': {'56': None}}} + +enter_service_write = {'es_permit_service': {'bo': {'3': None}}, + 'es_volt_high': {'ao': {'6': None}}, + 'es_volt_low': {'ao': {'7': None}}, + 'es_freq_high': {'ao': {'8': None}}, + 'es_freq_low': {'ao': {'9': None}}, + 'es_delay': {'ao': {'10': None}}, + 'es_randomized_delay': {'ao': {'11': None}}, + 'es_ramp_rate': {'ao': {'12': None}}} + +curve_read = {'curve_index': {'ai': {'297': None}}, + 'curve_edit_selector': {'ai': {'328': None}}, + 'curve_mode_type': {'ai': {'329': None}}, + 'no_of_points': {'ai': {'330': None}}, + 'x_value': {'ai': {'331': None}}, + 'y_value': {'ai': {'332': None}}, + 'x1': {'ai': {'333': None}}, + 'y1': {'ai': {'334': None}}, + 'x2': {'ai': {'335': None}}, + 'y2': {'ai': {'336': None}}, + 'x3': {'ai': {'337': None}}, + 'y3': {'ai': {'338': None}}, + 'x4': {'ai': {'339': None}}, + 'y4': {'ai': {'340': None}}, + 'x5': {'ai': {'341': None}}, + 'y5': {'ai': {'342': None}}, + 'x6': {'ai': {'343': None}}, + 'y6': {'ai': {'344': None}}, + 'x7': {'ai': {'345': None}}, + 'y7': {'ai': {'346': None}}, + 'x8': {'ai': {'347': None}}, + 'y8': {'ai': {'348': None}}, + } + +curve_write = {'curve_index': {'ao': {'217': None}}, + 'curve_edit_selector': {'ao': {'244': None}}, + 'curve_mode_type': {'ai': {'245': None}}, + 'no_of_points': {'ao': {'246': None}}, + 'x_value': {'ao': {'247': None}}, + 'y_value': {'ao': {'248': None}}, + 'x1': {'ao': {'249': None}}, + 'y1': {'ao': {'250': None}}, + 'x2': {'ao': {'251': None}}, + 'y2': {'ao': {'252': None}}, + 'x3': {'ao': {'253': None}}, + 'y3': {'ao': {'254': None}}, + 'x4': {'ao': {'255': None}}, + 'y4': {'ao': {'256': None}}, + 'x5': {'ao': {'257': None}}, + 'y5': {'ao': {'258': None}}, + 'x6': {'ao': {'259': None}}, + 'y6': {'ao': {'260': None}}, + 'x7': {'ao': {'261': None}}, + 'y7': {'ao': {'262': None}}, + 'x8': {'ao': {'263': None}}, + 'y8': {'ao': {'264': None}}, + } + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Watt-VAr Mode +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Active Power-Reactive Power (Watt-VAr) On/Off BO30 BI82 +Enable +P/Q Curve Points (x,y) Watts, VArs AO226, AO244-AO448 AI308, AI328-AI532 +''' +watt_var_data = {'watt_var_enable': {'bi': {'82': None}}} + +watt_var_write = {'watt_var_enable': {'bo': {'30': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Volt-Watt Mode +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +Voltage-Active Power Mode Enable On/Off BO25 BI77 +V/P Curve Points (x,y) Volts, Watts AO173, AO244-AO44 AI248, AI328 - AI532 +Open Loop Response Time Seconds AO175 - AO176 AI251 - AI252 + +______________________________________________________________________________________________________________ +Parameter params dict key units +______________________________________________________________________________________________________________ +Voltage-Active Power Mode Enable pv_mode_enable bool +P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. +P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. +P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. +P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + + +''' +volt_watt_data = {'pv_mode_enable': {'bi': {'77': None}}, + 'pv_olrt': {'ai': {'251': None}}} + +volt_watt_write = {'pv_mode_enable': {'bo': {'25': None}}, + 'pv_olrt': {'ao': {'175': None}}} + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Voltage Trip +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +HV Trip Curve Points (x,y) Seconds, Volts AO23, AO244-AO448 AI73, AI328 - AI532 +LV Trip Curve Points (x,y) Seconds, Volts AO24, AO244-AO448 AI74, AI328 - AI532 +''' + +''' +DNP3 App Note Table 63 - Mapping of IEC Std 1547 to The DNP3 DER Profile + +Category: Momentary Cessation +____________________________________________________________________________________________________________ +Information Units Output(s) Input(s) +____________________________________________________________________________________________________________ +HV Momentary Cessation Curve Points (x,y) Seconds, Volts AO25, AO244-AO448 AI75, AI328 - AI532 +LV Momentary Cessation Curve Points (x,y) Seconds, Volts AO26, AO244-AO448 AI76, AI328 - AI532 +Frequency Trip HF Trip Curve Points (x,y) Seconds, Hz AO28, AO244-AO448 AI79, AI328 - AI532 +LF Trip Curve Points (x,y) Seconds, Hz AO29, AO244-AO448 AI80, AI328 - AI532 +''' + +''' +Table 29 – Steps to perform a Connect/Disconnect DER +Step Description Optionality Function Codes Data Type Point Number Read-back Point +1. Set time window Optional Direct Operate / Response AO AO16 AI60 +2. Set reversion timeout Optional Direct Operate / Response AO AO17 AI61 +3. Retrieve status of Optional Read / Response or BI BI23 n/a +switch Unsolicited Response +4. Issue switch control Required Select / Response, BO BO5 BI23 +command and receive Operate / Response +response +5. Detect if switch is Optional Read / Response or BI BI24 n/a +moving Unsolicited Response +''' + +conn_data = {'conn': {'bi': {'21': None}}} +conn_write = {'conn': {'bo': {'5': None}}} + diff --git a/Lib/svpelab/der1547_sma.py b/Lib/svpelab/der1547_sma.py new file mode 100644 index 0000000..896585a --- /dev/null +++ b/Lib/svpelab/der1547_sma.py @@ -0,0 +1,876 @@ +""" +DER1547 methods defined for SMA devices + +Note that this acts as an abstraction for values returned from der_sma.py + +It maps the IEEE 1547 names from der1547.py to/from der.py names and uses the methods from +der_sma.py to perform the read/write actions +""" + +try: + import os + from . import der1547 + from . import der + from . import der_sma + import subprocess + import socket +except Exception as e: + print(('Import problem in der1547_sma.py: %s' % e)) + raise der1547.DER1547Error('Import problem in der1547_sma.py: %s' % e) + +sma_info = der_sma.sma_info + + +def der1547_info(): + return sma_info + + +def params(info, group_name): + der_sma.params(info, group_name) + + +GROUP_NAME = der_sma.GROUP_NAME + + +class DER1547(der1547.DER1547): + + def __init__(self, ts, group_name): + der1547.DER1547.__init__(self, ts, group_name) # inherit der1547 functions + self.sma = der_sma.DER(ts, group_name) # create DER SMA object to send commands + # self.ts.log_debug('self.sma in der1547_sma is %s' % self.sma) + + def param_value(self, name): + return self.sma.param_value(name) + + def config(self): + return self.sma.config() + + def open(self): + return self.sma.open() + + def close(self): + return self.sma.close() + + def info(self): + return self.sma.info() + + def get_nameplate(self): + """ + Get Nameplate information - See IEEE 1547-2018 Table 28 + ______________________________________________________________________________________________________________ + Parameter params dict key Units + ______________________________________________________________________________________________________________ + Active power rating at unity power factor np_p_max kW + (nameplate active power rating) + Active power rating at specified over-excited np_p_max_over_pf kW + power factor + Specified over-excited power factor np_over_pf Decimal + Active power rating at specified under-excited np_p_max_under_pf kW + power factor + Specified under-excited power factor np_under_pf Decimal + Apparent power maximum rating np_va_max kVA + Normal operating performance category np_normal_op_cat str + e.g., CAT_A-CAT_B + Abnormal operating performance category np_abnormal_op_cat str + e.g., CAT_II-CAT_III + Intentional Island Category (optional) np_intentional_island_cat str + e.g., UNCAT-INT_ISLAND_CAP-BLACK_START-ISOCH + Reactive power injected maximum rating np_q_max_inj kVAr + Reactive power absorbed maximum rating np_q_max_abs kVAr + Active power charge maximum rating np_p_max_charge kW + Apparent power charge maximum rating np_apparent_power_charge_max KVA + AC voltage nominal rating np_ac_v_nom Vac + AC voltage maximum rating np_ac_v_max_er_max Vac + AC voltage minimum rating np_ac_v_min_er_min Vac + Supported control mode functions np_supported_modes (dict) str list + e.g., ['CONST_PF', 'QV', 'QP', 'PV', 'PF'] + Reactive susceptance that remains connected to np_reactive_susceptance Siemens + the Area EPS in the cease to energize and trip + state + Maximum resistance (R) between RPA and POC. np_remote_meter_resistance Ohms + (unsupported in 1547) + Maximum reactance (X) between RPA and POC. np_remote_meter_reactance Ohms + (unsupported in 1547) + Manufacturer np_manufacturer str + Model np_model str + Serial number np_serial_num str + Version np_fw_ver str + + :return: dict with keys shown above. + """ + + nameplate = self.sma.nameplate() + + ieee_dict = {} + if nameplate.get('WRtg') is not None: + ieee_dict['np_p_max'] = nameplate['WRtg'] + if nameplate.get('VARtg') is not None: + ieee_dict['np_q_max_inj'] = nameplate['VARtg'] + ieee_dict['np_q_max_abs'] = nameplate['VARtg'] + # overwrite with quadrant-specific values, if they exist + if nameplate.get('VArRtgQ1') is not None: + ieee_dict['np_q_max_inj'] = nameplate['VArRtgQ1'] + if nameplate.get('VArRtgQ4') is not None: + ieee_dict['np_q_max_abs'] = nameplate['VArRtgQ4'] + if nameplate.get('PFRtgQ1') is not None: # todo: check sign + ieee_dict['np_over_pf'] = nameplate['PFRtgQ1'] + if nameplate.get('PFRtgQ4') is not None: + ieee_dict['np_under_pf'] = nameplate['PFRtgQ4'] + if nameplate.get('MaxChaRte') is not None: + ieee_dict['np_p_max_charge'] = nameplate['MaxChaRte'] + + info = self.sma.info() + if info.get('Manufacturer') is not None: + ieee_dict['np_manufacturer'] = info['Manufacturer'] + if info.get('Model') is not None: + ieee_dict['np_model'] = info['Model'] + if info.get('SerialNumber') is not None: + ieee_dict['np_serial_num'] = info['SerialNumber'] + if info.get('Version') is not None: + ieee_dict['np_fw_ver'] = info['Version'] + + return ieee_dict + + def get_settings(self): + """ + Get settings information + + :return: params dict with keys shown in nameplate. + """ + return self.get_nameplate() + + def set_settings(self, params=None): + """ + Set settings information + + :return: params dict with keys shown in nameplate. + """ + + return self.set_configuration(params) + + def get_configuration(self): + """ + Get configuration information + + :return: params dict with keys shown in nameplate. + """ + return self.get_nameplate() + + def set_configuration(self, params=None): + """ + Set configuration information. params are those in get_nameplate(). + """ + + return {} + + def get_monitoring(self): + """ + This information is indicative of the present operating conditions of the + DER. This information may be read. + + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Active Power mn_w kW + Reactive Power mn_var kVAr + Voltage (list) mn_v V-N list + Single phase devices: [V] + 3-phase devices: [V1, V2, V3] + Frequency mn_hz Hz + + Operational State mn_st dict of bools + {'mn_op_local': System in local/maintenance state + 'mn_op_lockout': System locked out + 'mn_op_starting': Start command has been received + 'mn_op_stopping': Emergency Stop command has been received + 'mn_op_started': Started + 'mn_op_stopped': Stopped + 'mn_op_permission_to_start': Start Permission Granted + 'mn_op_permission_to_stop': Stop Permission Granted} + + Connection State mn_conn dict of bools + {'mn_conn_connected_idle': Idle-Connected + 'mn_conn_connected_generating': On-Connected + 'mn_conn_connected_charging': On-Charging-Connected + 'mn_conn_off_available': Off-Available + 'mn_conn_off_not_available': Off-Not-Available + 'mn_conn_switch_closed_status': Switch Closed + 'mn_conn_switch_closed_movement': Switch Moving} + + Alarm Status mn_alrm dict of bools + Reported Alarm Status matches the device + present alarm condition for alarm and no + alarm conditions. For test purposes only, the + DER manufacturer shall specify at least one + way an alarm condition that is supported in + the protocol being tested can be set and + cleared. + {'mn_alm_system_comm_error': System Communication Error + 'mn_alm_priority_1': System Has Priority 1 Alarms + 'mn_alm_priority_2': System Has Priority 2 Alarms + 'mn_alm_priority_3': System Has Priority 3 Alarms + 'mn_alm_storage_chg_max': Storage State of Charge at Maximum. Maximum Usable State of Charge reached. + 'mn_alm_storage_chg_high': Storage State of Charge is Too High. Maximum Reserve reached. + 'mn_alm_storage_chg_low': Storage State of Charge is Too Low. Minimum Reserve reached. + 'mn_alm_storage_chg_depleted': Storage State of Charge is Depleted. Minimum Usable State of Charge Reached. + 'mn_alm_internal_temp_high': Storage Internal Temperature is Too High + 'mn_alm_internal_temp_low': Storage External (Ambient) Temperature is Too High} + + Operational State of Charge (not required in 1547) mn_soc_pct pct + + :return: dict with keys shown above. + """ + nameplate = self.sma.nameplate() + + ieee_dict = {} + if nameplate.get('W') is not None: + ieee_dict['mn_w'] = nameplate['W']/1000. # in kW + if nameplate.get('VAr') is not None: + ieee_dict['mn_var'] = nameplate['VAr']/1000. # in kVar + if nameplate.get('PhVphA') is not None: + ieee_dict['mn_v'] = [nameplate['PhVphA']] + if nameplate.get('PhVphB') is not None: + ieee_dict['mn_v'].append(nameplate['PhVphB']) + if nameplate.get('PhVphC') is not None: + ieee_dict['mn_v'].append(nameplate['PhVphC']) + if nameplate.get('Hz') is not None: + ieee_dict['mn_hz'] = nameplate['Hz'] + + return ieee_dict + + def get_const_pf(self): + """ + Get Constant Power Factor Mode control settings. IEEE 1547-2018 Table 30. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Absorbing Setting const_pf_abs VAr p.u + Constant Power Factor Injecting Setting const_pf_inj VAr p.u + Maximum response time to maintain constant power const_pf_olrt s + factor. (Not in 1547) + + :return: dict with keys shown above. + """ + + pf = self.sma.fixed_pf() + ieee_dict = {} + if pf.get('Ena') is not None: + ieee_dict['const_pf_mode_enable'] = pf['Ena'] + if pf.get('PF') is not None: + if pf['PF'] >= 0.: + ieee_dict['const_pf_abs'] = pf['PF'] + ieee_dict['const_pf_excitation'] = 'abs' + else: + ieee_dict['const_pf_inj'] = pf['PF'] + ieee_dict['const_pf_excitation'] = 'inj' + + return ieee_dict + + def set_const_pf(self, params=None): + """ + Set Constant Power Factor Mode control settings. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Absorbing Setting const_pf_abs VAr p.u + Constant Power Factor Injecting Setting const_pf_inj VAr p.u + Maximum response time to maintain constant power const_pf_olrt s + factor. (Not in 1547) + + :return: dict with keys shown above. + """ + new_params = {} + + if 'const_pf_mode_enable' in params: + new_params['Ena'] = params['const_pf_mode_enable'] + + # todo improve this logic + if 'const_pf_excitation' in params and 'const_pf_inj' in params: + if params['const_pf_excitation'] == 'inj': + new_params['PF'] = -1*abs(parms['const_pf_inj']) + if 'const_pf_excitation' in params and 'const_pf_abs' in params: + if params['const_pf_excitation'] == 'abs': + new_params['PF'] = abs(parms['const_pf_abs']) + + return self.sma.fixed_pf(params=new_params) + + def get_qv(self): + """ + Get Q(V), Volt-Var, Voltage-Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref (0.95-1.05) qv_vref V p.u. + Autonomous Vref Adjustment Enable qv_vref_auto_mode str + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Q(V) Curve Point V1-4 (list), [0.95, 0.99, 1.01, 1.05] qv_curve_v_pts V p.u. + Q(V) Curve Point Q1-4 (list), [1., 0., 0., -1.] qv_curve_q_pts VAr p.u. + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + """ + + vv = self.sma.volt_var() + + ieee_dict = {} + if vv.get('Ena') is not None: + ieee_dict['qv_mode_enable'] = vv['Ena'] + if vv.get('curve') is not None: + if vv['curve'].get('v') is not None: + ieee_dict['qv_curve_v_pts'] = vv['curve'].get('v') + if vv['curve'].get('var') is not None: + ieee_dict['qv_curve_q_pts'] = vv['curve'].get('var') + + return ieee_dict + + def set_qv(self, params=None): + """ + Set Q(V), Volt-Var + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref (0.95-1.05) qv_vref V p.u. + Autonomous Vref Adjustment Enable qv_vref_auto_mode str + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Q(V) Curve Point V1-4 (list), [0.95, 0.99, 1.01, 1.05] qv_curve_v_pts V p.u. + Q(V) Curve Point Q1-4 (list), [1., 0., 0., -1.] qv_curve_q_pts VAr p.u. + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + ______________________________________________________________________________________________________________ + """ + + new_params = {'curve': {}} + if params.get('qv_mode_enable') is not None: + new_params['Ena'] = params['qv_mode_enable'] + if params.get('qv_curve_v_pts') is not None: + new_params['curve']['v'] = params['qv_curve_v_pts'] + if params.get('qv_curve_q_pts') is not None: + new_params['curve']['var'] = params['qv_curve_q_pts'] + + return self.sma.volt_var(params=new_params) + + def get_const_q(self): + """ + Get Constant Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Constant Reactive Power Mode Enable const_q_mode_enable bool (True=Enabled) + Constant Reactive Power Excitation (not specified in const_q_mode_excitation str ('inj', 'abs') + 1547) + Constant Reactive power setting (See Table 7) const_q VAr p.u. + Constant Reactive Power (RofA not specified in 1547) const_q_abs_er_max VAr p.u. + Absorbing Reactive Power Setting. Per unit value + based on NP Qmax Abs. Negative signs should not be + used but if present indicate absorbing VAr. + Constant Reactive Power (RofA not specified in 1547) const_q_inj_er_max VAr p.u. + Injecting Reactive Power (minimum RofA) Per unit + value based on NP Qmax Inj. Positive signs should + not be used but if present indicate Injecting VAr. + Maximum Response Time to maintain constant reactive const_q_olrt_er_min s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt s + power (not specified in 1547) + Maximum Response Time to maintain constant reactive const_q_olrt_er_max s + power(not specified in 1547) + + :return: dict with keys shown above. + """ + + return {} + + def set_const_q(self, params=None): + """ + This information is used to update functional and mode settings for the + Constant Reactive Power Mode. This information may be written. + """ + + return {} + + def get_conn(self): + """ + Get Connection + + conn = bool for connection + """ + + return {} + + def set_conn(self, params=None): + """ + This information is used to update functional and mode settings for the + Constant Reactive Power Mode. This information may be written. + + conn = bool for connection + """ + + return {} + + def get_pf(self): + """ + Get P(f), Frequency-Active Power Mode Parameters - IEEE 1547 Table 38 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable pf_mode_enable bool (True=Enabled) + P(f) Overfrequency Droop dbOF Setting pf_dbof Hz + P(f) Underfrequency Droop dbUF Setting pf_dbuf Hz + P(f) Overfrequency Droop kOF Setting pf_kof unitless + P(f) Underfrequency Droop kUF Setting pf_kuf unitless + P(f) Open Loop Response Time Setting pf_olrt s + + :return: dict with keys shown above. + """ + + ieee_dict = {} + fw = self.sma.freq_droop() + if fw.get('Ena') is not None: + ieee_dict['pf_mode_enable'] = fw.get('Ena') + if fw.get('dbOF') is not None: + ieee_dict['pf_dbof'] = fw.get('dbOF') + if fw.get('dbUF') is not None: + ieee_dict['pf_dbuf'] = fw.get('dbUF') + if fw.get('kOF') is not None: + ieee_dict['pf_kof'] = fw.get('kOF') + if fw.get('kUF') is not None: + ieee_dict['pf_kuf'] = fw.get('kUF') + + return ieee_dict + + def set_pf(self, params=None): + """ + Set Frequency-Active Power Mode. + """ + + new_params = {} + if params.get('pf_mode_enable') is not None: + new_params['Ena'] = params['pf_mode_enable'] + if params.get('pf_dbof') is not None: + new_params['dbOF'] = params['pf_dbof'] + if params.get('pf_dbuf') is not None: + new_params['dbUF'] = params['pf_dbuf'] + if params.get('pf_kof') is not None: + new_params['kOF'] = params['pf_kof'] + if params.get('pf_kuf') is not None: + new_params['kUF'] = params['pf_kuf'] + + return self.sma.freq_droop(params=new_params) + + def get_p_lim(self): + """ + Get Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power Min p_lim_w_er_min P p.u. + Maximum Active Power p_lim_w P p.u. + Maximum Active Power Max p_lim_w_er_max P p.u. + """ + + ieee_dict = {} + p_lim = self.sma.limit_max_power() + if p_lim.get('Ena') is not None: + ieee_dict['p_lim_mode_enable'] = p_lim.get('Ena') + if p_lim.get('WMaxPct') is not None: + ieee_dict['p_lim_w'] = p_lim.get('WMaxPct') + + return ieee_dict + + def set_p_lim(self, params=None): + """ + Set Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power Min p_lim_w_er_min P p.u. + Maximum Active Power p_lim_w P p.u. + Maximum Active Power Max p_lim_w_er_max P p.u. + """ + new_params = {} + if params.get('p_lim_mode_enable') is not None: + new_params['Ena'] = params['p_lim_mode_enable'] + if params.get('p_lim_w') is not None: + new_params['WMaxPct'] = params['p_lim_w'] + + return self.sma.limit_max_power(new_params) + + def get_qp(self): + """ + Get Q(P) parameters. [Watt-Var] + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Active Power-Reactive Power (Watt-VAr) Enable qp_mode_enable bool + P-Q curve P1-3 Generation Setting (list) qp_curve_p_gen_pts P p.u. + P-Q curve Q1-3 Generation Setting (list) qp_curve_q_gen_pts VAr p.u. + P-Q curve P1-3 Load Setting (list) qp_curve_p_load_pts P p.u. + P-Q curve Q1-3 Load Setting (list) qp_curve_q_load_pts VAr p.u. + QP Open Loop Response Time Setting qp_olrt s + """ + + ieee_dict = {} + wv = self.sma.watt_var() + if wv.get('Ena') is not None: + ieee_dict['qp_mode_enable'] = wv.get('Ena') + if wv.get('curve') is not None: + if wv['curve'].get('w') is not None: + ieee_dict['qp_curve_p_gen_pts'] = wv['curve'].get('w') + if wv['curve'].get('var') is not None: + ieee_dict['qp_curve_q_gen_pts'] = wv['curve'].get('var') + + return ieee_dict + + def set_qp(self, params=None): + """ + Set Q(P) parameters. [Watt-Var] + """ + + new_params = {'curve': {}} + if params('qp_mode_enable') is not None: + new_params['Ena'] = params('qp_mode_enable') + if params('qp_curve_p_gen_pts') is not None: + new_params['curve']['w'] = params('qp_curve_p_gen_pts') + if params('qp_curve_p_gen_pts') is not None: + new_params['curve']['var'] = params('qp_curve_q_gen_pts') + + return self.sma.watt_var(new_params) + + def get_pv(self, params=None): + """ + Get P(V), Voltage-Active Power (Volt-Watt), Parameters + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Active Power Mode Enable pv_mode_enable bool + P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. + P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. + P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. + P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + """ + + ieee_dict = {} + vw = self.sma.volt_watt() + if vw.get('Ena') is not None: + ieee_dict['pv_mode_enable'] = vw.get('Ena') + if vw.get('curve') is not None: + if vw['curve'].get('v') is not None: + ieee_dict['pv_curve_v_pts'] = vw['curve'].get('v') + if vw['curve'].get('w') is not None: + ieee_dict['pv_curve_p_pts'] = vw['curve'].get('w') + + return ieee_dict + + def set_pv(self, params=None): + """ + Set P(V), Voltage-Active Power (Volt-Watt), Parameters + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Active Power Mode Enable pv_mode_enable bool + P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. + P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. + P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. + P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + + :return: dict with keys shown above. + """ + new_params = {'curve': {}} + if params('pv_mode_enable') is not None: + new_params['Ena'] = params('pv_mode_enable') + if params('pv_curve_v_pts') is not None: + new_params['curve']['v'] = params('pv_curve_v_pts') + if params('pv_curve_p_pts') is not None: + new_params['curve']['w'] = params('pv_curve_p_pts') + + return self.sma.volt_watt(new_params) + + def get_es_permit_service(self): + """ + Get Permit Service Mode Parameters - IEEE 1547 Table 39 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Permit service es_permit_service bool (True=Enabled) + ES Voltage Low (RofA not specified in 1547) es_v_low_er_min V p.u. + ES Voltage Low Setting es_v_low V p.u. + ES Voltage Low (RofA not specified in 1547) es_v_low_er_max V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_min V p.u. + ES Voltage High Setting es_v_high V p.u. + ES Voltage High (RofA not specified in 1547) es_v_high_er_max V p.u. + ES Frequency Low (RofA not specified in 1547) es_f_low_er_min Hz + ES Frequency Low Setting es_f_low Hz + ES Frequency Low (RofA not specified in 1547) es_f_low_er_max Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_min Hz + ES Frequency High Setting es_f_high Hz + ES Frequency Low (RofA not specified in 1547) es_f_high_er_max Hz + ES Randomized Delay es_randomized_delay bool (True=Enabled) + ES Delay (RofA not specified in 1547) es_delay_er_min s + ES Delay Setting es_delay s + ES Delay (RofA not specified in 1547) es_delay_er_max s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_min %/s + ES Ramp Rate Setting es_ramp_rate %/s + ES Ramp Rate Min (RofA not specified in 1547) es_ramp_rate_er_max %/s + + :return: dict with keys shown above. + """ + pass + + def set_es_permit_service(self, params=None): + """ + Set Permit Service Mode Parameters + """ + pass + + def get_ui(self): + """ + Get Unintentional Islanding Parameters + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Unintentional Islanding Mode (enabled/disabled). This ui_mode_enable bool + function is enabled by default, and disabled only by + request from the Area EPS Operator. + UI is always on in 1547 BUT 1547.1 says turn it off + for some testing + Unintential Islanding methods supported. Where multiple ui_capability_er list str + modes are supported place in a list. + UI BLRC = Balanced RLC, + UI PCPST = Powerline conducted, + UI PHIT = Permissive Hardware-input, + UI RMIP = Reverse/min relay. Methods other than UI + BRLC may require supplemental comissioning tests. + e.g., ['UI_BLRC', 'UI_PCPST', 'UI_PHIT', 'UI_RMIP'] + + :return: dict with keys shown above. + """ + return self.sma.ui() + + def set_ui(self, params=None): + """ + Set Unintentional Islanding Parameters + """ + return self.ts.prompt('Set UI with params = %s' % params) + + def get_ov(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV Trip Curve Point OV_V1-3 (see Tables 11-13) ov_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV Trip Curve Point OV_V1-3 Setting ov_trip_v_pts V p.u. + HV Trip Curve Point OV_V1-3 (RofA not specified in 1547) ov_trip_v_pts_er_max V p.u. + HV Trip Curve Point OV_T1-3 (see Tables 11-13) ov_trip_t_pts_er_min s + (RofA not specified in 1547) + HV Trip Curve Point OV_T1-3 Setting ov_trip_t_pts s + HV Trip Curve Point OV_T1-3 (RofA not specified in 1547) ov_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + ieee_dict = {} + vw = self.sma.vrt_trip_high() + if vw.get('curve') is not None: + if vw['curve'].get('v') is not None: + ieee_dict['ov_trip_v_pts'] = vw['curve'].get('V') + if vw['curve'].get('t') is not None: + ieee_dict['ov_trip_t_pts'] = vw['curve'].get('t') + + return ieee_dict + + def set_ov(self, params=None): + """ + Set Overvoltage Trip Parameters - IEEE 1547 Table 35 + """ + new_params = {'curve': {}} + if params('ov_trip_v_pts') is not None: + new_params['curve']['V'] = params('ov_trip_v_pts') + if params('ov_trip_t_pts') is not None: + new_params['curve']['t'] = params('ov_trip_t_pts') + + return self.sma.vrt_trip_high(new_params) + + def get_uv(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV Trip Curve Point UV_V1-3 (see Tables 11-13) uv_trip_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV Trip Curve Point UV_V1-3 Setting uv_trip_v_pts V p.u. + LV Trip Curve Point UV_V1-3 (RofA not specified in 1547) uv_trip_v_pts_er_max V p.u. + LV Trip Curve Point UV_T1-3 (see Tables 11-13) uv_trip_t_pts_er_min s + (RofA not specified in 1547) + LV Trip Curve Point UV_T1-3 Setting uv_trip_t_pts s + LV Trip Curve Point UV_T1-3 (RofA not specified in 1547) uv_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + ieee_dict = {} + vw = self.sma.vrt_trip_low() + if vw.get('curve') is not None: + if vw['curve'].get('v') is not None: + ieee_dict['uv_trip_v_pts'] = vw['curve'].get('V') + if vw['curve'].get('t') is not None: + ieee_dict['uv_trip_t_pts'] = vw['curve'].get('t') + + return ieee_dict + + def set_uv(self, params=None): + """ + Set Undervoltage Trip Parameters - IEEE 1547 Table 35 + """ + new_params = {'curve': {}} + if params('uv_trip_v_pts') is not None: + new_params['curve']['V'] = params('uv_trip_v_pts') + if params('uv_trip_t_pts') is not None: + new_params['curve']['t'] = params('uv_trip_t_pts') + + return self.sma.vrt_trip_low(new_params) + + def get_of(self, params=None): + """ + Get Overfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + OF Trip Curve Point OF_F1-3 (see Tables 11-13) of_trip_f_pts_er_min Hz + (RofA not specified in 1547) + OF Trip Curve Point OF_F1-3 Setting of_trip_f_pts Hz + OF Trip Curve Point OF_F1-3 (RofA not specified in 1547) of_trip_f_pts_er_max Hz + OF Trip Curve Point OF_T1-3 (see Tables 11-13) of_trip_t_pts_er_min s + (RofA not specified in 1547) + OF Trip Curve Point OF_T1-3 Setting of_trip_t_pts s + OF Trip Curve Point OF_T1-3 (RofA not specified in 1547) of_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + ieee_dict = {} + vw = self.sma.frt_trip_high() + if vw.get('curve') is not None: + if vw['curve'].get('Hz') is not None: + ieee_dict['of_trip_f_pts'] = vw['curve'].get('Hz') + if vw['curve'].get('t') is not None: + ieee_dict['ov_trip_t_pts'] = vw['curve'].get('t') + + return ieee_dict + + def set_of(self, params=None): + """ + Set Overfrequency Trip Parameters - IEEE 1547 Table 37 + """ + new_params = {'curve': {}} + if params('of_trip_f_pts') is not None: + new_params['curve']['Hz'] = params('of_trip_f_pts') + if params('of_trip_t_pts') is not None: + new_params['curve']['t'] = params('of_trip_t_pts') + + return self.sma.frt_trip_high(new_params) + + def get_uf(self, params=None): + """ + Get Underfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + UF Trip Curve Point UF_F1-3 (see Tables 11-13) uf_trip_f_pts_er_min Hz + (RofA not specified in 1547) + UF Trip Curve Point UF_F1-3 Setting uf_trip_f_pts Hz + UF Trip Curve Point UF_F1-3 (RofA not specified in 1547) uf_trip_f_pts_er_max Hz + UF Trip Curve Point UF_T1-3 (see Tables 11-13) uf_trip_t_pts_er_min s + (RofA not specified in 1547) + UF Trip Curve Point UF_T1-3 Setting uf_trip_t_pts s + UF Trip Curve Point UF_T1-3 (RofA not specified in 1547) uf_trip_t_pts_er_max s + + :return: dict with keys shown above. + """ + ieee_dict = {} + vw = self.sma.frt_trip_low() + if vw.get('curve') is not None: + if vw['curve'].get('Hz') is not None: + ieee_dict['uf_trip_f_pts'] = vw['curve'].get('Hz') + if vw['curve'].get('t') is not None: + ieee_dict['uf_trip_t_pts'] = vw['curve'].get('t') + + return ieee_dict + + def set_uf(self, params=None): + """ + Set Underfrequency Trip Parameters - IEEE 1547 Table 37 + """ + new_params = {'curve': {}} + if params('uf_trip_f_pts') is not None: + new_params['curve']['Hz'] = params('uf_trip_f_pts') + if params('uf_trip_t_pts') is not None: + new_params['curve']['t'] = params('uf_trip_t_pts') + + return self.sma.frt_trip_low(new_params) + + def get_ov_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV MC Curve Point OV_V1-3 (see Tables 11-13) ov_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV MC Curve Point OV_V1-3 Setting ov_mc_v_pts V p.u. + HV MC Curve Point OV_V1-3 (RofA not specified in 1547) ov_mc_v_pts_er_max V p.u. + HV MC Curve Point OV_T1-3 (see Tables 11-13) ov_mc_t_pts_er_min s + (RofA not specified in 1547) + HV MC Curve Point OV_T1-3 Setting ov_mc_t_pts s + HV MC Curve Point OV_T1-3 (RofA not specified in 1547) ov_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_ov_mc(self, params=None): + """ + Set Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + pass + + def get_uv_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV MC Curve Point UV_V1-3 (see Tables 11-13) uv_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV MC Curve Point UV_V1-3 Setting uv_mc_v_pts V p.u. + LV MC Curve Point UV_V1-3 (RofA not specified in 1547) uv_mc_v_pts_er_max V p.u. + LV MC Curve Point UV_T1-3 (see Tables 11-13) uv_mc_t_pts_er_min s + (RofA not specified in 1547) + LV MC Curve Point UV_T1-3 Setting uv_mc_t_pts s + LV MC Curve Point UV_T1-3 (RofA not specified in 1547) uv_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + pass + + def set_uv_mc(self, params=None): + """ + Set Undervoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + pass + + def set_cease_to_energize(self, params=None): + """ + + A DER can be directed to cease to energize and trip by changing the Permit service setting to “disabled” as + described in IEEE 1574 Section 4.10.3. + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Cease to energize and trip cease_to_energize bool (True=Enabled) + + """ + return self.set_es_permit_service(params={'es_permit_service': params['cease_to_energize']}) diff --git a/Lib/svpelab/der1547_sunspec.py b/Lib/svpelab/der1547_sunspec.py new file mode 100644 index 0000000..3b93389 --- /dev/null +++ b/Lib/svpelab/der1547_sunspec.py @@ -0,0 +1,2422 @@ +''' +DER1547 methods defined for SunSpec Modbus devices +''' + +import os +try: + import sunspec2.modbus.client as client + import sunspec2.file.client as file_client +except Exception as e: + print('Missing pysunspec2 package. %s' % e) +from . import der1547 + +sunspec_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'SunSpec' +} + + +def der1547_info(): + return sunspec_info + +MAPPED = 'Mapped SunSpec Device' +RTU = 'Modbus RTU' +TCP = 'Modbus TCP' + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sunspec_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + try: + info.param(pname('ifc_type'), label='Interface Type', default=RTU, + values=[RTU, TCP, MAPPED]) + info.param(pname('slave_id'), label='Slave Id', default=1) + # RTU parameters + info.param(pname('ifc_name'), label='Interface Name', default='COM3', active=pname('ifc_type'), + active_value=[RTU], + desc='Select the communication port from the UMS computer to the EUT.') + info.param(pname('baudrate'), label='Baud Rate', default=9600, values=[9600, 19200], active=pname('ifc_type'), + active_value=[RTU]) + info.param(pname('parity'), label='Parity', default='N', values=['N', 'E'], active=pname('ifc_type'), + active_value=[RTU]) + # TCP parameters + info.param(pname('ipaddr'), label='IP Address', default='127.0.0.1', active=pname('ifc_type'), + active_value=[TCP]) + info.param(pname('ipport'), label='IP Port', default=502, active=pname('ifc_type'), active_value=[TCP]) + info.param(pname('tls'), label='Use TLS', default='No', values=['Yes', 'No'], active=pname('ifc_type'), + active_value=[TCP], desc='Enable TLS (Modbus/TCP Security).') + info.param(pname('cafile'), label='CA Certificate', default='root.pem', + active=pname('tls'), active_value=['Yes'], + desc='Path to certificate authority (CA) certificate to use for validating server certificates.') + info.param(pname('certfile'), label='Client TLS Certificate', default='client-test-cert.pem', + active=pname('tls'), active_value=['Yes'], + desc='Path to client TLS certificate to use for client authentication.') + info.param(pname('keyfile'), label='Client TLS Key', default='client-test-key.pem', + active=pname('tls'), active_value=['Yes'], + desc='Path to client TLS key to use for client authentication.') + info.param(pname('insecure_skip_tls_verify'), label='Skip TLS Verification', default='Yes', + values=['Yes', 'No'], + active=pname('tls'), active_value=['Yes'], desc='Skip Verification of Server TLS Certificate.') + # Mapped parameters + info.param(pname('map_name'), label='Map File', default='device_1547.json', active=pname('ifc_type'), + active_value=[MAPPED]) + except NameError as e: + print('pysunspec2 package is likely missing. %s' % e) + + +GROUP_NAME = 'sunspec' + +class DER1547(der1547.DER1547): + + def __init__(self, ts, group_name): + der1547.DER1547.__init__(self, ts, group_name) + self.inv = None + self.ifc_type = None + self.ts = ts + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.open() + + def open(self): + self.ifc_type = self.param_value('ifc_type') + slave_id = self.param_value('slave_id') + + if self.ifc_type == TCP: + ipaddr = self.param_value('ipaddr') + ipport = self.param_value('ipport') + + tls = self.param_value('tls') == 'Yes' # bool + cafile = self.param_value('cafile') # path + certfile = self.param_value('certfile') # path + keyfile = self.param_value('keyfile') # path + skip_verify = self.param_value('insecure_skip_tls_verify') == 'Yes' # bool + + if tls: + try: # attempt to use pysunspec2 that supports TLS encryption - untested - TODO + self.inv = client.SunSpecModbusClientDeviceTCP(slave_id=slave_id, ipaddr=ipaddr, ipport=ipport, + tls=tls, cafile=cafile, certfile=certfile, + keyfile=keyfile, insecure_skip_tls_verify=skip_verify) + except Exception as e: # fallback to old version + if self.ts is not None: + self.ts.log('Could not create Modbus client with TLS encryption: %s. ' + 'Attempted unencrypted option.') + else: + print('Could not create Modbus client with TLS encryption: %s. Attempted unencrypted option.') + self.inv = client.SunSpecModbusClientDeviceTCP(slave_id=slave_id, ipaddr=ipaddr, ipport=ipport) + else: # don't use TLS + self.inv = client.SunSpecModbusClientDeviceTCP(slave_id=slave_id, ipaddr=ipaddr, ipport=ipport) + + elif self.ifc_type == MAPPED: + ifc_name = self.param_value('map_name') + self.inv = file_client.FileClientDevice(ifc_name) + elif self.ifc_type == RTU: + ifc_name = self.param_value('ifc_name') + baudrate = self.param_value('baudrate') + parity = self.param_value('parity') + self.inv = client.SunSpecModbusClientDeviceRTU(slave_id=slave_id, name=ifc_name, + baudrate=baudrate, parity=parity) + else: + raise der1547.DER1547Error('Unknown connection type. Not MAPPED, RTU, or TCP.') + + self.inv.scan() + + # self.ts.log('device models = %s' % self.inv.models) + + def close(self): + if self.inv is not None and self.ifc_type != MAPPED: + self.inv.close() + self.inv = None + + def info(self): + info = 'SunSpec Device' + if self.inv is not None: + common = self.inv.common[0] + info = 'SunSpec Device, Mn: %s, Md: %s, Opt: %s, Vr: %s, SN: %s' % \ + (common.Mn.cvalue, common.Md.cvalue, common.Opt.cvalue, common.Vr.cvalue, common.SN.cvalue) + return info + + def get_models(self): + """ Get SunSpec Models + + :return: list of models + + """ + if self.inv is None: + raise der1547.DER1547Error('DER not initialized') + + model_dict = self.inv.models + models = [] + for k in model_dict.keys(): + if not isinstance(k, int) and k is not None: + models.append(k) + + return models + + def print_modbus_map(self, models=None, w_labels=None): + """ + Prints the modbus map of the DER device + + :param models: model or models to read, if None read all + :param w_labels: if True, print the modbus points with labels included + :return: None + """ + + model_list = [] + if models is None: + model_list = self.get_models() + elif isinstance(models, str): + model_list = [models] + elif isinstance(models, list): + model_list = models + else: + der1547.DER1547Error('Incorrect model format for printing modbus map.') + + if not w_labels: + for m in model_list: + mod = eval('self.inv.%s[0]' % m) + self.ts.log('%s' % mod) + else: + for m in model_list: + self.ts.log('-'*50) + self.ts.log('Model: %s' % m) + self.ts.log('') + for pt in eval('self.inv.%s[0].points.keys()' % m): + # self.ts.log_debug('pt: %s' % pt) + if pt != 'Pad': + try: + label = eval('self.inv.%s[0].points[pt].pdef["label"]' % m) + val = eval('self.inv.%s[0].points[pt].cvalue' % m) + + if val is not None and \ + eval('self.inv.%s[0].points[pt].pdef.get("symbols")' % m) is not None: + symbol = eval('self.inv.%s[0].points[pt].pdef.get("symbols")' % m) + # self.ts.log('Symbols: %s' % symbol) + symb = None + if symbol is not None: + if isinstance(symbol, list): + for s in symbol: + # self.ts.log('s: %s' % s) + if val == s.get('value'): + symb = s.get("label") + else: + if symbol.get(val) is not None: + symb = eval('self.inv.%s[0].points[pt].pdef["symbols"][val]["label"]' % m) + self.ts.log('%s [%s]: %s [%s]' % (label, pt, val, symb)) + else: + self.ts.log('%s [%s]: %s' % (label, pt, val)) + except Exception as e: + if not pt[-3:] == '_SF': # Ignore the SF values from ac_meter and other legacy models + self.ts.log_debug('No data for pt %s' % pt) + + # Cycle through groups + self.print_group(group_obj=eval('self.inv.%s[0]' % m)) + + def print_group(self, group_obj, tab_level=2): + """ + Print out groups. Method calls itself for groups of groups + + :param group_obj: group object, must be a dict + :param tab_level: print indention + + :return: None + """ + if isinstance(group_obj.groups, dict): + for group in group_obj.groups.keys(): + if isinstance(group_obj.groups[group], list): # list of groups within the group + for i in range(len(group_obj.groups[group])): + self.ts.log('\t' * (tab_level - 1) + '-' * 50) + self.ts.log('\t' * (tab_level-1) + 'Group: %s (#%d)' % (group, i+1)) + + for pt in group_obj.groups[group][i].points.keys(): + # self.ts.log_debug('pt: %s' % pt) + if pt != 'Pad': + try: + label = group_obj.groups[group][i].points[pt].pdef["label"] + except Exception as e: + label = group_obj.groups[group][i].points[pt].pdef["name"] + val = group_obj.groups[group][i].points[pt].cvalue + + # symbol prints + if val is not None and \ + group_obj.groups[group][i].points[pt].pdef.get("symbols") is not None: + symbol = group_obj.groups[group][i].points[pt].pdef.get("symbols") + symb = None + if isinstance(symbol, list): + for s in symbol: + # self.ts.log('s: %s' % s) + if val == s.get('value'): + symb = s.get("label") + else: + if group_obj.symbol.get(val) is not None: + symb = symbol[val]['label'] + self.ts.log('\t' * tab_level + '%s [%s]: %s [%s]' % (label, pt, val, symb)) + else: + self.ts.log('\t' * tab_level + '%s [%s]: %s' % (label, pt, val)) + + # For cases of groups of groups, call this function again + new_obj = group_obj.groups[group][i] + # self.ts.log_debug('New Obj = %s' % new_obj) + self.print_group(group_obj=new_obj, tab_level=tab_level+1) + else: + self.ts.log('\t' * (tab_level - 1) + '-' * 50) + self.ts.log('\t' * (tab_level - 1) + 'Group: %s' % group) + for pt in group_obj.groups[group].points.keys(): + # self.ts.log_debug('pt: %s' % pt) + if pt != 'Pad': + label = group_obj.groups[group].points[pt].pdef["label"] + val = group_obj.groups[group].points[pt].cvalue + + # symbol prints + if val is not None and group_obj.groups[group].points[pt].pdef.get("symbols") is not None: + symbol = group_obj.groups[group].points[pt].pdef.get("symbols") + symb = None + if isinstance(symbol, list): + for s in symbol: + # self.ts.log('s: %s' % s) + if val == s.get('value'): + symb = s.get("label") + else: + if group_obj.symbol.get(val) is not None: + symb = symbol[val]['label'] + self.ts.log('\t' * tab_level + '%s [%s]: %s [%s]' % (label, pt, val, symb)) + else: + self.ts.log('\t' * tab_level + '%s [%s]: %s' % (label, pt, val)) + + # For cases of groups of groups, call this function again + new_obj = group_obj.groups[group] + # self.ts.log_debug('New Obj = %s' % new_obj) + self.print_group(group_obj=new_obj, tab_level=tab_level + 1) + else: + self.ts.log_warning('group_obj was not dict') + + def get_nameplate(self): + """ + Get Nameplate information - See IEEE 1547-2018 Table 28 + ______________________________________________________________________________________________________________ + Parameter params dict key Units + ______________________________________________________________________________________________________________ + Active power rating at unity power factor np_p_max kW + (nameplate active power rating) + Active power rating at specified over-excited np_p_max_over_pf kW + power factor + Specified over-excited power factor np_over_pf Decimal + Active power rating at specified under-excited np_p_max_under_pf kW + power factor + Specified under-excited power factor np_under_pf Decimal + Apparent power maximum rating np_va_max kVA + Normal operating performance category np_normal_op_cat str + e.g., CAT_A-CAT_B + Abnormal operating performance category np_abnormal_op_cat str + e.g., CAT_II-CAT_III + Intentional Island Category (optional) np_intentional_island_cat str + e.g., UNCAT-INT_ISLAND_CAP-BLACK_START-ISOCH + Reactive power injected maximum rating np_q_max_inj kVAr + Reactive power absorbed maximum rating np_q_max_abs kVAr + Active power charge maximum rating np_p_max_charge kW + Apparent power charge maximum rating np_apparent_power_charge_max kVA + AC voltage nominal rating np_ac_v_nom Vac + AC voltage maximum rating np_ac_v_max_er_max Vac + AC voltage minimum rating np_ac_v_min_er_min Vac + Supported control mode functions np_supported_modes dict + e.g., {'fixed_pf': True 'volt_var': False} with keys: + Supports Low Voltage Ride-Through Mode: 'lv_trip' + Supports High Voltage Ride-Through Mode: 'hv_trip' + Supports Low Freq Ride-Through Mode: 'lf_trip' + Supports High Freq Ride-Through Mode: 'hf_trip' + Supports Active Power Limit Mode: 'max_w' + Supports Volt-Watt Mode: 'volt_watt' + Supports Frequency-Watt Curve Mode: 'freq_watt' + Supports Constant VArs Mode: 'fixed_var' + Supports Fixed Power Factor Mode: 'fixed_pf' + Supports Volt-VAr Control Mode: 'volt_var' + Supports Watt-VAr Mode: 'watt_var' + Reactive susceptance that remains connected to np_reactive_susceptance Siemens + the Area EPS in the cease to energize and trip + state + Maximum resistance (R) between RPA and POC. np_remote_meter_resistance Ohms + (unsupported in 1547) + Maximum reactance (X) between RPA and POC. np_remote_meter_reactance Ohms + (unsupported in 1547) + Manufacturer np_manufacturer str + Model np_model str + Serial number np_serial_num str + Version np_fw_ver str + + :return: dict with keys shown above. + """ + + if self.inv is None: + raise der1547.DER1547Error('DER not initialized') + + params = {} + self.inv.DERCapacity[0].read() + der_capacity = self.inv.DERCapacity[0] + # self.ts.log('der_capacity: %s' % der_capacity) + if der_capacity.WMaxRtg.cvalue is not None: + params['np_p_max'] = der_capacity.WMaxRtg.cvalue / 1000. + if der_capacity.WOvrExtRtg.cvalue is not None: + params['np_p_max_over_pf'] = der_capacity.WOvrExtRtg.cvalue / 1000. + if der_capacity.PFOvrExtRtg.cvalue is not None: + params['np_over_pf'] = der_capacity.PFOvrExtRtg.cvalue + + if der_capacity.WUndExtRtg.cvalue is not None: + params['np_p_max_under_pf'] = der_capacity.WUndExtRtg.cvalue / 1000. + if der_capacity.PFUndExtRtg.cvalue is not None: + params['np_under_pf'] = der_capacity.PFUndExtRtg.cvalue + + if der_capacity.VAMaxRtg.cvalue is not None: + params['np_va_max'] = der_capacity.VAMaxRtg.cvalue / 1000. + + if der_capacity.NorOpCatRtg.cvalue is not None: + numeric = der_capacity.NorOpCatRtg.cvalue + # self.ts.log_debug('NorOpCatRtg number: %d, symbols: %s' % + # (numeric, der_capacity.NorOpCatRtg.pdef['symbols'])) + params['np_normal_op_cat'] = der_capacity.NorOpCatRtg.pdef['symbols'][numeric-1]['name'] + if der_capacity.AbnOpCatRtg.cvalue is not None: + numeric = der_capacity.AbnOpCatRtg.cvalue + params['np_abnormal_op_cat'] = der_capacity.AbnOpCatRtg.pdef['symbols'][numeric-1]['name'] + if hasattr(der_capacity, 'IntIslandCatRtg'): + if der_capacity.IntIslandCatRtg.cvalue is not None: + cvalue = der_capacity.IntIslandCatRtg.cvalue + params['np_intentional_island_cat'] = '' + if (cvalue & (1 << 0)) == (1 << 0): + params['np_intentional_island_cat'] += 'UNCATEGORIZED, ' + if (cvalue & (1 << 1)) == (1 << 1): + params['np_intentional_island_cat'] += 'INT_ISL_CAPABLE, ' + if (cvalue & (1 << 2)) == (1 << 2): + params['np_intentional_island_cat'] += 'BLACK_START_CAPABLE, ' + if (cvalue & (1 << 3)) == (1 << 3): + params['np_intentional_island_cat'] += 'ISOCH_CAPABLE, ' + params['np_intentional_island_cat'].rstrip(', ') + + if hasattr(der_capacity, 'IntIslandCat'): + if der_capacity.IntIslandCat.cvalue is not None: + cvalue = der_capacity.IntIslandCat.cvalue + params['np_intentional_island_mode'] = '' + if (cvalue & (1 << 0)) == (1 << 0): + params['np_intentional_island_mode'] += 'UNCATEGORIZED, ' + if (cvalue & (1 << 1)) == (1 << 1): + params['np_intentional_island_mode'] += 'INT_ISL_CAPABLE, ' + if (cvalue & (1 << 2)) == (1 << 2): + params['np_intentional_island_mode'] += 'BLACK_START_CAPABLE, ' + if (cvalue & (1 << 3)) == (1 << 3): + params['np_intentional_island_cat'] += 'ISOCH_CAPABLE, ' + params['np_intentional_island_mode'].rstrip(', ') + + if der_capacity.VarMaxInjRtg.cvalue is not None: + params['np_q_max_inj'] = der_capacity.VarMaxInjRtg.cvalue / 1000. + if der_capacity.VarMaxAbsRtg.cvalue is not None: + params['np_q_max_abs'] = der_capacity.VarMaxAbsRtg.cvalue / 1000. + if der_capacity.WChaRteMaxRtg.cvalue is not None: + params['np_p_max_charge'] = der_capacity.WChaRteMaxRtg.cvalue / 1000. + if der_capacity.WDisChaRteMaxRtg.cvalue is not None: # not in 1547 + params['np_p_max_discharge'] = der_capacity.WDisChaRteMaxRtg.cvalue / 1000. + + if der_capacity.VAChaRteMaxRtg.cvalue is not None: + params['np_apparent_power_charge_max'] = der_capacity.VAChaRteMaxRtg.cvalue / 1000. + if der_capacity.VADisChaRteMaxRtg.cvalue is not None: # not in 1547 + params['np_apparent_power_discharge_max'] = der_capacity.VADisChaRteMaxRtg.cvalue / 1000. + + if der_capacity.VNomRtg.cvalue is not None: + params['np_ac_v_nom'] = der_capacity.VNomRtg.cvalue + if der_capacity.VMaxRtg.cvalue is not None: + params['np_ac_v_max_er_max'] = der_capacity.VMaxRtg.cvalue + if der_capacity.VMinRtg.cvalue is not None: + params['np_ac_v_min_er_min'] = der_capacity.VMinRtg.cvalue + + if hasattr(der_capacity, 'CtrlModes'): + if der_capacity.CtrlModes.cvalue is not None: + cvalue = der_capacity.CtrlModes.cvalue + params['np_supported_modes'] = {} + params['np_supported_modes']['max_w'] = (cvalue & (1 << 0)) == (1 << 0) + params['np_supported_modes']['fixed_w'] = (cvalue & (1 << 1)) == (1 << 1) + params['np_supported_modes']['fixed_var'] = (cvalue & (1 << 2)) == (1 << 2) + params['np_supported_modes']['fixed_pf'] = (cvalue & (1 << 3)) == (1 << 3) + params['np_supported_modes']['volt_var'] = (cvalue & (1 << 4)) == (1 << 4) + params['np_supported_modes']['freq_watt'] = (cvalue & (1 << 5)) == (1 << 5) + params['np_supported_modes']['dyn_react_curr'] = (cvalue & (1 << 6)) == (1 << 6) + params['np_supported_modes']['lv_trip'] = (cvalue & (1 << 7)) == (1 << 7) + params['np_supported_modes']['hv_trip'] = (cvalue & (1 << 8)) == (1 << 8) + params['np_supported_modes']['watt_var'] = (cvalue & (1 << 9)) == (1 << 9) + params['np_supported_modes']['volt_watt'] = (cvalue & (1 << 10)) == (1 << 10) + params['np_supported_modes']['scheduled'] = (cvalue & (1 << 11)) == (1 << 11) + params['np_supported_modes']['lf_trip'] = (cvalue & (1 << 12)) == (1 << 12) + params['np_supported_modes']['hf_trip'] = (cvalue & (1 << 13)) == (1 << 13) + + if der_capacity.ReactSusceptRtg.cvalue is not None: + params['np_reactive_susceptance'] = der_capacity.ReactSusceptRtg.cvalue + + params['np_remote_meter_resistance'] = None + params['np_remote_meter_reactance'] = None + + self.inv.common[0].read() + common = self.inv.common[0] + if common.Mn.cvalue is not None: + params['np_manufacturer'] = common.Mn.cvalue + if common.Md.cvalue is not None: + params['np_model'] = common.Md.cvalue + if common.SN.cvalue is not None: + params['np_serial_num'] = common.SN.cvalue + if common.Vr.cvalue is not None: + params['np_fw_ver'] = common.Vr.cvalue + + return params + + def get_configuration(self): + """ + Get configuration information in the 1547 DER. Each rating in Table 28 may have an associated configuration + setting that represents the as-configured value. If a configuration setting value is different from the + corresponding nameplate value, the configuration setting value shall be used as the rating within the DER. + + :return: params dict with keys shown in nameplate. + """ + + if self.inv is None: + raise der1547.DER1547Error('DER not initialized') + + # self.ts.log(self.get_models()) + + params = {} + self.inv.DERCapacity[0].read() + der_capacity = self.inv.DERCapacity[0] + # self.ts.log('der_capacity: %s' % der_capacity) + if der_capacity.WMax.cvalue is not None: + params['np_p_max'] = der_capacity.WMax.cvalue / 1000. + if der_capacity.WMaxOvrExt.cvalue is not None: + params['np_p_max_over_pf'] = der_capacity.WMaxOvrExt.cvalue / 1000. + # if der_capacity.PFOvrExt.cvalue is not None: + # params['np_over_pf'] = der_capacity.PFOvrExt.cvalue + + if der_capacity.WMaxUndExt.cvalue is not None: + params['np_p_max_under_pf'] = der_capacity.WMaxUndExt.cvalue / 1000. + # if der_capacity.PFUndExt.cvalue is not None: + # params['np_under_pf'] = der_capacity.PFUndExt.cvalue + + if der_capacity.VAMax.cvalue is not None: + params['np_va_max'] = der_capacity.VAMax.cvalue / 1000. + + if der_capacity.VarMaxInj.cvalue is not None: + params['np_q_max_inj'] = der_capacity.VarMaxInj.cvalue / 1000. + if der_capacity.VarMaxAbs.cvalue is not None: + params['np_q_max_abs'] = der_capacity.VarMaxAbs.cvalue / 1000. + if der_capacity.WChaRteMax.cvalue is not None: + params['np_p_max_charge'] = der_capacity.WChaRteMax.cvalue / 1000. + if der_capacity.WDisChaRteMax.cvalue is not None: # not in 1547 + params['np_p_max_discharge'] = der_capacity.WDisChaRteMax.cvalue / 1000. + + if der_capacity.VAChaRteMax.cvalue is not None: + params['np_apparent_power_charge_max'] = der_capacity.VAChaRteMax.cvalue / 1000. + if der_capacity.VADisChaRteMax.cvalue is not None: # not in 1547 + params['np_apparent_power_discharge_max'] = der_capacity.VADisChaRteMax.cvalue / 1000. + + if der_capacity.VNom.cvalue is not None: + params['np_ac_v_nom'] = der_capacity.VNom.cvalue + if der_capacity.VMax.cvalue is not None: + params['np_ac_v_max_er_max'] = der_capacity.VMax.cvalue + if der_capacity.VMin.cvalue is not None: + params['np_ac_v_min_er_min'] = der_capacity.VMin.cvalue + + if der_capacity.CtrlModes.cvalue is not None: + cvalue = der_capacity.CtrlModes.cvalue + params['np_supported_modes'] = {} + params['np_supported_modes']['max_w'] = (cvalue & (1 << 0)) == (1 << 0) + params['np_supported_modes']['fixed_w'] = (cvalue & (1 << 1)) == (1 << 1) + params['np_supported_modes']['fixed_var'] = (cvalue & (1 << 2)) == (1 << 2) + params['np_supported_modes']['fixed_pf'] = (cvalue & (1 << 3)) == (1 << 3) + params['np_supported_modes']['volt_var'] = (cvalue & (1 << 4)) == (1 << 4) + params['np_supported_modes']['freq_watt'] = (cvalue & (1 << 5)) == (1 << 5) + params['np_supported_modes']['dyn_react_curr'] = (cvalue & (1 << 6)) == (1 << 6) + params['np_supported_modes']['lv_trip'] = (cvalue & (1 << 7)) == (1 << 7) + params['np_supported_modes']['hv_trip'] = (cvalue & (1 << 8)) == (1 << 8) + params['np_supported_modes']['watt_var'] = (cvalue & (1 << 9)) == (1 << 9) + params['np_supported_modes']['volt_watt'] = (cvalue & (1 << 10)) == (1 << 10) + params['np_supported_modes']['scheduled'] = (cvalue & (1 << 11)) == (1 << 11) + params['np_supported_modes']['lf_trip'] = (cvalue & (1 << 12)) == (1 << 12) + params['np_supported_modes']['hf_trip'] = (cvalue & (1 << 13)) == (1 << 13) + if der_capacity.ReactSusceptRtg.cvalue is not None: + params['np_reactive_susceptance'] = der_capacity.ReactSusceptRtg.cvalue + + params['np_remote_meter_resistance'] = None + params['np_remote_meter_reactance'] = None + + self.inv.common[0].read() + common = self.inv.common[0] + if common.Mn.cvalue is not None: + params['np_manufacturer'] = common.Mn.cvalue + if common.Md.cvalue is not None: + params['np_model'] = common.Md.cvalue + if common.SN.cvalue is not None: + params['np_serial_num'] = common.SN.cvalue + if common.Vr.cvalue is not None: + params['np_fw_ver'] = common.Vr.cvalue + + return params + + def set_configuration(self, params=None): + """ + Set configuration information. params are those in get_configuration(). + """ + + der_capacity = self.inv.DERCapacity[0] + der_capacity.read() + + if params.get('np_p_max') is not None: + der_capacity.WMax.cvalue = params.get('np_p_max') * 1000. + if params.get('np_p_max_over_pf') is not None: + der_capacity.WMaxOvrExt.cvalue = params.get('np_p_max_over_pf') * 1000. + if params.get('np_p_max_under_pf') is not None: + der_capacity.WMaxUndExt.cvalue = params.get('np_p_max_under_pf') * 1000. + + if params.get('np_va_max') is not None: + der_capacity.VAMax.cvalue = params.get('np_va_max') * 1000. + + if params.get('np_q_max_inj') is not None: + der_capacity.VarMaxInj.cvalue = params.get('np_q_max_inj') * 1000. + if params.get('np_q_max_abs') is not None: + der_capacity.VarMaxAbs.cvalue = params.get('np_q_max_abs') * 1000. + if params.get('np_p_max_charge') is not None: + der_capacity.WChaRteMax.cvalue = params.get('np_p_max_charge') * 1000. + if params.get('np_p_max_discharge') is not None: + der_capacity.WDisChaRteMax.cvalue = params.get('np_p_max_discharge') * 1000. + + if params.get('np_apparent_power_charge_max') is not None: + der_capacity.VAChaRteMax.cvalue = params.get('np_apparent_power_charge_max') * 1000. + if params.get('np_apparent_power_discharge_max') is not None: + der_capacity.VADisChaRteMax.cvalue = params.get('np_apparent_power_discharge_max') * 1000. + + if params.get('np_ac_v_nom') is not None: + der_capacity.Vnom.cvalue = params.get('np_ac_v_nom') + if params.get('np_ac_v_max_er_max') is not None: + der_capacity.VMax.cvalue = params.get('np_ac_v_max_er_max') + if params.get('np_ac_v_min_er_min') is not None: + der_capacity.VMin.cvalue = params.get('np_ac_v_min_er_min') + + if params.get('np_supported_modes') is not None: + if isinstance(params.get('np_supported_modes'), int): + der_capacity.CtrlModes.cvalue = params.get('np_supported_modes') + else: # assume dict + ctrl_decimal = 0 + if params['np_supported_modes']['max_w']: + ctrl_decimal += 1 << 0 + if params['np_supported_modes']['fixed_w']: + ctrl_decimal += 1 << 1 + if params['np_supported_modes']['fixed_var']: + ctrl_decimal += 1 << 2 + if params['np_supported_modes']['fixed_pf']: + ctrl_decimal += 1 << 3 + if params['np_supported_modes']['volt_var']: + ctrl_decimal += 1 << 4 + if params['np_supported_modes']['freq_watt']: + ctrl_decimal += 1 << 5 + if params['np_supported_modes']['dyn_react_curr']: + ctrl_decimal += 1 << 6 + if params['np_supported_modes']['lv_trip']: + ctrl_decimal += 1 << 7 + if params['np_supported_modes']['hv_trip']: + ctrl_decimal += 1 << 8 + if params['np_supported_modes']['watt_var']: + ctrl_decimal += 1 << 9 + if params['np_supported_modes']['volt_watt']: + ctrl_decimal += 1 << 10 + if params['np_supported_modes']['scheduled']: + ctrl_decimal += 1 << 11 + if params['np_supported_modes']['lf_trip']: + ctrl_decimal += 1 << 12 + if params['np_supported_modes']['hf_trip']: + ctrl_decimal += 1 << 13 + der_capacity.CtrlModes.cvalue = ctrl_decimal + + if params.get('mn_alrm') is not None: + if isinstance(params.get('mn_alrm'), int): + der_capacity.Alrm.cvalue = params.get('mn_alrm') + else: # assume dict + alrm_decimal = 0 + if params['mn_alrm']['mn_alm_ground_fault']: + alrm_decimal += 1 << 0 + if params['mn_alrm']['mn_alm_over_dc_volt']: + alrm_decimal += 1 << 1 + if params['mn_alrm']['mn_alm_disconn_open']: + alrm_decimal += 1 << 2 + if params['mn_alrm']['mn_alm_dc_disconn_open']: + alrm_decimal += 1 << 3 + if params['mn_alrm']['mn_alm_grid_disconn']: + alrm_decimal += 1 << 4 + if params['mn_alrm']['mn_alm_cabinet_open']: + alrm_decimal += 1 << 5 + if params['mn_alrm']['mn_alm_manual_shutdown']: + alrm_decimal += 1 << 6 + if params['mn_alrm']['mn_alm_over_temp']: + alrm_decimal += 1 << 7 + if params['mn_alrm']['mn_alm_over_freq']: + alrm_decimal += 1 << 8 + if params['mn_alrm']['mn_alm_under_freq']: + alrm_decimal += 1 << 9 + if params['mn_alrm']['mn_alm_over_volt']: + alrm_decimal += 1 << 10 + if params['mn_alrm']['mn_alm_under_volt']: + alrm_decimal += 1 << 11 + if params['mn_alrm']['mn_alm_fuse']: + alrm_decimal += 1 << 12 + if params['mn_alrm']['mn_alm_under_temp']: + alrm_decimal += 1 << 13 + if params['mn_alrm']['mn_alm_mem_or_comm']: + alrm_decimal += 1 << 14 + der_capacity.Alrm.cvalue = alrm_decimal + + if params.get('np_reactive_susceptance') is not None: + der_capacity.ReactSusceptRtg.cvalue = params.get('np_reactive_susceptance') + der_capacity.write() + + common = self.inv.common[0] + common.read() + if params.get('np_manufacturer') is not None: + common.Mn.cvalue = params.get('np_manufacturer') + if params.get('np_model') is not None: + common.Md.cvalue = params.get('np_model') + if params.get('np_serial_num') is not None: + common.SN.cvalue = params.get('np_serial_num') + if params.get('np_fw_ver') is not None: + common.Vr.cvalue = params.get('np_fw_ver') + common.write() + + def get_settings(self): + """ + Get configuration information in the 1547 DER. Each rating in Table 28 may have an associated configuration + setting that represents the as-configured value. If a configuration setting value is different from the + corresponding nameplate value, the configuration setting value shall be used as the rating within the DER. + + :return: params dict with keys shown in nameplate. + """ + return self.get_configuration() + + def set_settings(self, params=None): + """ + Set configuration information. params are those in get_configuration(). + """ + return self.set_configuration(params) + + def get_monitoring(self): + """ + This information is indicative of the present operating conditions of the + DER. This information may be read. + + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Active Power mn_w kW + Reactive Power mn_var kVAr + Voltage (list) mn_v V-N list + Single phase devices: [V] + 3-phase devices: [V1, V2, V3] + Frequency mn_hz Hz + + + Operational State mn_st bool + 'On': True, DER operating (e.g., generating) + 'Off': False, DER not operating + + Connection State mn_conn bool + 'Connected': True, DER connected + 'Disconnected': False, DER not connected + + DER State (not in IEEE 1547.1) mn_der_st dict of bools + 'mn_der_st_off': OFF # SunSpec Points + 'mn_der_st_sleeping': SLEEPING + 'mn_der_st_mppt': MPPT + 'mn_der_st_throttled': THROTTLED (curtailed), forced power reduction/derating + 'mn_der_st_shutting_down': SHUTTING_DOWN + 'mn_der_st_fault': FAULT + 'mn_der_st_standby': STANDBY + + Alarm Status mn_alrm dict of bools + Reported Alarm Status matches the device + present alarm condition for alarm and no + alarm conditions. For test purposes only, the + DER manufacturer shall specify at least one + way an alarm condition that is supported in + the protocol being tested can be set and + cleared. + 'mn_alm_ground_fault': Ground Fault # Start of SunSpec Errors + 'mn_alm_over_dc_volt': DC Over Voltage + 'mn_alm_disconn_open': Disconnect Open + 'mn_alm_dc_disconn_open': DC Disconnect Open + 'mn_alm_grid_disconn': Grid Disconnect + 'mn_alm_cabinet_open': Cabinet Open + 'mn_alm_manual_shutdown': Manual Shutdown + 'mn_alm_over_temp': Over Temperature + 'mn_alm_over_freq': Frequency Above Limit + 'mn_alm_under_freq': Frequency Under Limit + 'mn_alm_over_volt': AC Voltage Above Limit + 'mn_alm_under_volt': AC Voltage Under Limit + 'mn_alm_fuse': Blown String Fuse On Input + 'mn_alm_under_temp': Under Temperature + 'mn_alm_mem_or_comm': Generic Memory Or Communication Error (Internal) + 'mn_alm_hdwr_fail': Hardware Test Failure + 'mn_alm_mfr_alrm': Manufacturer Alarm + + Operational State of Charge (not required in 1547) mn_soc_pct pct + + :return: dict with keys shown above. + """ + + if self.inv is None: + raise der1547.DER1547Error('DER not initialized') + + params = {} + self.inv.DERMeasureAC[0].read() + der_pts = self.inv.DERMeasureAC[0] + + if der_pts.W.cvalue is not None: + params['mn_w'] = der_pts.W.cvalue / 1000. + if der_pts.Var.cvalue is not None: + params['mn_var'] = der_pts.Var.cvalue / 1000. + + if der_pts.ACType.cvalue is not None: + numeric = der_pts.ACType.cvalue + params['np_ac_type'] = der_pts.ACType.pdef['symbols'][numeric-1]['name'] + if params['np_ac_type'] == 'SINGLE_PHASE': + phases = 1 + elif params['np_ac_type'] == 'SPLIT_PHASE': + phases = 2 + else: # THREE_PHASE + phases = 3 + else: + phases = 1 # assume single phase DER + + if phases == 1: + params['mn_v'] = [der_pts.LNV.cvalue] + elif phases == 2: + params['mn_v'] = [] + params['mn_v'].append(der_pts.VL1.cvalue) + params['mn_v'].append(der_pts.VL2.cvalue) + else: # phases == 3 + params['mn_v'] = [] + params['mn_v'].append(der_pts.VL1.cvalue) + params['mn_v'].append(der_pts.VL2.cvalue) + params['mn_v'].append(der_pts.VL3.cvalue) + + if der_pts.Hz.cvalue is not None: + params['mn_hz'] = der_pts.Hz.cvalue + + # need to convert values to dict up with example + if der_pts.St.cvalue is not None: + params['mn_st'] = der_pts.St.cvalue == 2 + # self.ts.log_debug('DER State: %s ' % der_pts.St.pdef['symbols'][params['mn_st']]['label']) + + if der_pts.ConnSt.cvalue is not None: + params['mn_conn'] = der_pts.ConnSt.cvalue == 2 + # self.ts.log_debug('DER Conn State: %s ' % der_pts.ConnSt.pdef['symbols'][params['mn_conn']]['label']) + + if der_pts.Alrm.cvalue is not None: + cvalue = int(der_pts.Alrm.cvalue) # bitfield + params['mn_alrm'] = {} + params['mn_alrm']['mn_alm_ground_fault'] = (cvalue & (1 << 0)) == (1 << 0) + params['mn_alrm']['mn_alm_over_dc_volt'] = (cvalue & (1 << 1)) == (1 << 1) + params['mn_alrm']['mn_alm_disconn_open'] = (cvalue & (1 << 2)) == (1 << 2) + params['mn_alrm']['mn_alm_dc_disconn_open'] = (cvalue & (1 << 3)) == (1 << 3) + params['mn_alrm']['mn_alm_grid_disconn'] = (cvalue & (1 << 4)) == (1 << 4) + params['mn_alrm']['mn_alm_cabinet_open'] = (cvalue & (1 << 5)) == (1 << 5) + params['mn_alrm']['mn_alm_manual_shutdown'] = (cvalue & (1 << 6)) == (1 << 6) + params['mn_alrm']['mn_alm_over_temp'] = (cvalue & (1 << 7)) == (1 << 7) + params['mn_alrm']['mn_alm_over_freq'] = (cvalue & (1 << 8)) == (1 << 8) + params['mn_alrm']['mn_alm_under_freq'] = (cvalue & (1 << 9)) == (1 << 9) + params['mn_alrm']['mn_alm_over_volt'] = (cvalue & (1 << 10)) == (1 << 10) + params['mn_alrm']['mn_alm_under_volt'] = (cvalue & (1 << 11)) == (1 << 11) + params['mn_alrm']['mn_alm_fuse'] = (cvalue & (1 << 12)) == (1 << 12) + params['mn_alrm']['mn_alm_under_temp'] = (cvalue & (1 << 13)) == (1 << 13) + params['mn_alrm']['mn_alm_mem_or_comm'] = (cvalue & (1 << 14)) == (1 << 14) + params['mn_alrm']['mn_alm_hdwr_fail'] = (cvalue & (1 << 15)) == (1 << 15) + params['mn_alrm']['mn_alm_mfr_alrm'] = (cvalue & (1 << 16)) == (1 << 16) + + # self.ts.log_debug('DER Alarm: %s ' % params['mn_der_st']) + # self.ts.log_debug('DER Mfr Alarm: %s ' % der_pts.MnAlrmInfo.cvalue) + + if hasattr(der_pts, 'InvSt'): + if der_pts.InvSt.cvalue is not None: + cvalue = der_pts.InvSt.cvalue + params['mn_der_st'] = {'mn_der_st_off': False, # SunSpec Points + 'mn_der_st_sleeping': False, + 'mn_der_st_mppt': False, + 'mn_der_st_throttled': False, + 'mn_der_st_shutting_down': False, + 'mn_der_st_fault': False, + 'mn_der_st_standby': False} + if cvalue == 1: + params['mn_der_st']['mn_der_st_off'] = True + elif cvalue == 2: + params['mn_der_st']['mn_der_st_sleeping'] = True + elif cvalue == 3: + params['mn_der_st']['mn_der_st_mppt'] = True + elif cvalue == 4: + params['mn_der_st']['mn_der_st_throttled'] = True + elif cvalue == 5: + params['mn_der_st']['mn_der_st_shutting_down'] = True + elif cvalue == 6: + params['mn_der_st']['mn_der_st_fault'] = True + elif cvalue == 7: + params['mn_der_st']['mn_der_st_standby'] = True + + # self.ts.log_debug('DER State: %s ' % params['mn_der_st']) + + if hasattr(der_pts, 'DERMode'): + if der_pts.DERMode.cvalue is not None: + cvalue = der_pts.InvSt.cvalue + params['mn_op_mode'] = {} + params['mn_op_mode']['mn_op_mode_grid_following'] = (cvalue & (1 << 0)) == (1 << 0) + params['mn_op_mode']['mn_op_mode_grid_forming'] = (cvalue & (1 << 1)) == (1 << 1) + params['mn_op_mode']['mn_op_mode_curtailed'] = (cvalue & (1 << 2)) == (1 << 2) + + if hasattr(der_pts, 'ThrotPct'): + if der_pts.ThrotPct.cvalue is not None: + params['mn_curtailed_pct'] = der_pts.ThrotPct.cvalue + + if hasattr(der_pts, 'ThrotSrc'): + if der_pts.ThrotSrc.cvalue is not None: + cvalue = der_pts.InvSt.cvalue + params['mn_curtailed_mode'] = {} + params['mn_curtailed_mode']['max_w'] = (cvalue & (1 << 0)) == (1 << 0) + params['mn_curtailed_mode']['fixed_w'] = (cvalue & (1 << 1)) == (1 << 1) + params['mn_curtailed_mode']['fixed_var'] = (cvalue & (1 << 2)) == (1 << 2) + params['mn_curtailed_mode']['fixed_pf'] = (cvalue & (1 << 3)) == (1 << 3) + params['mn_curtailed_mode']['volt_var'] = (cvalue & (1 << 4)) == (1 << 4) + params['mn_curtailed_mode']['freq_watt'] = (cvalue & (1 << 5)) == (1 << 5) + params['mn_curtailed_mode']['dyn_react_curr'] = (cvalue & (1 << 6)) == (1 << 6) + params['mn_curtailed_mode']['lvrt'] = (cvalue & (1 << 7)) == (1 << 7) + params['mn_curtailed_mode']['hvrt'] = (cvalue & (1 << 8)) == (1 << 8) + params['mn_curtailed_mode']['watt_var'] = (cvalue & (1 << 9)) == (1 << 9) + params['mn_curtailed_mode']['volt_watt'] = (cvalue & (1 << 10)) == (1 << 10) + params['mn_curtailed_mode']['scheduled'] = (cvalue & (1 << 11)) == (1 << 11) + params['mn_curtailed_mode']['lfrt'] = (cvalue & (1 << 12)) == (1 << 12) + params['mn_curtailed_mode']['hfrt'] = (cvalue & (1 << 13)) == (1 << 13) + params['mn_curtailed_mode']['derated'] = (cvalue & (1 << 14)) == (1 << 14) + + return params + + def get_const_pf(self): + """ + Get Constant Power Factor Mode control settings. IEEE 1547-2018 Table 30. + ________________________________________________________________________________________________________________ + Parameter params dict key units + ________________________________________________________________________________________________________________ + Constant Power Factor Mode Select const_pf_mode_enable bool (True=Enabled) + Constant Power Factor Excitation const_pf_excitation str ('inj', 'abs') + Constant Power Factor Excitation Inj W const_pf_excitation_charging str ('inj', 'abs') + Constant Power Factor Absorbing W Setting const_pf_abs decimal + Constant Power Factor Injecting W Setting const_pf_inj decimal + + SunSpec Points: + Power Factor Enable (W Inj) Enable [PFWInjEna]: 0 + Power Factor Reversion Enable (W Inj) [PFWInjRvrtEna]: None + PF Reversion Time (W Inj) [PFWInjRvrtTms]: None + PF Reversion Time Rem (W Inj) [PFWInjRvrtRem]: None + + (Only for DER with storage and absorbing power) + Power Factor Enable (W Abs) Enable [PFWAbsEna]: 0 + Power Factor Reversion Enable (W Abs) [PFWAbsRvrtEna]: None + PF Reversion Time (W Abs) [PFWAbsRvrtTms]: None + PF Reversion Time Rem (W Abs) [PFWAbsRvrtRem]: None + + -------------------------------------------------- + Group: PFWInj + Power Factor (W Inj) [PF]: 0.950 + Power Factor Excitation (W Inj) [Ext]: 1 + -------------------------------------------------- + Group: PFWInjRvrt + Reversion Power Factor (W Inj) [PF]: None + Reversion PF Excitation (W Inj) [Ext]: None + -------------------------------------------------- + Group: PFWAbs + Power Factor (W Abs) [PF]: None + Power Factor Excitation (W Abs) [Ext]: None + -------------------------------------------------- + Group: PFWAbsRvrt + Reversion Power Factor (W Abs) [PF]: None + Reversion PF Excitation (W Abs) [Ext]: None + + :return: dict with keys shown above. + """ + + params = {} + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if der_pts.PFWInjEna.cvalue is not None: + if der_pts.PFWInjEna.cvalue == 1: + params['const_pf_mode_enable'] = True + else: + params['const_pf_mode_enable'] = False + + if der_pts.PFWInj.PF.cvalue is not None: + params['const_pf_inj'] = der_pts.PFWInj.PF.cvalue + if der_pts.PFWInj.Ext.cvalue is not None: + if der_pts.PFWInj.Ext.cvalue == 0: + params['const_pf_excitation'] = 'inj' # Over-excited + else: + params['const_pf_excitation'] = 'abs' + + if der_pts.PFWAbs.PF.cvalue is not None: + params['const_pf_abs'] = der_pts.PFWAbs.PF.cvalue + if der_pts.PFWAbs.Ext.cvalue is not None: + if der_pts.PFWAbs.Ext.cvalue == 0: + params['const_pf_excitation_charging'] = 'inj' # Over-excited + else: + params['const_pf_excitation_charging'] = 'abs' + + return params + + def set_const_pf(self, params=None): + """ + Set Constant Power Factor Mode control settings. + """ + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if params.get('const_pf_mode_enable') is not None: + if params.get('const_pf_mode_enable') is True: + der_pts.PFWInjEna.cvalue = 1 + else: + der_pts.PFWInjEna.cvalue = 0 + + if params.get('const_pf_inj') is not None: + der_pts.PFWInj.PF.cvalue = abs(params.get('const_pf_inj')) # uint16 + if params.get('const_pf_excitation') is not None: # assume PF and excit always written together + if params.get('const_pf_excitation') == 'inj': + der_pts.PFWInj.Ext.cvalue = 0 # Over-excited + else: + der_pts.PFWInj.Ext.cvalue = 1 # Under-excited + + if params.get('const_pf_abs') is not None: + der_pts.PFWAbs.PF.cvalue = params.get('const_pf_abs') + if params.get('const_pf_excitation_charging') is not None: # assume PF and excit always written together + if params.get('const_pf_excitation_charging') == 'inj': + der_pts.PFWAbs.Ext.cvalue = 0 # Over-excited + else: + der_pts.PFWAbs.Ext.cvalue = 1 # Under-excited + der_pts.write() + + return params + + def get_qv(self, group=1): + """ + :param group: the numerical value of the volt-var curve. The python index is one less. + + Get Q(V) parameters. [Volt-Var] + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Reactive Power Mode Enable qv_mode_enable bool (True=Enabled) + Vref (0.95-1.05) qv_vref V p.u. + Autonomous Vref Adjustment Enable qv_vref_auto_mode bool (True=Enabled) + Vref adjustment time Constant (300-5000) qv_vref_olrt s + Q(V) Curve Point V1-4 (list, e.g., [95, 99, 101, 105]) qv_curve_v_pts V p.u. + Q(V) Curve Point Q1-4 (list) qv_curve_q_pts VAr p.u. + Q(V) Open Loop Response Time Setting (1-90) qv_olrt s + + :return: dict with keys shown above. + + SunSpec Points + Model ID [ID]: 705 + Model Length [L]: 64 + Module Enable [Ena]: 1 [Enabled] + Adopt Curve Request [AdptCrvReq]: 0 + Adopt Curve Result [AdptCrvRslt]: 0 [Update In Progress] + Number Of Points [NPt]: 4 + Stored Curve Count [NCrv]: 3 + Reversion Timeout [RvrtTms]: 0 + Reversion Time Remaining [RvrtRem]: 0 + Reversion Curve [RvrtCrv]: 0 + Voltage Scale Factor [V_SF]: -2 + Var Scale Factor [DeptRef_SF]: -2 + -------------------------------------------------- + Group: Crv (#1) + Active Points [ActPt]: 4 + Dependent Reference [DeptRef]: 1 [Percent Max Watts] + Pri [Pri]: 1 [Active Power Priority] + Vref Adjustment [VRef]: 1 + Current Autonomous Vref [VRefAuto]: 0 + Autonomous Vref Enable [VRefAutoEna]: None + Auto Vref Time Constant [VRefTms]: 5 + Open Loop Response Time [RspTms]: 6 + Curve Access [ReadOnly]: 1 [Read-Only Access] + -------------------------------------------------- + Group: Pt (#1) + Voltage Point [V]: 92.0 + Reactive Power Point [Var]: 30.0 + -------------------------------------------------- + Group: Pt (#2) + Voltage Point [V]: 96.7 + Reactive Power Point [Var]: 0.0 + -------------------------------------------------- + Group: Pt (#3) + Voltage Point [V]: 103.0 + Reactive Power Point [Var]: 0.0 + -------------------------------------------------- + Group: Pt (#4) + Voltage Point [V]: 107.0 + Reactive Power Point [Var]: -30.0 + """ + params = {} + der_pts = self.inv.DERVoltVar[0] + der_pts.read() + group -= 1 # convert to the python index + + if der_pts.Ena.cvalue is not None: + if der_pts.Ena.cvalue == 1: + params['qv_mode_enable'] = True + else: + params['qv_mode_enable'] = False + + if der_pts.NPt.cvalue is not None: + params['qv_n_points'] = der_pts.NPt.cvalue + if der_pts.NCrv.cvalue is not None: + params['qv_n_curves'] = der_pts.NCrv.cvalue + # Stored Curve Sets - Number of curve sets contained in NCrv + # The first set is read-only and indicates the current settings. + + # curve points + if der_pts.Crv[group].VRefAutoEna.cvalue is not None: + if der_pts.Crv[group].VRefAutoEna.cvalue == 1: + params['qv_vref_auto_mode'] = True + else: + params['qv_vref_auto_mode'] = False + if der_pts.Crv[group].VRefAutoTms.cvalue is not None: + params['qv_vref_olrt'] = der_pts.Crv[group].VRefAutoTms.cvalue + + if der_pts.Crv[group].ActPt.cvalue is not None: + params['qv_curve_n_active_pts'] = der_pts.Crv[group].ActPt.cvalue + else: + params['qv_curve_n_active_pts'] = 4 + + params['qv_curve_v_pts'] = [] + params['qv_curve_q_pts'] = [] + for i in range(params['qv_curve_n_active_pts']): + params['qv_curve_v_pts'].append(der_pts.Crv[group].Pt[i].V.cvalue / 100.) # pu + params['qv_curve_q_pts'].append(der_pts.Crv[group].Pt[i].Var.cvalue / 100.) + + if der_pts.Crv[group].RspTms.cvalue is not None: + params['qv_olrt'] = der_pts.Crv[group].RspTms.cvalue + + if der_pts.AdptCrvRslt.cvalue is not None: + write_result = der_pts.AdptCrvRslt.cvalue + params['qv_write_result'] = der_pts.AdptCrvRslt.pdef['symbols'][write_result]['name'] + else: + params['qv_write_result'] = 'UNKNOWN' + + return params + + def set_qv(self, params=None, group=2): + """ + Set Q(V) parameters. [Volt-Var] + + VV Curve 1 is read only and represents the current operating state of the DER. + We will write to curve 2 by default and then enable it. + """ + der_pts = self.inv.DERVoltVar[0] + der_pts.read() + group -= 1 # convert to python index + + # work with the read only points in curve 0 if it is a simulated DER + if self.ifc_type == MAPPED: + group = 0 # + + if params.get('qv_mode_enable') is not None: + if params.get('qv_mode_enable') is True: + der_pts.Ena.cvalue = 1 + else: + der_pts.Ena.cvalue = 0 + + if params.get('qv_vref_auto_mode') is not None: + if params['qv_vref_auto_mode']: + der_pts.Crv[group].VRefAutoEna.cvalue = 1 + else: + der_pts.Crv[group].VRefAutoEna.cvalue = 0 + + # curve points + curve_write = False + if params.get('qv_vref_olrt') is not None: + der_pts.Crv[group].VRefAutoTms.cvalue = params['qv_vref_olrt'] + curve_write = True + + if params.get('qv_curve_v_pts') is not None: + if params.get('qv_curve_q_pts') is None: + raise der1547.DER1547Error('Volt-Var curves must be writen in pairs. No Q points provided.') + if params.get('qv_curve_q_pts') is not None: + if params.get('qv_curve_v_pts') is None: + raise der1547.DER1547Error('Volt-Var curves must be writen in pairs. No V points provided') + + if params.get('qv_curve_v_pts') is not None: + if len(params['qv_curve_v_pts']) != len(params['qv_curve_q_pts']): + raise der1547.DER1547Error('V and Q lists are not the same lengths') + if len(params['qv_curve_v_pts']) > der_pts.NPt.cvalue: + raise der1547.DER1547Error('Volt-Var curves require more points than are supported.') + + if len(params['qv_curve_v_pts']) != der_pts.Crv[group].ActPt.cvalue: + der_pts.Crv[group].ActPt.cvalue = len(params['qv_curve_v_pts']) + + for i in range(len(params['qv_curve_v_pts'])): + der_pts.Crv[group].Pt[i].V.cvalue = params['qv_curve_v_pts'][i]*100. # convert pu to % + der_pts.Crv[group].Pt[i].Var.cvalue = params['qv_curve_q_pts'][i]*100. + + curve_write = True + + if params.get('qv_olrt') is not None: + der_pts.Crv[group].RspTms.cvalue = params['qv_olrt'] + curve_write = True + + der_pts.write() # write the VV points and curve + if curve_write: # if writing a new curve, set AdptCrvReq + der_pts.AdptCrvReq.cvalue = group + der_pts.write() # request enabling the new curve + self.ts.sleep(2) # wait to reread the AdptCrvRslt register + curve_enable_result = self.get_qv()['qv_write_result'] + if curve_enable_result == 'IN_PROGRESS' or curve_enable_result == 'FAILED': + self.ts.log_warning('VV Write Result: %s' % curve_enable_result) + + return params + + def get_qp(self, group=1): + """ + Get Q(P) parameters. [Watt-Var] - IEEE 1547 Table 32 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Active Power-Reactive Power (Watt-VAr) Enable qp_mode_enable bool + P-Q curve P1-3 Generation Setting (list) qp_curve_p_gen_pts P p.u. + P-Q curve Q1-3 Generation Setting (list) qp_curve_q_gen_pts VAr p.u. + P-Q curve P1-3 Load Setting (list), negative values qp_curve_p_load_pts P p.u. + P-Q curve Q1-3 Load Setting (list) qp_curve_q_load_pts VAr p.u. + QP Open Loop Response Time Setting qp_olrt s + + :return: dict with keys shown above. + + SunSpec Points + Model ID [ID]: 712 + Model Length [L]: 19 + Module Enable [Ena]: None + Set Active Curve Request [AdptCrvReq]: None + Set Active Curve Result [AdptCrvRslt]: None + Number Of Points [NPt]: 1 + Stored Curve Count [NCrv]: 1 + Reversion Timeout [RvrtTms]: 0 + Reversion Time Left [RvrtRem]: 0 + Reversion Curve [RvrtCrv]: 0 + Active Power Scale Factor [W_SF]: None + Var Scale Factor [DeptRef_SF]: -2 + -------------------------------------------------- + Group: Crv (#1) + Active Points [ActPt]: 1 + Dependent Reference [DeptRef]: None + Pri [Pri]: None + Curve Access [ReadOnly]: None + -------------------------------------------------- + Group: Pt (#1) + Active Power Point [W]: None + Reactive Power Point [Var]: None + + """ + params = {} + der_pts = self.inv.DERWattVar[0] + der_pts.read() + group -= 1 # convert to the python index + + if der_pts.Ena.cvalue is not None: + if der_pts.Ena.cvalue == 1: + params['qp_mode_enable'] = True + else: + params['qp_mode_enable'] = False + + if der_pts.NPt.cvalue is not None: + params['qp_n_points'] = der_pts.NPt.cvalue + if der_pts.NCrv.cvalue is not None: + params['qp_n_curves'] = der_pts.NCrv.cvalue + + # curve points + if der_pts.Crv[group].ActPt.cvalue is not None: + params['qp_curve_n_active_pts'] = der_pts.Crv[group].ActPt.cvalue + else: + params['qp_curve_n_active_pts'] = 6 + params['qp_curve_p_gen_pts'] = [] + params['qp_curve_q_gen_pts'] = [] + params['qp_curve_p_load_pts'] = [] + params['qp_curve_q_load_pts'] = [] + for i in range(params['qp_curve_n_active_pts']): + # self.ts.log_debug('%s, Pt: %s' % (i, der_pts.Crv[group].Pt[i])) + if der_pts.Crv[group].Pt[i].W.cvalue is not None: + p_pu = der_pts.Crv[group].Pt[i].W.cvalue / 100. + q_pu = der_pts.Crv[group].Pt[i].Var.cvalue / 100. + # self.ts.log_debug('P = %s, Q: %s' % (p_pu, q_pu)) + if p_pu < 0: + params['qp_curve_p_load_pts'].append(p_pu) # pu + params['qp_curve_q_load_pts'].append(q_pu) + else: + params['qp_curve_p_gen_pts'].append(p_pu) # pu + params['qp_curve_q_gen_pts'].append(q_pu) + + params['qp_curve_p_load_pts'].reverse() # place P'1 next to axis and P'3 toward -100% P pu + params['qp_curve_q_load_pts'].reverse() + + if der_pts.AdptCrvRslt.cvalue is not None: + write_result = der_pts.AdptCrvRslt.cvalue + params['qp_write_result'] = der_pts.AdptCrvRslt.pdef['symbols'][write_result]['name'] + else: + params['qp_write_result'] = 'UNKNOWN' + + return params + + def set_qp(self, params=None, group=2): + """ + Set Q(P) parameters. [Watt-Var] + """ + der_pts = self.inv.DERWattVar[0] + der_pts.read() + group -= 1 # convert to python index + + # work with the read only points in curve 0 if it is a simulated DER + if self.ifc_type == MAPPED: + group = 0 + + if params.get('qp_mode_enable') is not None: + if params.get('qp_mode_enable'): + der_pts.Ena.cvalue = 1 + else: + der_pts.Ena.cvalue = 0 + der_pts.write() + + if params.get('qp_curve_p_gen_pts') is not None: + p_gen_points = params.get('qp_curve_p_gen_pts') + if params.get('qp_curve_q_gen_pts') is None: + raise der1547.DER1547Error('Watt-Var curves must be writen in pairs. No Qgen points provided.') + if len(params['qp_curve_p_gen_pts']) != len(params['qp_curve_q_gen_pts']): + raise der1547.DER1547Error('P and Q lists are not the same lengths') + else: + p_gen_points = self.get_qp().get('qp_curve_p_gen_pts') + + if params.get('qp_curve_q_gen_pts') is not None: + q_gen_points = params.get('qp_curve_q_gen_pts') + if params.get('qp_curve_p_gen_pts') is None: + raise der1547.DER1547Error('Watt-Var curves must be writen in pairs. No Pgen points provided') + else: + q_gen_points = self.get_qp().get('qp_curve_q_gen_pts') + + if params.get('qp_curve_p_load_pts') is not None: + p_load_points = params.get('qp_curve_p_load_pts') + if params.get('qp_curve_q_load_pts') is None: + raise der1547.DER1547Error('Watt-Var curves must be writen in pairs. No Qload points provided.') + if len(params['qp_curve_p_load_pts']) != len(params['qp_curve_q_load_pts']): + raise der1547.DER1547Error('P and Q lists are not the same lengths') + else: + p_load_points = self.get_qp().get('qp_curve_p_load_pts') + # self.ts.log_debug('From read') + + if params.get('qp_curve_q_load_pts') is not None: + q_load_points = params.get('qp_curve_q_load_pts') + if params.get('qp_curve_p_load_pts') is None: + raise der1547.DER1547Error('Watt-Var curves must be writen in pairs. No Pload points provided') + else: + q_load_points = self.get_qp().get('qp_curve_q_load_pts') + + points = len(p_gen_points) + len(p_load_points) + if points > der_pts.NPt.cvalue: + raise der1547.DER1547Error('Watt-Var curves require more points than are supported.') + + if params.get('qp_curve_p_gen_pts') is None and params.get('qp_curve_q_gen_pts') is None and \ + params.get('qp_curve_p_load_pts') is None and params.get('qp_curve_q_load_pts'): + # do not write points, because none included in params + return params + else: + curve_write = False + if points != der_pts.Crv[group].ActPt.cvalue: + der_pts.Crv[group].ActPt.cvalue = points + der_pts.write() + + # reverse the load points so they align to the axis + p_load_points.reverse() + q_load_points.reverse() + + p_points = p_load_points + p_gen_points + q_points = q_load_points + q_gen_points + # self.ts.log_debug('p_points = %s, q_points: %s' % (p_points, q_points)) + for i in range(points): + curve_write = True + der_pts.Crv[group].Pt[i].W.cvalue = p_points[i]*100. # convert pu to % + der_pts.Crv[group].Pt[i].Var.cvalue = q_points[i]*100. + + # if params.get('qp_olrt') is not None: + # der_pts.Crv[group].RspTms.cvalue = params['qp_olrt'] + + der_pts.write() # write the VV points and curve + if curve_write: # if writing a new curve, set AdptCrvReq + der_pts.AdptCrvReq.cvalue = group + der_pts.write() # request enabling the new curve + self.ts.sleep(2) # wait to reread the AdptCrvRslt register + curve_enable_result = self.get_qp()['qp_write_result'] + if curve_enable_result == 'IN_PROGRESS' or curve_enable_result == 'FAILED': + self.ts.log_warning('WV Write Result: %s' % curve_enable_result) + + return params + + def get_pv(self, group=1): + """ + Get P(V), Voltage-Active Power (Volt-Watt), Parameters + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Voltage-Active Power Mode Enable pv_mode_enable bool + P(V) Curve Point V1-2 Setting (list) pv_curve_v_pts V p.u. + P(V) Curve Point P1-2 Setting (list) pv_curve_p_pts P p.u. + P(V) Curve Point P1-P'2 Setting (list) pv_curve_p_bidrct_pts P p.u. + P(V) Open Loop Response time Setting (0.5-60) pv_olrt s + + :return: dict with keys shown above. + + SunSpec Points + Model ID [ID]: 706 + Model Length [L]: 29 + Module Enable [Ena]: 0 [Disabled] + Adopt Curve Request [AdptCrvReq]: 0 + Adopt Curve Result [AdptCrvRslt]: 0 [Update In Progress] + Number Of Points [NPt]: 2 + Stored Curve Count [NCrv]: 2 + Reversion Timeout [RvrtTms]: None + Reversion Time Remaining [RvrtRem]: None + Reversion Curve [RvrtCrv]: None + Voltage Scale Factor [V_SF]: 0 + Watt Scale Factor [DeptRef_SF]: 0 + -------------------------------------------------- + Group: Crv (#1) + Active Points [ActPt]: 2 + Dependent Reference [DeptRef]: 1 [None] + Open Loop Response Time [RspTms]: 10 + Curve Access [ReadOnly]: 1 [Read-Only Access] + -------------------------------------------------- + Group: Pt (#1) + Voltage Point [V]: 106 + Dependent Reference [W]: 100 + -------------------------------------------------- + Group: Pt (#2) + Voltage Point [V]: 110 + Dependent Reference [W]: 0 + """ + params = {} + der_pts = self.inv.DERVoltWatt[0] + der_pts.read() + group -= 1 # convert to the python index + + if der_pts.Ena.cvalue is not None: + if der_pts.Ena.cvalue == 1: + params['pv_mode_enable'] = True + else: + params['pv_mode_enable'] = False + + if der_pts.NPt.cvalue is not None: + params['pv_n_points'] = der_pts.NPt.cvalue + if der_pts.NCrv.cvalue is not None: + params['pv_n_curves'] = der_pts.NCrv.cvalue + + # curve points + if der_pts.Crv[group].ActPt.cvalue is not None: + params['pv_curve_n_active_pts'] = der_pts.Crv[group].ActPt.cvalue + else: + params['pv_curve_n_active_pts'] = None + + params['pv_curve_v_pts'] = [] + params['pv_curve_p_pts'] = [] + params['pv_curve_p_bidrct_pts'] = [] + for i in range(params['pv_curve_n_active_pts']): + # self.ts.log_debug('%s, Pt: %s' % (i, der_pts.Crv[group].Pt[i])) + if der_pts.Crv[group].Pt[i].W.cvalue is not None: + v_pu = der_pts.Crv[group].Pt[i].V.cvalue / 100. + p_pu = der_pts.Crv[group].Pt[i].W.cvalue / 100. + # self.ts.log_debug('P = %s, Q: %s' % (p_pu, q_pu)) + if p_pu >= 0: + params['pv_curve_p_pts'].append(p_pu) # pu + params['pv_curve_v_pts'].append(v_pu) + else: + params['pv_curve_p_bidrct_pts'].append(p_pu) # pu + params['pv_curve_v_pts'].append(v_pu) + + if der_pts.Crv[group].RspTms.cvalue is not None: + params['pv_olrt'] = der_pts.Crv[group].RspTms.cvalue + + if der_pts.AdptCrvRslt.cvalue is not None: + write_result = der_pts.AdptCrvRslt.cvalue + params['pv_write_result'] = der_pts.AdptCrvRslt.pdef['symbols'][write_result]['name'] + else: + params['pv_write_result'] = 'UNKNOWN' + + return params + + def set_pv(self, params=None, group=2): + """ + Set P(V), Voltage-Active Power (Volt-Watt), Parameters + """ + der_pts = self.inv.DERVoltWatt[0] + der_pts.read() + group -= 1 # convert to python index + + # work with the read only points in curve 0 if it is a simulated DER + if self.ifc_type == MAPPED: + group = 0 + + if params.get('pv_mode_enable') is not None: + if params.get('pv_mode_enable') is True: + der_pts.Ena.cvalue = 1 + else: + der_pts.Ena.cvalue = 0 + + curve_write = False + v_pts = params.get('pv_curve_v_pts') + p_pts = params.get('pv_curve_p_pts') + p_prime_pts = params.get('pv_curve_p_bidrct_pts') + points = None + if p_pts is not None: + if p_prime_pts is not None: + p_pts += p_prime_pts # only add p' is p points exist + + if v_pts is not None: + if p_pts is None: + raise der1547.DER1547Error('Volt-Watt curves must be writen in pairs. No P points provided.') + if len(v_pts) != len(p_pts): + raise der1547.DER1547Error('P and Q lists are not the same lengths') + if p_pts is not None: + points = len(p_pts) + if v_pts is None: + raise der1547.DER1547Error('Volt-Watt curves must be writen in pairs. No Pgen points provided') + + if points is not None: + if points > der_pts.NPt.cvalue: + raise der1547.DER1547Error('Volt-Watt curves require more points than are supported.') + + if points != der_pts.Crv[group].ActPt.cvalue: + der_pts.Crv[group].ActPt.cvalue = points + curve_write = True + + for i in range(points): + der_pts.Crv[group].Pt[i].V.cvalue = v_pts[i] * 100. # convert pu to % + der_pts.Crv[group].Pt[i].W.cvalue = p_pts[i] * 100. # convert pu to % + curve_write = True + + if params.get('pv_olrt') is not None: + der_pts.Crv[group].RspTms.cvalue = params['pv_olrt'] + + der_pts.write() # write the VV points and curve + if curve_write: # if writing a new curve, set AdptCrvReq + der_pts.AdptCrvReq.cvalue = group + der_pts.write() # request enabling the new curve + self.ts.sleep(2) # wait to reread the AdptCrvRslt register + curve_enable_result = self.get_pv()['pv_write_result'] + if curve_enable_result == 'IN_PROGRESS' or curve_enable_result == 'FAILED': + self.ts.log_warning('VV Write Result: %s' % curve_enable_result) + + return params + + def get_const_q(self): + """ + Get Constant Reactive Power Mode + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Constant Reactive Power Mode Enable const_q_mode_enable bool (True=Enabled) + Constant Reactive Power Excitation (not specified in const_q_mode_excitation str ('inj', 'abs') + 1547) + Constant Reactive power setting (See Table 7) const_q VAr p.u. + Maximum Response Time to maintain constant reactive const_q_olrt s + power (not specified in 1547) + + :return: dict with keys shown above. + + SunSpec Points: + Set Reactive Power Enable [VarSetEna]: None + Set Reactive Power Mode [VarSetMod]: None + Reactive Power Priority [VarSetPri]: None + Reactive Power Setpoint (Vars) [VarSet]: None + Reversion Reactive Power (Vars) [VarSetRvrt]: None + Reactive Power Setpoint (Pct) [VarSetPct]: None + Reversion Reactive Power (Pct) [VarSetPctRvrt]: None + Reversion Reactive Power Enable [VarSetRvrtEna]: None + Reactive Power Reversion Time [VarSetRvrtTms]: None + Reactive Power Rev Time Rem [VarSetRvrtRem]: None + + """ + params = {} + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if der_pts.VarSetEna.cvalue is not None: + if der_pts.VarSetEna.cvalue == 1: + params['const_q_mode_enable'] = True + else: + params['const_q_mode_enable'] = False + + if der_pts.VarSetPct.cvalue is not None: + # use positive Q value with excitation indicating directionality + params['const_q'] = abs(der_pts.VarSetPct.cvalue * 100.) # pu to pct + + if der_pts.VarSetPct.cvalue > 0: + params['const_q_mode_excitation'] = 'inj' + else: + params['const_q_mode_excitation'] = 'abs' + + return params + + def set_const_q(self, params=None): + """ + Set Constant Reactive Power Mode + """ + + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if params.get('const_q_mode_enable') is not None: + if params.get('const_q_mode_enable'): + der_pts.VarSetEna.cvalue = 1 + else: + der_pts.VarSetEna.cvalue = 0 + + if params.get('const_q_var_mode') is not None: # Set Reactive Power Mode + if params.get('const_q_var_mode') == 'W_MAX_PCT': + der_pts.VarSetMod.cvalue = 1 + elif params.get('const_q_var_mode') == 'VAR_MAX_PCT': + der_pts.VarSetMod.cvalue = 2 + elif params.get('const_q_var_mode') == 'VAR_AVAIL_PCT': + der_pts.VarSetMod.cvalue = 3 + elif params.get('const_q_var_mode') == 'VARS': + der_pts.VarSetMod.cvalue = 4 + else: + self.ts.log_warning('const_q_var_mode parameter error') + + if params.get('const_q_var_priority') is not None: # Reactive Power Priority + if params.get('const_q_var_priority') == 'ACTIVE': + der_pts.VarSetPri.cvalue = 1 + elif params.get('const_q_var_priority') == 'REACTIVE': + der_pts.VarSetPri.cvalue = 2 + elif params.get('const_q_var_priority') == 'IEEE_1547': + der_pts.VarSetPri.cvalue = 3 + elif params.get('const_q_var_priority') == 'PF': + der_pts.VarSetPri.cvalue = 4 + elif params.get('const_q_var_priority') == 'VENDOR': + der_pts.VarSetPri.cvalue = 5 + else: + self.ts.log_warning('const_q_var_priority parameter error') + + if params.get('const_q') is not None: + if params.get('const_q_mode_excitation') is not None: + if params.get('const_q_mode_excitation') == 'inj': + der_pts.VarSetPct.cvalue = params.get('const_q') / 100. # pct to pu + else: + der_pts.VarSetPct.cvalue = -1 * params.get('const_q') / 100. # pct to pu + else: + self.ts.log_warning('No excitation provided to set_const_q() method.') + # raise der1547.DER1547Error('No excitation provided to set_const_q() method.') + + return params + + def get_p_lim(self): + """ + Get Limit maximum active power - IEEE 1547 Table 40 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable p_lim_mode_enable bool (True=Enabled) + Maximum Active Power p_lim_w P p.u. + + SunSpec Points: + Limit Max Active Power Enable [WMaxLimPctEna]: 0 + Limit Max Power Setpoint [WMaxLim]: 100.0 + Reversion Limit Max Power [WMaxLimPctRvrt]: None + Reversion Limit Max Power Enable [WMaxLimPctRvrtEna]: None + Limit Max Power Reversion Time [WMaxLimPctRvrtTms]: None + Limit Max Power Rev Time Rem [WMaxLimPctRvrtRem]: None + + Set Active Power Enable [WSetEna]: None + Set Active Power Mode [WSetMod]: None + Active Power Setpoint (W) [WSet]: None + Reversion Active Power (W) [WSetRvrt]: None + Active Power Setpoint (Pct) [WSetPct]: None + Reversion Active Power (Pct) [WSetPctRvrt]: None + Reversion Active Power Enable [WSetRvrtEna]: None + Active Power Reversion Time [WSetRvrtTms]: None + Active Power Rev Time Rem [WSetRvrtRem]: None + + """ + params = {} + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if der_pts.WMaxLimPctEna.cvalue is not None: + if der_pts.WMaxLimPctEna.cvalue == 1: + params['p_lim_mode_enable'] = True + else: + params['p_lim_mode_enable'] = False + if der_pts.WMaxLimPct.cvalue is not None: + params['p_lim_w'] = der_pts.WMaxLimPct.cvalue / 100. + + if der_pts.WMaxLimPctRvrt.cvalue is not None: + params['p_lim_w_rvrt'] = der_pts.WMaxLimPct.cvalue + if der_pts.WMaxLimPctRvrtEna.cvalue is not None: + if der_pts.WMaxLimPctRvrtEna.cvalue == 1: + params['p_lim_w_rvrt_ena'] = True + else: + params['p_lim_w_rvrt_ena'] = False + + if der_pts.WMaxLimPctRvrtTms.cvalue is not None: + params['p_lim_w_rvrt_time'] = der_pts.WMaxLimPctRvrtTms.cvalue + if der_pts.WMaxLimPctRvrtRem.cvalue is not None: + params['p_lim_w_rvrt_time_remaining'] = der_pts.WMaxLimPctRvrtRem.cvalue + + return params + + def set_p_lim(self, params=None): + """ + Get Limit maximum active power. + """ + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if params.get('p_lim_mode_enable') is not None: + if params.get('p_lim_mode_enable'): + der_pts.WMaxLimPctEna.cvalue = 1 + else: + der_pts.WMaxLimPctEna.cvalue = 0 + if params.get('p_lim_w') is not None: + der_pts.WMaxLimPct.cvalue = params.get('p_lim_w') * 100. + + # todo for non-DER + # if params.get('p_lim_mode_enable') is not None: + # der_pts.WSetEna.cvalue = params.get('p_lim_mode_enable') + # if params.get('p_lim_mode_enable') is not None: + # der_pts.WSet.cvalue = params.get('p_lim_mode_enable') + + der_pts.write() + + return params + + def get_active_power(self): + """ + Get active power of DER + + :return: + """ + params = {} + der_pts = self.inv.DERCtlAC[0] + der_pts.read() + + if der_pts.WSetEna.cvalue is not None: + if der_pts.WSetEna.cvalue == 1: + params['p_set_mode_enable'] = True + else: + params['p_set_mode_enable'] = False + if der_pts.WSetMod.cvalue is not None: + numeric = der_pts.WSetMod.cvalue + params['p_set_w'] = der_pts.WSetMod.pdef['symbols'][numeric-1]['name'] + if der_pts.WSet.cvalue is not None: + params['p_set_w'] = der_pts.WMaxPctLim.cvalue / 100. + + if der_pts.WSetRvrt.cvalue is not None: + params['p_set_w_rvrt'] = der_pts.WSetRvrt.cvalue + if der_pts.WSetRvrtEna.cvalue is not None: + if der_pts.WSetRvrtEna.cvalue == 1: + params['p_set_w_rvrt_ena'] = True + else: + params['p_set_w_rvrt_ena'] = False + + if der_pts.WSetRvrtTms.cvalue is not None: + params['p_set_w_rvrt_time'] = der_pts.WSetRvrtTms.cvalue + if der_pts.WSetRvrtRem.cvalue is not None: + params['p_set_w_rvrt_time_remaining'] = der_pts.WSetRvrtRem.cvalue + + return params + + def set_active_power(self, params=None): + pass + + def get_pf(self, group=1): + """ + Get P(f), Frequency-Active Power Mode Parameters - IEEE 1547 Table 38 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Frequency-Active Power Mode Enable pf_mode_enable bool (True=Enabled) + P(f) Overfrequency Droop dbOF Setting pf_dbof Hz + P(f) Underfrequency Droop dbUF Setting pf_dbuf Hz + P(f) Overfrequency Droop kOF Setting pf_kof unitless + P(f) Underfrequency Droop kUF Setting pf_kuf unitless + P(f) Open Loop Response Time Setting pf_olrt s + + :return: dict with keys shown above. + + SunSpec Points + DER Frequency Droop ID [ID]: 711 + DER Frequency Droop Length [L]: 19 + DER Frequency-Watt (Frequency-Droop) Module Enable. [Ena]: None + Set Active Control Request [AdptCtlReq]: None + Set Active Control Result [AdptCtlRslt]: None + Stored Curve Count [NCtl]: 1 + Reversion Timeout [RvrtTms]: 0 + Reversion Time Left [RvrtRem]: 0 + Reversion Control [RvrtCtl]: None + Deadband Scale Factor [Db_SF]: -2 + Frequency Change Scale Factor [K_SF]: -2 + Open-Loop Scale Factor [RspTms_SF]: 0 + -------------------------------------------------- + Group: Ctl (#1) + Over-Frequency Deadband [DbOf]: 600.3000000000001 + Under-Frequency Deadband [DbUf]: 599.7 + Over-Frequency Change Ratio [KOf]: 0.4 + Under-Frequency Change Ratio [KUf]: 0.4 + Open-Loop Response Time [RspTms]: 600 + Control Access [ReadOnly]: None + + """ + params = {} + der_pts = self.inv.DERFreqDroop[0] + der_pts.read() + group -= 1 # convert to the python index + + if der_pts.Ena.cvalue is not None: + if der_pts.Ena.cvalue == 1: + params['pf_mode_enable'] = True + else: + params['pf_mode_enable'] = False + + if der_pts.NCtl.cvalue is not None: + params['pf_n_curves'] = der_pts.NCtl.cvalue + + if der_pts.Ctl[group].DbOf.cvalue is not None: + params['pf_dbof'] = der_pts.Ctl[group].DbOf.cvalue + if der_pts.Ctl[group].DbUf.cvalue is not None: + params['pf_dbuf'] = der_pts.Ctl[group].DbUf.cvalue + if der_pts.Ctl[group].KOf.cvalue is not None: + params['pf_kof'] = der_pts.Ctl[group].KOf.cvalue + if der_pts.Ctl[group].KUf.cvalue is not None: + params['pf_kuf'] = der_pts.Ctl[group].KUf.cvalue + if der_pts.Ctl[group].RspTms.cvalue is not None: + params['pf_olrt'] = der_pts.Ctl[group].RspTms.cvalue + + if der_pts.AdptCtlRslt is not None: + if der_pts.AdptCtlRslt.cvalue is not None: + write_result = der_pts.AdptCtlRslt.cvalue + params['pf_write_result'] = der_pts.AdptCtlRslt.pdef['symbols'][write_result]['name'] + else: + params['pf_write_result'] = 'UNKNOWN' + + return params + + def set_pf(self, params=None, group=2): + """ + Set P(f), Frequency-Active Power Mode Parameters + """ + der_pts = self.inv.DERFreqDroop[0] + der_pts.read() + group -= 1 # convert to the python index + + # work with the read only points in curve 0 if it is a simulated DER + if self.ifc_type == MAPPED: + group = 0 + + if params.get('pf_mode_enable') is not None: + if params.get('pf_mode_enable'): + der_pts.Ena.cvalue = 1 + else: + der_pts.Ena.cvalue = 0 + + curve_write = False + if params.get('pf_dbof') is not None: + der_pts.Ctl[group].DbOf.cvalue = params.get('pf_dbof') + curve_write = True + if params.get('pf_dbuf') is not None: + der_pts.Ctl[group].DbUf.cvalue = params.get('pf_dbuf') + curve_write = True + if params.get('pf_kof') is not None: + der_pts.Ctl[group].KOf.cvalue = params.get('pf_kof') + curve_write = True + if params.get('pf_kuf') is not None: + der_pts.Ctl[group].KUf.cvalue = params.get('pf_kuf') + curve_write = True + if params.get('pf_olrt') is not None: + der_pts.Ctl[group].RspTms.cvalue = params.get('pf_olrt') + curve_write = True + + der_pts.write() # write the VV points and curve + if curve_write: # if writing a new curve, set AdptCrvReq + der_pts.AdptCtlReq.cvalue = group + der_pts.write() # request enabling the new curve + self.ts.sleep(2) # wait to reread the AdptCrvRslt register + curve_enable_result = self.get_pf()['pf_write_result'] + if curve_enable_result == 'IN_PROGRESS' or curve_enable_result == 'FAILED': + self.ts.log_warning('VV Write Result: %s' % curve_enable_result) + + return params + + def get_es_permit_service(self): + """ + Get Permit Service Mode Parameters - IEEE 1547 Table 39 + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Permit service es_permit_service bool (True=Enabled) + ES Voltage Low Setting es_v_low V p.u. + ES Voltage High Setting es_v_high V p.u. + ES Frequency Low Setting es_f_low Hz + ES Frequency High Setting es_f_high Hz + ES Randomized Delay es_randomized_delay bool (True=Enabled) + ES Delay Setting es_delay s + ES Ramp Rate Setting es_ramp_rate %/s + + :return: dict with keys shown above. + + SunSpec Points + Enter Service ID [ID]: 703 + Enter Service Length [L]: 12 + Permit Enter Service [ES]: 1 [None] + Enter Service Voltage High [ESVHi]: 1.05 + Enter Service Voltage Low [ESVLo]: 0.917 + Enter Service Frequency High [ESHzHi]: 60.1 + Enter Service Frequency Low [ESHzLo]: 59.5 + Enter Service Delay Time [ESDlyTms]: 300 + Enter Service Random Delay [ESRndTms]: 100 + Enter Service Ramp Time [ESRmpTms]: 60 + Voltage Scale Factor [V_SF]: -3 + Frequency Scale Factor [Hz_SF]: -2 + + """ + params = {} + der_pts = self.inv.DEREnterService[0] + der_pts.read() + + if der_pts.ES.cvalue is not None: + if der_pts.ES.cvalue == 1: + params['es_permit_service'] = True + else: + params['es_permit_service'] = False + + if der_pts.ESVLo.cvalue is not None: + params['es_v_low'] = der_pts.ESVLo.cvalue + if der_pts.ESVHi.cvalue is not None: + params['es_v_high'] = der_pts.ESVHi.cvalue + + if der_pts.ESHzLo.cvalue is not None: + params['es_f_low'] = der_pts.ESHzLo.cvalue + if der_pts.ESHzHi.cvalue is not None: + params['es_f_high'] = der_pts.ESHzHi.cvalue + + if der_pts.ESRndTms.cvalue is not None: + params['es_randomized_delay'] = der_pts.ESRndTms.cvalue + if der_pts.ESDlyTms.cvalue is not None: + params['es_delay'] = der_pts.ESDlyTms.cvalue + if der_pts.ESRmpTms.cvalue is not None: + params['es_ramp_rate'] = der_pts.ESRmpTms.cvalue + + return params + + def set_es_permit_service(self, params=None): + """ + Set Permit Service Mode Parameters + """ + der_pts = self.inv.DEREnterService[0] + der_pts.read() + + if params.get('es_permit_service') is not None: + if params.get('es_permit_service'): + der_pts.ES.cvalue = 1 + else: + der_pts.ES.cvalue = 0 + + if params.get('es_v_low') is not None: + der_pts.ESVLo.cvalue = params['es_v_low'] + if params.get('es_v_high') is not None: + der_pts.ESVHi.cvalue = params['es_v_high'] + + if params.get('es_f_low') is not None: + der_pts.ESHzLo.cvalue = params['es_f_low'] + if params.get('es_f_high') is not None: + der_pts.ESHzHi.cvalue = params['es_f_high'] + + if params.get('es_randomized_delay') is not None: + der_pts.ESRndTms.cvalue = params['es_randomized_delay'] + if params.get('es_delay') is not None: + der_pts.ESDlyTms.cvalue = params['es_delay'] + if params.get('es_ramp_rate') is not None: + der_pts.ESRmpTms.cvalue = params['es_ramp_rate'] + + der_pts.write() + + return params + + def get_ui(self): + """ + Get Unintentional Islanding Parameters + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + Unintentional Islanding Mode (enabled/disabled). This ui_mode_enable bool + function is enabled by default, and disabled only by + request from the Area EPS Operator. + UI is always on in 1547 BUT 1547.1 says turn it off + for some testing + Unintential Islanding methods supported. Where multiple ui_capability_er list str + modes are supported place in a list. + UI BLRC = Balanced RLC, + UI PCPST = Powerline conducted, + UI PHIT = Permissive Hardware-input, + UI RMIP = Reverse/min relay. Methods other than UI + BRLC may require supplemental comissioning tests. + e.g., ['UI_BLRC', 'UI_PCPST', 'UI_PHIT', 'UI_RMIP'] + + :return: dict with keys shown above. + """ + pass + + def set_ui(self): + """ + Get Unintentional Islanding Parameters + """ + pass + + def get_any_ride_thru_trip_or_mc(self, der_pts=None, group=1, curve_type=None): + """ + A catch-all function for ride-through curves, trip curves, and momentary cessation + + :param der_pts: the object of the DER that will be written, e.g., self.inv.DERTripLV + :param group: the top level curve group + :param curve_type: str 'MustTrip', 'MayTrip', or 'MomCess' + :return: params dict + + Example SunSpec Points + DER Trip LV Model ID [ID]: 707 + DER Trip LV Model Length [L]: 18 + DER Trip LV Module Enable [Ena]: 1 [Enabled] + Adopt Curve Request [AdptCrvReq]: None + Adopt Curve Result [AdptCrvRslt]: None + Number Of Points [NPt]: 1 + Stored Curve Count [NCrvSet]: 1 + Voltage Scale Factor [V_SF]: -2 + Time Point Scale Factor [Tms_SF]: 0 + -------------------------------------------------- + Group: Crv (#1) + Curve Access [ReadOnly]: None + -------------------------------------------------- + Group: MustTrip + Number Of Active Points [ActPt]: 1 + -------------------------------------------------- + Group: Pt (#1) + Voltage Point [V]: 50.0 + Time Point [Tms]: 5 + -------------------------------------------------- + Group: MayTrip + Number Of Active Points [ActPt]: 1 + -------------------------------------------------- + Group: Pt (#1) + Voltage Point [V]: 70.0 + Time Point [Tms]: 5 + -------------------------------------------------- + Group: MomCess + Number Of Active Points [ActPt]: 1 + -------------------------------------------------- + Group: Pt (#1) + Voltage Point [V]: 60.0 + Time Point [Tms]: 5 + + """ + der_pts.read() + group -= 1 # convert to the python index + + params = {} + if der_pts.Ena.cvalue is not None: + if der_pts.Ena.cvalue == 1: + params['Ena'] = True + else: + params['Ena'] = False + + if der_pts.AdptCrvReq.cvalue is not None: + params['AdptCrvReq'] = der_pts.AdptCrvReq.cvalue + else: + params['AdptCrvReq'] = None + + if der_pts.AdptCrvRslt.cvalue is not None: + write_result = der_pts.AdptCrvRslt.cvalue + params['write_result'] = der_pts.AdptCrvRslt.pdef['symbols'][write_result]['name'] + else: + params['write_result'] = 'UNKNOWN' + + if der_pts.NPt.cvalue is not None: + params['NPt'] = der_pts.NPt.cvalue + else: + params['NPt'] = None + + if der_pts.NCrvSet.cvalue is not None: + params['NCrvSet'] = der_pts.NCrvSet.cvalue + else: + params['NCrvSet'] = None + + if der_pts.NCrvSet.cvalue is not None: + params['NCrvSet'] = der_pts.NCrvSet.cvalue + else: + params['NCrvSet'] = None + + if curve_type == 'MustTrip': + if not isinstance(der_pts.Crv[group].MustTrip, list): + curve = der_pts.Crv[group].MustTrip + else: + curve = der_pts.Crv[group].MustTrip[0] # assume first curve + elif curve_type == 'MayTrip': + if not isinstance(der_pts.Crv[group].MayTrip, list): + curve = der_pts.Crv[group].MayTrip + else: + curve = der_pts.Crv[group].MayTrip[0] # assume first curve + elif curve_type == 'MomCess': + if not isinstance(der_pts.Crv[group].MomCess, list): + curve = der_pts.Crv[group].MomCess + else: + curve = der_pts.Crv[group].MomCess[0] # assume first curve + else: + raise der1547.DER1547Error('Incorrect curve_type string in get_any_ride_thru_trip_or_mc') + + if curve.ActPt.cvalue is not None: + params['ActPt'] = curve.ActPt.cvalue + else: + params['ActPt'] = 0 + + params['T'] = [] + params['V'] = [] + params['F'] = [] + + for i in range(params['ActPt']): + params['T'].append(curve.Pt[i].Tms.cvalue) + try: + params['V'].append(curve.Pt[i].V.cvalue) + except Exception as e: + pass # not a voltage curve + try: + params['F'].append(curve.Pt[i].Hz.cvalue) + except Exception as e: + pass # not a freq curve + + return params + + def set_any_ride_thru_trip_or_mc(self, params=None, der_pts=None, group=1, curve_type=None): + """ + A catch-all function for ride-through curves, trip curves, and momentary cessation + + :param params: dict of points to write to SunSpec device + :param der_pts: the object of the DER that will be written, e.g., self.inv.DERTripLV + :param group: the top level curve group + :param curve_type: str 'MustTrip', 'MayTrip', or 'MomCess' + :return: params dict + + """ + der_pts.read() + group -= 1 # convert to the python index + + if params.get('Ena') is not None: + if params['Ena']: + der_pts.Ena.cvalue = 1 + else: + der_pts.Ena.cvalue = 0 + + if params.get('AdptCrvReq') is not None: + der_pts.AdptCrvReq.cvalue = params['AdptCrvReq'] + if params.get('NPt') is not None: + der_pts.NPt.cvalue = params['NPt'] + if params.get('NCrvSet') is not None: + der_pts.NCrvSet.cvalue = params['NCrvSet'] + + if curve_type == 'MustTrip': + if not isinstance(der_pts.Crv[group].MustTrip, list): + curve = der_pts.Crv[group].MustTrip + else: + curve = der_pts.Crv[group].MustTrip[0] # assume first curve + elif curve_type == 'MayTrip': + if not isinstance(der_pts.Crv[group].MayTrip, list): + curve = der_pts.Crv[group].MayTrip + else: + curve = der_pts.Crv[group].MayTrip[0] # assume first curve + elif curve_type == 'MomCess': + if not isinstance(der_pts.Crv[group].MomCess, list): + curve = der_pts.Crv[group].MomCess + else: + curve = der_pts.Crv[group].MomCess[0] # assume first curve + else: + raise der1547.DER1547Error('Incorrect curve_type string in get_any_ride_thru_trip_or_mc') + + curve_write = False + if params.get('T') is not None: + curve_write = True + if len(params.get('T')) > der_pts.NPt.cvalue: + raise der1547.DER1547Error('Number of points is larger than NPt') + if curve.ActPt.cvalue > der_pts.NPt.cvalue: + raise der1547.DER1547Error('ActPt is larger than NPt') + curve.ActPt.cvalue = len(params['T']) + # self.ts.log_debug('params[T]: %s' % params['T']) + + for i in range(len(params['T'])): + # self.ts.log_debug('params[T][i]: %s' % params['T'][i]) + curve.Pt[i].Tms.cvalue = params['T'][i] + if params.get('V') is not None: + # self.ts.log_debug('params[V][i]: %s' % params['V'][i]) + curve.Pt[i].V.cvalue = params['V'][i] + if params.get('F') is not None: + # self.ts.log_debug('params[F]: %s' % params['F']) + curve.Pt[i].Hz.cvalue = params['F'][i] + + der_pts.write() + if curve_write: + der_pts.AdptCrvReq.cvalue = group + der_pts.write() # request enabling the new curve + self.ts.sleep(2) # wait to reread the AdptCrvRslt register + curve_enable_result = self.get_any_ride_thru_trip_or_mc(der_pts, group, curve_type)['write_result'] + if curve_enable_result == 'IN_PROGRESS' or curve_enable_result == 'FAILED': + self.ts.log_warning('Write Result: %s' % curve_enable_result) + + return params + + def get_ov(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV Trip Curve Point OV_V1-3 Setting ov_trip_v_pts V p.u. + HV Trip Curve Point OV_T1-3 Setting ov_trip_t_pts s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripHV[0], group=1, curve_type='MustTrip') + params = {'ov_trip_v_pts': [v / 100. for v in suns_dict['V']], 'ov_trip_t_pts': suns_dict['T']} + return params + + def set_ov(self, params=None): + """ + Set Overvoltage Trip Parameters - IEEE 1547 Table 35 + """ + suns_dict = {} + if params.get('ov_trip_v_pts') is not None: + suns_dict['V'] = [v * 100. for v in params.get('ov_trip_v_pts')] + if sorted(suns_dict['V']) != suns_dict['V']: + raise der1547.DER1547Error('Voltage points are not increasing') + if params.get('ov_trip_t_pts') is not None: + suns_dict['T'] = params.get('ov_trip_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripHV[0], + group=1, curve_type='MustTrip') + + return params + + def get_uv(self, params=None): + """ + Get Overvoltage Trip Parameters - IEEE 1547 Table 35 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV Trip Curve Point UV_V1-3 Setting uv_trip_v_pts V p.u. + LV Trip Curve Point UV_T1-3 Setting uv_trip_t_pts s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripLV[0], group=1, curve_type='MustTrip') + params= {'uv_trip_v_pts': [v / 100. for v in suns_dict['V']], 'uv_trip_t_pts': suns_dict['T']} + return params + + def set_uv(self, params=None): + """ + Set Undervoltage Trip Parameters - IEEE 1547 Table 35 + """ + suns_dict = {} + if params.get('uv_trip_v_pts') is not None: + suns_dict['V'] = [v * 100. for v in params.get('uv_trip_v_pts')] + if sorted(reversed(suns_dict['V'])) != list(reversed(suns_dict['V'])): + raise der1547.DER1547Error('Voltage points are not decreasing') + if params.get('uv_trip_t_pts') is not None: + suns_dict['T'] = params.get('uv_trip_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripLV[0], + group=1, curve_type='MustTrip') + + return params + + def get_of(self, params=None): + """ + Get Overfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + OF Trip Curve Point OF_F1-3 Setting of_trip_f_pts Hz + OF Trip Curve Point OF_T1-3 Setting of_trip_t_pts s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripHF[0], group=1, curve_type='MustTrip') + params= {'of_trip_f_pts': suns_dict['F'], 'of_trip_t_pts': suns_dict['T']} + return params + + def set_of(self, params=None): + """ + Set Overfrequency Trip Parameters - IEEE 1547 Table 37 + """ + suns_dict = {} + if params.get('of_trip_f_pts') is not None: + suns_dict['F'] = params.get('of_trip_f_pts') + if sorted(suns_dict['F']) != suns_dict['F']: + raise der1547.DER1547Error('Freq points are not decreasing') + if params.get('of_trip_t_pts') is not None: + suns_dict['T'] = params.get('of_trip_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripHF[0], + group=1, curve_type='MustTrip') + + return params + + def get_uf(self, params=None): + """ + Get Underfrequency Trip Parameters - IEEE 1547 Table 37 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + UF Trip Curve Point UF_F1-3 Setting uf_trip_f_pts Hz + UF Trip Curve Point UF_T1-3 Setting uf_trip_t_pts s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripLF[0], group=1, curve_type='MustTrip') + params= {'uf_trip_f_pts': suns_dict['F'], 'uf_trip_t_pts': suns_dict['T']} + return params + + def set_uf(self, params=None): + """ + Set Underfrequency Trip Parameters - IEEE 1547 Table 37 + """ + suns_dict = {} + if params.get('uf_trip_f_pts') is not None: + suns_dict['F'] = params.get('uf_trip_f_pts') + if sorted(reversed(suns_dict['F'])) != list(reversed(suns_dict['F'])): + raise der1547.DER1547Error('Freq points are not decreasing') + if params.get('uf_trip_t_pts') is not None: + suns_dict['T'] = params.get('uf_trip_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripLF[0], group=1, + curve_type='MustTrip') + + return params + + def get_ov_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + HV MC Curve Point OV_V1-3 (see Tables 11-13) ov_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + HV MC Curve Point OV_V1-3 Setting ov_mc_v_pts V p.u. + HV MC Curve Point OV_V1-3 (RofA not specified in 1547) ov_mc_v_pts_er_max V p.u. + HV MC Curve Point OV_T1-3 (see Tables 11-13) ov_mc_t_pts_er_min s + (RofA not specified in 1547) + HV MC Curve Point OV_T1-3 Setting ov_mc_t_pts s + HV MC Curve Point OV_T1-3 (RofA not specified in 1547) ov_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripHV[0], group=1, curve_type='MomCess') + params = {'ov_mc_v_pts': [v / 100. for v in suns_dict['V']], 'ov_mc_t_pts': suns_dict['T']} + return params + + def set_ov_mc(self, params=None): + """ + Set Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + suns_dict = {} + if params.get('ov_mc_v_pts') is not None: + suns_dict['V'] = [v * 100. for v in params.get('ov_mc_v_pts')] + if sorted(suns_dict['V']) != suns_dict['V']: + raise der1547.DER1547Error('Voltage points are not increasing') + if params.get('ov_mc_t_pts') is not None: + suns_dict['T'] = params.get('ov_mc_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripHV[0], group=1, + curve_type='MomCess') + + return params + + def get_uv_mc(self, params=None): + """ + Get Overvoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + _______________________________________________________________________________________________________________ + Parameter params dict key units + _______________________________________________________________________________________________________________ + LV MC Curve Point UV_V1-3 (see Tables 11-13) uv_mc_v_pts_er_min V p.u. + (RofA not specified in 1547) + LV MC Curve Point UV_V1-3 Setting uv_mc_v_pts V p.u. + LV MC Curve Point UV_V1-3 (RofA not specified in 1547) uv_mc_v_pts_er_max V p.u. + LV MC Curve Point UV_T1-3 (see Tables 11-13) uv_mc_t_pts_er_min s + (RofA not specified in 1547) + LV MC Curve Point UV_T1-3 Setting uv_mc_t_pts s + LV MC Curve Point UV_T1-3 (RofA not specified in 1547) uv_mc_t_pts_er_max s + + :return: dict with keys shown above. + """ + suns_dict = self.get_any_ride_thru_trip_or_mc(der_pts=self.inv.DERTripLV[0], group=1, curve_type='MomCess') + params = {'uv_mc_v_pts': [v / 100. for v in suns_dict['V']], 'uv_mc_t_pts': suns_dict['T']} + return params + + def set_uv_mc(self, params=None): + """ + Set Undervoltage Momentary Cessation (MC) Parameters - IEEE 1547 Table 36 + """ + suns_dict = {} + if params.get('uv_mc_v_pts') is not None: + suns_dict['V'] = [v * 100. for v in params.get('uv_mc_v_pts')] + if sorted(reversed(suns_dict['V'])) != list(reversed(suns_dict['V'])): + raise der1547.DER1547Error('Voltage points are not decreasing') + if params.get('uv_mc_t_pts') is not None: + suns_dict['T'] = params.get('uv_mc_t_pts') + + self.set_any_ride_thru_trip_or_mc(params=suns_dict, der_pts=self.inv.DERTripLV[0], group=1, + curve_type='MomCess') + + return params + + def set_cease_to_energize(self, params=None): + """ + + A DER can be directed to cease to energize and trip by changing the Permit service setting to “disabled” as + described in IEEE 1574 Section 4.10.3. + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Cease to energize and trip cease_to_energize bool (True=Enabled) + + """ + return self.set_es_permit_service(params={'es_permit_service': params['cease_to_energize']}) + + ''' + # Additional functions outside of IEEE 1547-2018 + def get_conn(self): + """ + Get Connection - DER Connect/Disconnect Switch + ______________________________________________________________________________________________________________ + Parameter params dict key units + ______________________________________________________________________________________________________________ + Connect/Disconnect Enable conn bool (True=Enabled) + """ + pass + + def set_conn(self, params=None): + """ + Set Connection + """ + pass + + def set_error(self, params=None): + """ + Set Error, for testing Monitoring Data in DER + + error = set error + """ + pass + + ''' diff --git a/Lib/svpelab/der_epri_pv_sim.py b/Lib/svpelab/der_epri_pv_sim.py new file mode 100644 index 0000000..74ba7c2 --- /dev/null +++ b/Lib/svpelab/der_epri_pv_sim.py @@ -0,0 +1,346 @@ +""" +Copyright (c) 2018, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import der +import math + +try: + import requests +except Exception as e: + print('Missing requests package') +try: + import json +except Exception as e: + print('Missing json package') +try: + import urllib.request, urllib.error, urllib.parse +except Exception as e: + print('Missing urllib2 package') +epri_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'EPRI' +} + +def der_info(): + return epri_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = epri_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + # TCP parameters + info.param(pname('ipaddr'), label='IP Address', default='http://10.1.2.2') + info.param(pname('ipport'), label='IP Port', default=8000) + info.param(pname('ipaddr_reads'), label='IP Address Data Stream', default='http://localhost') + info.param(pname('ipport_reads'), label='IP Port Data Stream', default=8081) + info.param(pname('mRID'), label='Inverter ID', default='03ac0d62-2d29-49ad-915e-15b9fbd46d86') + # a3bbf028-ff09-4185-95ea-4c6dfea23d8c + # 22261658-4c34-41ec-ab51-6a794bb47d37 + +GROUP_NAME = 'epri' + + +class DER(der.DER): + + def __init__(self, ts, group_name): + der.DER.__init__(self, ts, group_name) + self.headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + self.connection = None + self.mrid = self.param_value('mRID') + + # Setup client for writing to EPRI inverter + ipaddr = self.param_value('ipaddr') + ipport = self.param_value('ipport') + self.address = '%s:%s' % (ipaddr, ipport) + + # Configure client connection to server for monitoring points + server_ipaddr = self.param_value('ipaddr_reads') + server_ipport = self.param_value('ipport_reads') + self.server_address = '%s:%s' % (server_ipaddr, server_ipport) + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.open() + + def open(self): + # Start communications between DERMS and EPRI PV Sim + comm_start_cmd = { + "namespace": "comms", + "function": "startCommunication", + "requestId": "requestId", + "parameters": { + "deviceIds": [self.mrid] + } + } + + r = requests.post(self.address, json=comm_start_cmd) + self.ts.log_debug('Communication established to PDA. Data Posted! ' + 'statusMessage: %s' % r.json()['statusMessage']) + + def close(self): + if self.connection is not None: + self.connection.close() + + def info(self): + """ Get DER device information. + + Params: + Manufacturer + Model + Version + Options + SerialNumber + + :return: Dictionary of information elements. + """ + + try: + params = {} + params['Manufacturer'] = 'EPRI' + params['Model'] = "PV Simulator" + except Exception as e: + raise der.DERError(str(e)) + + return params + + def measurements(self): + """ Get measurement data. + + Params: + + :return: Dictionary of measurement data. + """ + + try: + params = {} + req = urllib.request.Request(self.server_address) + resp = urllib.request.urlopen(req, timeout=0.5) + r = resp.read() + if len(r) > 0: + data = json.loads(r) + # self.ts.log_debug(data) + params['W'] = data.get(self.mrid).get('Watts') + params['Hz'] = data.get(self.mrid).get('F') + params['VAr'] = data.get(self.mrid).get('Vars') + params['PF'] = data.get(self.mrid).get('PF') + params['PhVphA'] = data.get(self.mrid).get('VphAN') + try: + params['VA'] = math.sqrt(params['W']**2 + params['VAr']**2) + # self.ts.log_debug('%s Watts, %s Vars, %s VA' % (params['W'], params['VAr'], params['VA'])) + except Exception as e: + params['VA'] = None + except Exception as e: + raise der.DERError(str(e)) + + return params + + def fixed_pf(self, params=None): + """ Get/set fixed power factor control settings. + + Params: + Ena - Enabled (True/False) + PF - Power Factor set point + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for fixed factor. + """ + if params is not None: + pf = params.get('PF') + if pf is None: + pf = 1.0 + var_action = "reverseProducingVars" + else: + if pf < 0: # negative pf indicates the the inverter is injecting vars (EEI/SunSpec Sign Convention) + var_action = "doNotreverseProducingVars" + else: + var_action = "reverseProducingVars" + + win_tms = params.get('WinTms') + if win_tms is None: + win_tms = 0.0 + rmp_tms = params.get('RmpTms') + if rmp_tms is None: + rmp_tms = 0.0 + rvrt_tms = params.get('RvrtTms') + if rvrt_tms is None: + rvrt_tms = 0.0 + + # Field Data Type Description + # namespace String Namespace will be "der" for all device level messages to the PDA + # function String Function name will be "configurePowerFactor" to enable the power + # factorfunction in the inverter + # requestId String RequestId will be a unique identifier for each request. Request IDs + # can be used by RT-OPF to track the status of the request. Response + # from PDA will contain the request ID of the corresponding request. + # deviceIds Array of strings Array containing the mRIDs of the devices + # timeWindow Integer Time in seconds, over which a new setting is to take effect + # reversionTimeout Integer Time in seconds, after which the function is disabled + # rampTime Integer Time in seconds, over which the DER linearly places the new limit into + # effect + # powerFactor number Sets the power factor of the inverter. Value must be between -1.0 and + # +1.0 + # varAction String Specifies whether the PF setting is leading or lagging. The value + # must be "reverseProducingVars" to absorb VARs and + # "doNotreverseProducingVars" to produce VARs + pf_cmd = {"namespace": "der", + "function": "configurePowerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": [self.mrid], + "timeWindow": win_tms, + "reversionTimeout": rvrt_tms, + "rampTime": rmp_tms, + "powerFactor": pf, + "varAction": var_action + } + } + + # self.ts.log_debug('Setting new PF...') + r = requests.post(self.address, json=pf_cmd) + # self.ts.log_debug('Data Posted! statusMessage: %s' % r.json()['statusMessage']) + + ena = params.get('Ena') + if ena is None: + ena = False + # Field Data Type Description + # enable Boolean Enable key will be set to true in order to enable the function + # and false to disable the power factor function + pf_enable_cmd = {"namespace": "der", + "function": "powerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": [self.mrid], + "enable": ena + } + } + + # self.ts.log_debug('Enabling new PF...') + r = requests.post(self.address, json=pf_enable_cmd) + # self.ts.log_debug('Data Posted! statusMessage: %s' % r.json()['statusMessage']) + + else: # read PF data + params = {'Ena': None, 'PF': None, 'WinTms': None, 'RmpTms': None, 'RvrtTms': None} + + return params + + def limit_max_power(self, params=None): + """ Get/set max active power control settings. + + Params: + Ena - Enabled (True/False) + WMaxPct - Active power maximum as percentage of WMax + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for limit max power. + """ + pass + + def volt_var(self, params=None): + """ Get/set volt/var control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/var control. + """ + pass + +if __name__ == "__main__": + + import os + import http.client + import json + import requests + + headers = {'Content-type': 'application/json'} + + comm_start_cmd = { + "namespace": "comms", + "function": "startCommunication", + "requestId": "requestId", + "parameters": { + "deviceIds": ['03ac0d62-2d29-49ad-915e-15b9fbd46d86', ] + } + } + + response = requests.post('http://localhost:8000', json=comm_start_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + pf_cmd = {"namespace": "der", + "function": "configurePowerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": ["03ac0d62-2d29-49ad-915e-15b9fbd46d86"], + "timeWindow": 0, + "reversionTimeout": 0, + "rampTime": 0, + "powerFactor": 0.85, + "varAction": "reverseProducingVars" + } + } + + print('Setting new PF...') + response = requests.post('http://localhost:8000', json=pf_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + + pf_enable_cmd = {"namespace": "der", + "function": "powerFactor", + "requestId": "requestId", + "parameters": { + "deviceIds": ["03ac0d62-2d29-49ad-915e-15b9fbd46d86"], + "enable": True + } + } + + print('Enabling new PF...') + response = requests.post('http://localhost:8000', json=pf_enable_cmd) + print(('Data Posted! statusMessage: %s' % response.json()['statusMessage'])) + diff --git a/Lib/svpelab/der_manual.py b/Lib/svpelab/der_manual.py index 9034cdc..69380d7 100644 --- a/Lib/svpelab/der_manual.py +++ b/Lib/svpelab/der_manual.py @@ -32,7 +32,7 @@ import os -import der +from . import der manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -47,6 +47,12 @@ def params(info, group_name): pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name mode = manual_info['mode'] info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, + glob=True) + info.param(pname('ipaddr'), label='IP Address', default='1.2.3.4') + info.param(pname('ipport'), label='IP Port', default=999) + info.param(pname('slave_id'), label='Slave Id', default=1) + ''' info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) @@ -56,8 +62,11 @@ def params(info, group_name): class DER(der.DER): - def __init__(self, ts, group_name): + def __init__(self, ts, group_name, support_interfaces): der.DER.__init__(self, ts, group_name) + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) def info(self): """ Get DER device information. @@ -73,12 +82,12 @@ def info(self): """ try: params = {} - params['Manufacturer'] = self.ts.prompt('Enter Manufacturer: ') - params['Model'] = self.ts.prompt('Enter Model: ') - params['Options'] = self.ts.prompt('Enter Options: ') - params['Version'] = self.ts.prompt('Enter Version: ') - params['SerialNumber'] = self.ts.prompt('Enter Serial Number: ') - except Exception, e: + params['Manufacturer'] = 'MANUAL' + params['Model'] = 'MANUAL' + params['Options'] = 'MANUAL' + params['Version'] = 'MANUAL' + params['SerialNumber'] = 'MANUAL' + except Exception as e: raise der.DERError(str(e)) return params @@ -117,7 +126,7 @@ def nameplate(self): params['AhrRtg'] = self.ts.prompt('Enter AhrRtg: ') params['MaxChaRte'] = self.ts.prompt('Enter MaxChaRte: ') params['MaxDisChaRte'] = self.ts.prompt('Enter MaxDisChaRte: ') - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -131,38 +140,42 @@ def measurements(self): """ try: - params['A'] = self.ts.prompt('Enter A: ') - params['AphA'] = self.ts.prompt('Enter AphA: ') - params['AphB'] = self.ts.prompt('Enter AphB: ') - params['AphC'] = self.ts.prompt('Enter AphC: ') - params['PPVphAB'] = self.ts.prompt('Enter PPVphAB: ') - params['PPVphBC'] = self.ts.prompt('Enter PPVphBC: ') - params['PPVphCA'] = self.ts.prompt('Enter PPVphCA: ') - params['PhVphA'] = self.ts.prompt('Enter PhVphA: ') - params['PhVphB'] = self.ts.prompt('Enter PhVphB: ') - params['PhVphC'] = self.ts.prompt('Enter PhVphC: ') - params['W'] = self.ts.prompt('Enter W: ') - params['Hz'] = self.ts.prompt('Enter Hz: ') - params['VA'] = self.ts.prompt('Enter VA: ') - params['VAr'] = self.ts.prompt('Enter VAr: ') - params['PF'] = self.ts.prompt('Enter PF: ') - params['WH'] = self.ts.prompt('Enter WH: ') - params['DCA'] = self.ts.prompt('Enter DCA: ') - params['DCV'] = self.ts.prompt('Enter DCV: ') - params['DCW'] = self.ts.prompt('Enter DCW: ') - params['TmpCab'] = self.ts.prompt('Enter TmpCab: ') - params['TmpSnk'] = self.ts.prompt('Enter TmpSnk: ') - params['TmpTrns'] = self.ts.prompt('Enter TmpTrns: ') - params['TmpOt'] = self.ts.prompt('Enter TmpOt: ') - params['St'] = self.ts.prompt('Enter St: ') - params['StVnd'] = self.ts.prompt('Enter StVnd: ') - params['Evt1'] = self.ts.prompt('Enter Evt1: ') - params['Evt2'] = self.ts.prompt('Enter Evt2: ') - params['EvtVnd1'] = self.ts.prompt('Enter EvtVnd1: ') - params['EvtVnd2'] = self.ts.prompt('Enter EvtVnd2: ') - params['EvtVnd3'] = self.ts.prompt('Enter EvtVnd3: ') - params['EvtVnd4'] = self.ts.prompt('Enter EvtVnd4: ') - except Exception, e: + a = 123 + params = {} + + params['A'] = a + params['AphA'] = a + params['AphB'] = a + params['AphC'] = a + params['PPVphAB'] = a + params['PPVphBC'] = a + params['PPVphCA'] = a + params['PhVphA'] = a + params['PhVphB'] = a + params['PhVphC'] = a + params['W'] = a + params['Hz'] = a + params['VA'] = a + params['VAr'] = a + params['PF'] = a + params['WH'] = a + params['DCA'] = a + params['DCV'] = a + params['DCW'] = a + params['TmpCab'] = a + params['TmpSnk'] = a + params['TmpTrns'] = a + params['TmpOt'] = a + params['St'] = a + params['StVnd'] = a + params['Evt1'] = a + params['Evt2'] = a + params['EvtVnd1'] = a + params['EvtVnd2'] = a + params['EvtVnd3'] = a + params['EvtVnd4'] = a + + except Exception as e: raise der.DERError(str(e)) return params @@ -204,7 +217,7 @@ def settings(self, params=None): params['PFMinQ3'] = self.inv.settings.PFMinQ3 params['PFMinQ4'] = self.inv.settings.PFMinQ4 params['VArAct'] = self.inv.settings.VArAct - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -238,7 +251,7 @@ def conn_status(self, params=None): params['EPC_Connected'] = (ecp_conn_bitfield & ECPCONN_CONNECTED) == ECPCONN_CONNECTED else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -272,7 +285,7 @@ def controls_status(self, params=None): params['HFRT'] = (status_bitfield & STACTCTL_HFRT) == STACTCTL_HFRT else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -296,7 +309,7 @@ def connect(self, params=None): params['Conn'] = self.ts.prompt('What is the connect status: True/False') params['WinTms'] = self.ts.prompt('What is the Time Window?') params['RvrtTms'] = self.ts.prompt('What is the Revert Time?') - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -331,7 +344,7 @@ def fixed_pf(self, params=None): else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -363,7 +376,7 @@ def limit_max_power(self, params=None): params['WinTms'] = self.inv.controls.WMaxLimPct_WinTms params['RmpTms'] = self.inv.controls.WMaxLimPct_RmpTms params['RvrtTms'] = self.inv.controls.WMaxLimPct_RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -390,7 +403,7 @@ def volt_var(self, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -413,7 +426,7 @@ def volt_var_curve(self, id, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -453,7 +466,7 @@ def freq_watt(self, params=None): params['RvrtTms'] = self.inv.freq_watt.RvrtTms if self.inv.freq_watt.ActCrv != 0: params['curve'] = self.freq_watt_curve(id=self.inv.freq_watt.ActCrv) - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -498,7 +511,7 @@ def freq_watt_curve(self, id, params=None): params['id'] = id #also store the curve number hz = [] w = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 hz_point = 'Hz%d' % i w_point = 'VAr%d' % i hz.append(getattr(curve, hz_point)) @@ -506,7 +519,7 @@ def freq_watt_curve(self, id, params=None): params['hz'] = hz params['w'] = w - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -533,23 +546,7 @@ def freq_watt_param(self, params=None): try: if params is not None: self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.hfrtc.read() - if self.inv.freq_watt_param.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - if self.inv.freq_watt_param.HysEna == 0: - params['HysEna'] = False - else: - params['HysEna'] = True - params['WGra'] = self.inv.freq_watt_param.WGra - params['HzStr'] = self.inv.freq_watt_param.HzStr - params['HzStop'] = self.inv.freq_watt_param.HzStop - params['HzStopWGra'] = self.inv.freq_watt_param.HzStopWGra - - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -589,7 +586,7 @@ def frt_stay_connected_high(self, params=None): params['RmpTms'] = self.inv.hfrtc.RmpTms params['RvrtTms'] = self.inv.hfrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -629,7 +626,7 @@ def frt_stay_connected_low(self, params=None): params['RmpTms'] = self.inv.lfrtc.RmpTms params['RvrtTms'] = self.inv.lfrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -662,7 +659,7 @@ def reactive_power(self, params=None): params['curve'] = self.ts.prompt('Curve parameters are: ') ''' - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -695,7 +692,7 @@ def active_power(self, params=None): params['curve'] = self.ts.prompt('Curve parameters are: ') ''' - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -748,7 +745,7 @@ def storage(self, params=None): params['InOutWRte_RvrtTms'] = self.ts.prompt('InOutWRte_RvrtTms? ') params['InOutWRte_RmpTms'] = self.ts.prompt('InOutWRte_RmpTms? ') ''' - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params diff --git a/Lib/svpelab/der_pass.py b/Lib/svpelab/der_pass.py index 9275eac..67a61d7 100644 --- a/Lib/svpelab/der_pass.py +++ b/Lib/svpelab/der_pass.py @@ -32,7 +32,7 @@ import os -import der +from . import der manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -270,7 +270,7 @@ def freq_watt(self, params=None): pass else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -302,7 +302,7 @@ def freq_watt_curve(self, id, params=None): else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -332,7 +332,7 @@ def freq_watt_param(self, params=None): else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -454,7 +454,7 @@ def storage(self, params=None): else: params = {} params['ChaState'] = 50 - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params diff --git a/Lib/svpelab/der_sim.py b/Lib/svpelab/der_sim.py index 4cdc4e2..a435e4c 100644 --- a/Lib/svpelab/der_sim.py +++ b/Lib/svpelab/der_sim.py @@ -32,7 +32,7 @@ import os -import der +from . import der sim_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -73,51 +73,12 @@ def info(self): """ try: params = {} - params['Manufacturer'] = self.ts.prompt('Enter Manufacturer: ') - params['Model'] = self.ts.prompt('Enter Model: ') - params['Options'] = self.ts.prompt('Enter Options: ') - params['Version'] = self.ts.prompt('Enter Version: ') - params['SerialNumber'] = self.ts.prompt('Enter Serial Number: ') - except Exception, e: - raise der.DERError(str(e)) - - return params - - def nameplate(self): - """ Get nameplate ratings. - - Params: - WRtg - Active power maximum rating - VARtg - Apparent power maximum rating - VArRtgQ1, VArRtgQ2, VArRtgQ3, VArRtgQ4 - VAr maximum rating for each quadrant - ARtg - Current maximum rating - PFRtgQ1, PFRtgQ2, PFRtgQ3, PFRtgQ4 - Power factor rating for each quadrant - WHRtg - Energy maximum rating - AhrRtg - Amp-hour maximum rating - MaxChaRte - Charge rate maximum rating - MaxDisChaRte - Discharge rate maximum rating - - :return: Dictionary of nameplate ratings. - """ - - try: - params = {} - params['WRtg'] = self.ts.prompt('Enter WRtg: ') - params['VARtg'] = self.ts.prompt('Enter VARtg: ') - params['VArRtgQ1'] = self.ts.prompt('Enter VArRtgQ1: ') - params['VArRtgQ2'] = self.ts.prompt('Enter VArRtgQ2: ') - params['VArRtgQ3'] = self.ts.prompt('Enter VArRtgQ3: ') - params['VArRtgQ4'] = self.ts.prompt('Enter VArRtgQ4: ') - params['ARtg'] = self.ts.prompt('Enter ARtg: ') - params['PFRtgQ1'] = self.ts.prompt('Enter PFRtgQ1: ') - params['PFRtgQ2'] = self.ts.prompt('Enter PFRtgQ2: ') - params['PFRtgQ3'] = self.ts.prompt('Enter PFRtgQ3: ') - params['PFRtgQ4'] = self.ts.prompt('Enter PFRtgQ4: ') - params['WHRtg'] = self.ts.prompt('Enter WHRtg: ') - params['AhrRtg'] = self.ts.prompt('Enter AhrRtg: ') - params['MaxChaRte'] = self.ts.prompt('Enter MaxChaRte: ') - params['MaxDisChaRte'] = self.ts.prompt('Enter MaxDisChaRte: ') - except Exception, e: + params['Manufacturer'] = 'RANDOM SIMULATED' + params['Model'] = 'RANDOM SIMULATED' + params['Options'] = 'RANDOM SIMULATED' + params['Version'] = 'RANDOM SIMULATED' + params['SerialNumber'] = 'RANDOM SIMULATED' + except Exception as e: raise der.DERError(str(e)) return params @@ -131,656 +92,42 @@ def measurements(self): """ try: - params['A'] = self.ts.prompt('Enter A: ') - params['AphA'] = self.ts.prompt('Enter AphA: ') - params['AphB'] = self.ts.prompt('Enter AphB: ') - params['AphC'] = self.ts.prompt('Enter AphC: ') - params['PPVphAB'] = self.ts.prompt('Enter PPVphAB: ') - params['PPVphBC'] = self.ts.prompt('Enter PPVphBC: ') - params['PPVphCA'] = self.ts.prompt('Enter PPVphCA: ') - params['PhVphA'] = self.ts.prompt('Enter PhVphA: ') - params['PhVphB'] = self.ts.prompt('Enter PhVphB: ') - params['PhVphC'] = self.ts.prompt('Enter PhVphC: ') - params['W'] = self.ts.prompt('Enter W: ') - params['Hz'] = self.ts.prompt('Enter Hz: ') - params['VA'] = self.ts.prompt('Enter VA: ') - params['VAr'] = self.ts.prompt('Enter VAr: ') - params['PF'] = self.ts.prompt('Enter PF: ') - params['WH'] = self.ts.prompt('Enter WH: ') - params['DCA'] = self.ts.prompt('Enter DCA: ') - params['DCV'] = self.ts.prompt('Enter DCV: ') - params['DCW'] = self.ts.prompt('Enter DCW: ') - params['TmpCab'] = self.ts.prompt('Enter TmpCab: ') - params['TmpSnk'] = self.ts.prompt('Enter TmpSnk: ') - params['TmpTrns'] = self.ts.prompt('Enter TmpTrns: ') - params['TmpOt'] = self.ts.prompt('Enter TmpOt: ') - params['St'] = self.ts.prompt('Enter St: ') - params['StVnd'] = self.ts.prompt('Enter StVnd: ') - params['Evt1'] = self.ts.prompt('Enter Evt1: ') - params['Evt2'] = self.ts.prompt('Enter Evt2: ') - params['EvtVnd1'] = self.ts.prompt('Enter EvtVnd1: ') - params['EvtVnd2'] = self.ts.prompt('Enter EvtVnd2: ') - params['EvtVnd3'] = self.ts.prompt('Enter EvtVnd3: ') - params['EvtVnd4'] = self.ts.prompt('Enter EvtVnd4: ') - except Exception, e: - raise der.DERError(str(e)) - - return params - - def settings(self, params=None): - """ - Get/set capability settings. - - Params: - WMax - Active power maximum - VRef - Reference voltage - VRefOfs - Reference voltage offset - VMax - Voltage maximum - VMin - Voltage minimum - VAMax - Apparent power maximum - VArMaxQ1, VArMaxQ2, VArMaxQ3, VArMaxQ4 - VAr maximum for each quadrant - WGra - Default active power ramp rate - PFMinQ1, PFMinQ2, PFMinQ3, PFMinQ4 - VArAct - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for connect. - """ - try: + a = 123 params = {} - params['WMax'] = self.inv.settings['WMax'] - params['VRef'] = self.inv.settings.VRef - params['VRefOfs'] = self.inv.settings.VRefOfs - params['VMax'] = self.inv.settings.VMax - params['VMin'] = self.inv.settings.VMin - params['VAMax'] = self.inv.settings.VAMax - params['VArMaxQ1'] = self.inv.settings.VArMaxQ1 - params['VArMaxQ2'] = self.inv.settings.VArMaxQ2 - params['VArMaxQ3'] = self.inv.settings.VArMaxQ3 - params['VArMaxQ4'] = self.inv.settings.VArMaxQ4 - params['WGra'] = self.inv.settings.WGra - params['PFMinQ1'] = self.inv.settings.PFMinQ1 - params['PFMinQ2'] = self.inv.settings.PFMinQ2 - params['PFMinQ3'] = self.inv.settings.PFMinQ3 - params['PFMinQ4'] = self.inv.settings.PFMinQ4 - params['VArAct'] = self.inv.settings.VArAct - except Exception, e: - raise der.DERError(str(e)) - - return params - - - def conn_status(self, params=None): - """ Get status of controls (binary True if active). - :return: Dictionary of active controls. - """ - if self.inv is None: - raise der.DERError('DER not initialized') - - try: - self.inv.status.read() - pv_conn_bitfield = self.inv.status.PVConn - stor_conn_bitfield = self.inv.status.StorConn - ecp_conn_bitfield = self.inv.status.ECPConn - - params = {} - if pv_conn_bitfield is not None: - params['PV_Connected'] = (pv_conn_bitfield & PVCONN_CONNECTED) == PVCONN_CONNECTED - params['PV_Available'] = (pv_conn_bitfield & PVCONN_AVAILABLE) == PVCONN_AVAILABLE - params['PV_Operating'] = (pv_conn_bitfield & PVCONN_OPERATING) == PVCONN_OPERATING - params['PV_Test'] = (pv_conn_bitfield & PVCONN_TEST) == PVCONN_TEST - elif stor_conn_bitfield is not None: - params['Storage_Connected'] = (stor_conn_bitfield & STORCONN_CONNECTED) == STORCONN_CONNECTED - params['Storage_Available'] = (stor_conn_bitfield & STORCONN_AVAILABLE) == STORCONN_AVAILABLE - params['Storage_Operating'] = (stor_conn_bitfield & STORCONN_OPERATING) == STORCONN_OPERATING - params['Storage_Test'] = (stor_conn_bitfield & STORCONN_TEST) == STORCONN_TEST - elif ecp_conn_bitfield is not None: - params['EPC_Connected'] = (ecp_conn_bitfield & ECPCONN_CONNECTED) == ECPCONN_CONNECTED - else: - params = {} - except Exception, e: - raise der.DERError(str(e)) - - return params - - - def controls_status(self, params=None): - """ Get status of controls (binary True if active). - :return: Dictionary of active controls. - """ - if self.inv is None: - raise der.DERError('DER not initialized') - - try: - self.inv.status.read() - status_bitfield = self.inv.status.StActCtl - params = {} - if status_bitfield is not None: - params['Fixed_W'] = (status_bitfield & STACTCTL_FIXED_W) == STACTCTL_FIXED_W - params['Fixed_Var'] = (status_bitfield & STACTCTL_FIXED_VAR) == STACTCTL_FIXED_VAR - params['Fixed_PF'] = (status_bitfield & STACTCTL_FIXED_PF) == STACTCTL_FIXED_PF - params['Volt_Var'] = (status_bitfield & STACTCTL_VOLT_VAR) == STACTCTL_VOLT_VAR - params['Freq_Watt_Param'] = (status_bitfield & STACTCTL_FREQ_WATT_PARAM) == STACTCTL_FREQ_WATT_PARAM - params['Freq_Watt_Curve'] = (status_bitfield & STACTCTL_FREQ_WATT_CURVE) == STACTCTL_FREQ_WATT_CURVE - params['Dyn_Reactive_Power'] = (status_bitfield & STACTCTL_DYN_REACTIVE_POWER) == STACTCTL_DYN_REACTIVE_POWER - params['LVRT'] = (status_bitfield & STACTCTL_LVRT) == STACTCTL_LVRT - params['HVRT'] = (status_bitfield & STACTCTL_HVRT) == STACTCTL_HVRT - params['Watt_PF'] = (status_bitfield & STACTCTL_WATT_PF) == STACTCTL_WATT_PF - params['Volt_Watt'] = (status_bitfield & STACTCTL_VOLT_WATT) == STACTCTL_VOLT_WATT - params['Scheduled'] = (status_bitfield & STACTCTL_SCHEDULED) == STACTCTL_SCHEDULED - params['LFRT'] = (status_bitfield & STACTCTL_LFRT) == STACTCTL_LFRT - params['HFRT'] = (status_bitfield & STACTCTL_HFRT) == STACTCTL_HFRT - else: - params = {} - except Exception, e: - raise der.DERError(str(e)) - - return params - - def connect(self, params=None): - """ Get/set connect/disconnect function settings. - - Params: - Conn - Connected (True/False) - WinTms - Randomized start time delay in seconds - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for connect. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - params['Conn'] = self.ts.prompt('What is the connect status: True/False') - params['WinTms'] = self.ts.prompt('What is the Time Window?') - params['RvrtTms'] = self.ts.prompt('What is the Revert Time?') - except Exception, e: - raise der.DERError(str(e)) - - return params - - def fixed_pf(self, params=None): - """ Get/set fixed power factor control settings. - - Params: - Ena - Enabled (True/False) - PF - Power Factor set point - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for fixed factor. - """ - try: - if params is not None: - cmd = 'Unknown request' - enable = params.get('Ena') - pf = params.get('PF') - if pf is not None: - cmd = 'Setting DER power factor to %s' % (str(pf)) - if enable is not None and enable is True: - cmd += ' and enabling fixed power factor mode' - elif enable is not None: - if enable is True: - cmd = 'Enabling DER fixed power factor mode' - else: - cmd = 'Disabling DER fixed power factor mode' - self.ts.log(cmd) - else: - params = {} - - except Exception, e: - raise der.DERError(str(e)) - - return params - - def limit_max_power(self, params=None): - """ Get/set max active power control settings. - - Params: - Ena - Enabled (True/False) - WMaxPct - Active power maximum as percentage of WMax - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for limit max power. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.controls.read() - if self.inv.controls.WMaxLim_Ena == 0: - params['Ena'] = False - else: - params['Ena'] = True - params['WMaxPct'] = self.inv.controls.WMaxLimPct - params['WinTms'] = self.inv.controls.WMaxLimPct_WinTms - params['RmpTms'] = self.inv.controls.WMaxLimPct_RmpTms - params['RvrtTms'] = self.inv.controls.WMaxLimPct_RvrtTms - except Exception, e: - raise der.DERError(str(e)) - - return params - - def volt_var(self, params=None): - """ Get/set volt/var control - - Params: - Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for volt/var control. - """ - - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.volt_var.read() - if self.inv.volt_var.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - params['ActCrv'] = self.inv.volt_var.ActCrv - params['NCrv'] = self.inv.volt_var.NCrv - params['NPt'] = self.inv.volt_var.NPt - params['WinTms'] = self.inv.volt_var.WinTms - params['RmpTms'] = self.inv.volt_var.RmpTms - params['RvrtTms'] = self.inv.volt_var.RvrtTms - if self.inv.volt_var.ActCrv != 0: - params['curve'] = self.volt_var_curve(id=self.inv.volt_var.ActCrv) - except Exception, e: - raise der.DERError(str(e)) - - return params - - def volt_var_curve(self, id, params=None): - """ Get/set volt/var curve - v [] - List of voltage curve points - var [] - List of var curve points based on DeptRef - DeptRef - Dependent reference type: 'VAR_MAX_PCT', 'VAR_AVAL_PCT', 'VA_MAX_PCT', 'W_MAX_PCT' - RmpTms - Ramp timer - RmpDecTmm - Ramp decrement timer - RmpIncTmm - Ramp increment timer - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for volt/var control. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - act_pt = curve.ActPt - dept_ref = volt_var_dept_ref.get(curve.DeptRef) - if dept_ref is None: - raise der.DERError('DeptRef out of range: %s' % (dept_ref)) - params['DeptRef'] = dept_ref - params['RmpTms'] = curve.RmpTms - params['RmpDecTmm'] = curve.RmpDecTmm - params['RmpIncTmm'] = curve.RmpIncTmm - params['id'] = id #also store the curve number - - v = [] - var = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 - v_point = 'V%d' % i - var_point = 'VAr%d' % i - v.append(getattr(curve, v_point)) - var.append(getattr(curve, var_point)) - params['v'] = v - params['var'] = var - - except Exception, e: - raise der.DERError(str(e)) - - return params - - - def freq_watt(self, params=None): - """ Get/set freq/watt control - - Params: - Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for freq/watt control. - """ - - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.freq_watt.read() - if self.inv.freq_watt.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - params['ActCrv'] = self.inv.freq_watt.ActCrv - params['NCrv'] = self.inv.freq_watt.NCrv - params['NPt'] = self.inv.freq_watt.NPt - params['WinTms'] = self.inv.freq_watt.WinTms - params['RmpTms'] = self.inv.freq_watt.RmpTms - params['RvrtTms'] = self.inv.freq_watt.RvrtTms - if self.inv.freq_watt.ActCrv != 0: - params['curve'] = self.freq_watt_curve(id=self.inv.freq_watt.ActCrv) - except Exception, e: - raise der.DERError(str(e)) - - return params - - def freq_watt_curve(self, id, params=None): - """ Get/set volt/var curve - hz [] - List of frequency curve points - w [] - List of power curve points - CrvNam - Optional description for curve. (Max 16 chars) - RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). - RmpDecTmm - Ramp decrement timer - RmpIncTmm - Ramp increment timer - RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of - snap shot function. - SnptW - 1=enable snapshot/capture mode - WRef - Reference active power (default = WMax). - WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining - power output. - WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. - ReadOnly - 0 = READWRITE, 1 = READONLY - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for freq/watt curve. - """ - - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - act_pt = curve.ActPt - params['CrvNam'] = curve.CrvNam - params['RmpPT1Tms'] = curve.RmpPT1Tms - params['RmpDecTmm'] = curve.RmpDecTmm - params['RmpIncTmm'] = curve.RmpIncTmm - params['RmpRsUp'] = curve.RmpRsUp - params['SnptW'] = curve.SnptW - params['WRef'] = curve.WRef - params['WRefStrHz'] = curve.WRefStrHz - params['WRefStopHz'] = curve.WRefStopHz - params['ReadOnly'] = curve.ReadOnly - params['id'] = id #also store the curve number - hz = [] - w = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 - hz_point = 'Hz%d' % i - w_point = 'VAr%d' % i - hz.append(getattr(curve, hz_point)) - w.append(getattr(curve, w_point)) - params['hz'] = hz - params['w'] = w - - except Exception, e: - raise der.DERError(str(e)) - - return params - - def freq_watt_param(self, params=None): - """ Get/set frequency-watt with parameters - - Params: - Ena - Enabled (True/False) - HysEna - Enable hysterisis (True/False) - WGra - The slope of the reduction in the maximum allowed watts output as a function of frequency. - HzStr - The frequency deviation from nominal frequency (ECPNomHz) at which a snapshot of the instantaneous - power output is taken to act as the CAPPED power level (PM) and above which reduction in power - output occurs. - HzStop - The frequency deviation from nominal frequency (ECPNomHz) at which curtailed power output may - return to normal and the cap on the power level value is removed. - HzStopWGra - The maximum time-based rate of change at which power output returns to normal after having - been capped by an over frequency event. - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for frequency-watt with parameters control. - """ - - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.hfrtc.read() - if self.inv.freq_watt_param.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - if self.inv.freq_watt_param.HysEna == 0: - params['HysEna'] = False - else: - params['HysEna'] = True - params['WGra'] = self.inv.freq_watt_param.WGra - params['HzStr'] = self.inv.freq_watt_param.HzStr - params['HzStop'] = self.inv.freq_watt_param.HzStop - params['HzStopWGra'] = self.inv.freq_watt_param.HzStopWGra - - except Exception, e: - raise der.DERError(str(e)) - - return params - - def frt_stay_connected_high(self, params=None): - """ Get/set high frequency ride through (must stay connected curve) - - Params: - Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - Tms# - Time point in the curve - Hz# - Frequency point in the curve - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. - """ - - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.hfrtc.read() - if self.inv.hfrtc.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - params['ActCrv'] = self.inv.hfrtc.ActCrv - params['NCrv'] = self.inv.hfrtc.NCrv - params['NPt'] = self.inv.hfrtc.NPt - params['WinTms'] = self.inv.hfrtc.WinTms - params['RmpTms'] = self.inv.hfrtc.RmpTms - params['RvrtTms'] = self.inv.hfrtc.RvrtTms - - except Exception, e: - raise der.DERError(str(e)) - - return params - - - def frt_stay_connected_low(self, params=None): - """ Get/set high frequency ride through (must stay connected curve) - - Params: - Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - Tms# - Time point in the curve - Hz# - Frequency point in the curve - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - self.inv.lfrtc.read() - if self.inv.lfrtc.ModEna == 0: - params['Ena'] = False - else: - params['Ena'] = True - params['ActCrv'] = self.inv.lfrtc.ActCrv - params['NCrv'] = self.inv.lfrtc.NCrv - params['NPt'] = self.inv.lfrtc.NPt - params['WinTms'] = self.inv.lfrtc.WinTms - params['RmpTms'] = self.inv.lfrtc.RmpTms - params['RvrtTms'] = self.inv.lfrtc.RvrtTms - - except Exception, e: - raise der.DERError(str(e)) - - return params - - def reactive_power(self, params=None): - """ Set the reactive power - - Params: - Ena - Enabled (True/False) - Q - Reactive power as %Qmax (positive is overexcited, negative is underexcited) - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for Q control. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - params['Q'] = 200 - ''' - params['Ena'] = self.ts.prompt('Reactive power is enabled (True/False)? ') - params['Q'] = self.ts.prompt('Reactive power is: ') - params['WinTms'] = self.ts.prompt('Time Window is: ') - params['RmpTms'] = self.ts.prompt('Ramp Time is: ') - params['RvrtTms'] = self.ts.prompt('Revert Time is: ') - params['curve'] = self.ts.prompt('Curve parameters are: ') - ''' - - except Exception, e: - raise der.DERError(str(e)) - - return params - - def active_power(self, params=None): - """ Get/set active power of EUT - - Params: - Ena - Enabled (True/False) - P - Active power in %Wmax (positive is exporting (discharging), negative is importing (charging) power) - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - params['P'] = 2000 - ''' - params['Ena'] = self.ts.prompt('Active power is enabled (True/False)? ') - params['P'] = self.ts.prompt('Active power is: ') - params['WinTms'] = self.ts.prompt('Time Window is: ') - params['RmpTms'] = self.ts.prompt('Ramp Time is: ') - params['RvrtTms'] = self.ts.prompt('Revert Time is: ') - params['curve'] = self.ts.prompt('Curve parameters are: ') - ''' - - except Exception, e: - raise der.DERError(str(e)) - - return params - - - def storage(self, params=None): - """ Get/set storage parameters - - Params: - WChaMax - Setpoint for maximum charge. - WChaGra - Setpoint for maximum charging rate. Default is MaxChaRte. - WDisChaGra - Setpoint for maximum discharge rate. Default is MaxDisChaRte. - StorCtl_Mod - Activate hold/discharge/charge storage control mode. Bitfield value. - VAChaMax - Setpoint for maximum charging VA. - MinRsvPct - Setpoint for minimum reserve for storage as a percentage of the nominal maximum storage. - ChaState (R) - Currently available energy as a percent of the capacity rating. - StorAval (R) - State of charge (ChaState) minus storage reserve (MinRsvPct) times capacity rating (AhrRtg). - InBatV (R) - Internal battery voltage. - ChaSt (R) - Charge status of storage device. Enumerated value. - OutWRte - Percent of max discharge rate. - InWRte - Percent of max charging rate. - InOutWRte_WinTms - Time window for charge/discharge rate change. - InOutWRte_RvrtTms - Timeout period for charge/discharge rate. - InOutWRte_RmpTms - Ramp time for moving from current setpoint to new setpoint. - - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. - """ - try: - if params is not None: - self.ts.confirm('Set the following parameters %s' % params) - else: - params = {} - params['ChaState'] = 50 - ''' - params['WChaMax'] = self.ts.prompt('WChaMax? ') - params['WChaGra'] = self.ts.prompt('WChaGra? ') - params['WDisChaGra'] = self.ts.prompt('WDisChaGra? ') - params['StorCtl_Mod'] = self.ts.prompt('StorCtl_Mod? ') - params['VAChaMax'] = self.ts.prompt('VAChaMax? ') - params['MinRsvPct'] = self.ts.prompt('MinRsvPct? ') - params['ChaState'] = self.ts.prompt('ChaState? ') - params['StorAval'] = self.ts.prompt('StorAval? ') - params['InBatV'] = self.ts.prompt('InBatV? ') - params['ChaSt'] = self.ts.prompt('ChaSt? ') - params['OutWRte'] = self.ts.prompt('OutWRte? ') - params['InWRte'] = self.ts.prompt('InWRte? ') - params['InOutWRte_WinTms'] = sself.ts.prompt('InOutWRte_WinTms? ') - params['InOutWRte_RvrtTms'] = self.ts.prompt('InOutWRte_RvrtTms? ') - params['InOutWRte_RmpTms'] = self.ts.prompt('InOutWRte_RmpTms? ') - ''' - except Exception, e: + params['A'] = a + params['AphA'] = a + params['AphB'] = a + params['AphC'] = a + params['PPVphAB'] = a + params['PPVphBC'] = a + params['PPVphCA'] = a + params['PhVphA'] = a + params['PhVphB'] = a + params['PhVphC'] = a + params['W'] = a + params['Hz'] = a + params['VA'] = a + params['VAr'] = a + params['PF'] = a + params['WH'] = a + params['DCA'] = a + params['DCV'] = a + params['DCW'] = a + params['TmpCab'] = a + params['TmpSnk'] = a + params['TmpTrns'] = a + params['TmpOt'] = a + params['St'] = a + params['StVnd'] = a + params['Evt1'] = a + params['Evt2'] = a + params['EvtVnd1'] = a + params['EvtVnd2'] = a + params['EvtVnd3'] = a + params['EvtVnd4'] = a + + except Exception as e: raise der.DERError(str(e)) return params diff --git a/Lib/svpelab/der_sma.py b/Lib/svpelab/der_sma.py index 34ee20d..c638e04 100644 --- a/Lib/svpelab/der_sma.py +++ b/Lib/svpelab/der_sma.py @@ -29,21 +29,26 @@ Questions can be directed to support@sunspec.org """ - -import os -import der -import script -import sunspec.core.modbus.client as client -import sunspec.core.util as util +try: + import os + from . import der + import script + import sunspec.core.modbus.client as client + import sunspec.core.util as util +except Exception as e: + print(('Import problem in der_sma.py: %s' % e)) + raise der.DERError('Import problem in der_sma.py: %s' % e) sma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], 'mode': 'SMA' } + def der_info(): return sma_info + def params(info, group_name): gname = lambda name: group_name + '.' + name pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name @@ -54,11 +59,14 @@ def params(info, group_name): # TCP parameters info.param(pname('ipaddr'), label='IP Address', default='192.168.0.170') info.param(pname('ipport'), label='IP Port', default=502) - info.param(pname('slave_id'), label='Slave Id', default=1) + info.param(pname('slave_id'), label='Slave Id', default=3) + info.param(pname('firmware'), label='Firmware Number', default='02.02.30.R', + values=['02.02.30.R', '02.83.03.R', '02.84.01.R', '02.63.33.S']) info.param(pname('confgridguard'), label='Configure Grid Guard', default='False', values=['True', 'False']) info.param(pname('gridguard'), label='Grid Guard Number', default=12345678, active=pname('confgridguard'), active_value='True') + GROUP_NAME = 'sma' @@ -67,6 +75,8 @@ class DER(der.DER): def __init__(self, ts, group_name): der.DER.__init__(self, ts, group_name) self.inv = None + self.ts = ts + self.firmware = self.param_value('firmware') def param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) @@ -84,7 +94,11 @@ def open(self): config_grid_guard = self.param_value('confgridguard') if config_grid_guard == 'True': gg = int(self.param_value('gridguard')) - self.gridguard(gg) + gg_success = self.gridguard(gg) + if gg_success: + self.ts.log('Grid Guard Code Accepted.') + else: + self.ts.log_warning('Grid Guard Code Not Accepted!') def gridguard(self, new_gg=None): """ Read/Write SMA Grid Guard. @@ -95,18 +109,37 @@ def gridguard(self, new_gg=None): :return: 0 or 1 for GG off or on. """ + gg_reg = {'02.02.30.R': 43090, '02.84.01.R': 43090, + '02.83.03.R': 43090, '02.63.33.S': 43090} + + data = self.inv.read(gg_reg[self.firmware], 2) + gg = util.data_to_u32(data) + if int(gg) == 1: + self.ts.log("Grid Guard is already set") + return True + if new_gg is not None: - print('Writing new Grid Guard: %d' % new_gg) - self.inv.write(43090, util.u32_to_data(int(new_gg))) + if self.ts is not None: + self.ts.log('Writing new Grid Guard: %d' % new_gg) + else: + print(('Writing new Grid Guard: %d' % new_gg)) + self.inv.write(gg_reg[self.firmware], util.u32_to_data(int(new_gg))) + self.ts.sleep(1) - data = self.inv.read(43090, 2) + data = self.inv.read(gg_reg[self.firmware], 2) gg = util.data_to_u32(data) if gg == 0: - print('Grid guard was not enabled') + if self.ts is not None: + self.ts.log('Grid guard was not enabled') + else: + print('Grid guard was not enabled') return False else: - print('Grid guard was enabled') + if self.ts is not None: + self.ts.log('Grid guard was enabled') + else: + print('Grid guard was enabled') return True def close(self): @@ -127,7 +160,34 @@ def info(self): :return: Dictionary of information elements. """ - der.DERError('Unimplemented function: info') + model_reg = {'02.02.30.R': 30053, '02.84.01.R': 30053, '02.83.03.R': 30053, '02.63.33.S': 30053} + serial_reg = {'02.02.30.R': 30057, '02.84.01.R': 30057, '02.83.03.R': 30057, '02.63.33.S': 30057} + # Software Package + version_reg = {'02.02.30.R': 30059, '02.84.01.R': 30059, '02.83.03.R': 30059, '02.63.33.S': 30059} + + try: + params = {} + params['Manufacturer'] = 'SMA' + model_code = util.data_to_u32(self.inv.read(model_reg[self.firmware], 2)) + if model_code == 9194: + params['Model'] = 'STP 12000TL-US-10' + if model_code == 9195: + params['Model'] = 'STP 15000TL-US-10' + if model_code == 9196: + params['Model'] = 'STP 20000TL-US-10' + if model_code == 9197: + params['Model'] = 'STP 24000TL-US-10' + if model_code == 9310: + params['Model'] = 'STP 30000TL-US-10' + + params['Version'] = util.data_to_u32(self.inv.read(version_reg[self.firmware], 2)) + params['Options'] = '' + params['SerialNumber'] = util.data_to_u32(self.inv.read(serial_reg[self.firmware], 2)) + + except Exception as e: + raise der.DERError('Info Error: %s' % e) + + return params def nameplate(self): """ Get nameplate ratings. @@ -146,17 +206,79 @@ def nameplate(self): :return: Dictionary of nameplate ratings. """ - der.DERError('Unimplemented function: nameplate') + # raise der.DERError('Unimplemented function: nameplate') + return {} def measurements(self): """ Get measurement data. - Params: + Params: None :return: Dictionary of measurement data. """ - der.DERError('Unimplemented function: measurements') + a_reg = {'02.02.30.R': 30795, '02.84.01.R': 30795, '02.83.03.R': 30795, '02.63.33.S': 30795} + v1_reg = {'02.02.30.R': 30783, '02.84.01.R': 30783, '02.83.03.R': 30783, '02.63.33.S': 30783} + v2_reg = {'02.02.30.R': 30785, '02.84.01.R': 30785, '02.83.03.R': 30785, '02.63.33.S': 30785} + v3_reg = {'02.02.30.R': 30787, '02.84.01.R': 30787, '02.83.03.R': 30787, '02.63.33.S': 30787} + + w_reg = {'02.02.30.R': 30775, '02.84.01.R': 30775, '02.83.03.R': 30775, '02.63.33.S': 30775} + f_reg = {'02.02.30.R': 30803, '02.84.01.R': 30803, '02.83.03.R': 30803, '02.63.33.S': 30803} + va_reg = {'02.02.30.R': 30813, '02.84.01.R': 30813, '02.83.03.R': 30813, '02.63.33.S': 30813} + var_reg = {'02.02.30.R': 30805, '02.84.01.R': 30805, '02.83.03.R': 30805, '02.63.33.S': 30805} + + dc_i_reg = {'02.02.30.R': 30769, '02.84.01.R': 30769, '02.83.03.R': 30769, '02.63.33.S': 30769} + dc_i2_reg = {'02.02.30.R': None, '02.84.01.R': 30957, '02.83.03.R': 30957, '02.63.33.S': None} + dc_v_reg = {'02.02.30.R': 30771, '02.84.01.R': 30771, '02.83.03.R': 30771, '02.63.33.S': 30771} + dc_v2_reg = {'02.02.30.R': None, '02.84.01.R': 30959, '02.83.03.R': 30959, '02.63.33.S': None} + dc_p_reg = {'02.02.30.R': 30773, '02.84.01.R': 30773, '02.83.03.R': 30773, '02.63.33.S': 30773} + dc_p2_reg = {'02.02.30.R': None, '02.84.01.R': 30961, '02.83.03.R': 30961, '02.63.33.S': None} + + pf_reg = {'02.02.30.R': 30821, '02.84.01.R': 30949, '02.83.03.R': 30949, '02.63.33.S': 30821} + eei_pf_reg = {'02.02.30.R': 31221, '02.84.01.R': 31221, '02.83.03.R': 31221, '02.63.33.S': 31221} # EEI + + try: + params = {} + params['A'] = util.data_to_u32(self.inv.read(a_reg[self.firmware], 2))/1000. + params['AphA'] = params['A']/3. + params['AphB'] = params['A']/3. + params['AphC'] = params['A']/3. + params['PPVphAB'] = None + params['PPVphBC'] = None + params['PPVphCA'] = None + params['PhVphA'] = util.data_to_u32(self.inv.read(v1_reg[self.firmware], 2))/100. + params['PhVphB'] = util.data_to_u32(self.inv.read(v2_reg[self.firmware], 2))/100. + params['PhVphC'] = util.data_to_u32(self.inv.read(v3_reg[self.firmware], 2))/100. + params['W'] = float(util.data_to_s32(self.inv.read(w_reg[self.firmware], 2))) + params['Hz'] = util.data_to_u32(self.inv.read(f_reg[self.firmware], 2))/100. + params['VA'] = float(util.data_to_s32(self.inv.read(va_reg[self.firmware], 2))) + params['VAr'] = float(util.data_to_s32(self.inv.read(var_reg[self.firmware], 2))) + # pf = util.data_to_u32(self.inv.read(pf_reg[self.firmware], 2)) / 1000. + params['PF'] = util.data_to_s32(self.inv.read(eei_pf_reg[self.firmware], 2)) / 1000. + params['WH'] = None + params['DCA'] = util.data_to_s32(self.inv.read(dc_i_reg[self.firmware], 2))/1000. + if dc_i2_reg[self.firmware] is not None: + params['DCA'] += util.data_to_s32(self.inv.read(dc_i2_reg[self.firmware], 2)) / 1000. + params['DCV'] = util.data_to_s32(self.inv.read(dc_v_reg[self.firmware], 2)) / 100. + params['DCW'] = float(util.data_to_s32(self.inv.read(dc_p_reg[self.firmware], 2))) + if dc_p2_reg[self.firmware] is not None: + params['DCW'] += float(util.data_to_s32(self.inv.read(dc_p2_reg[self.firmware], 2))) + params['TmpCab'] = None + params['TmpSnk'] = None + params['TmpTrns'] = None + params['TmpOt'] = None + params['St'] = None + params['StVnd'] = None + params['Evt1'] = None + params['Evt2'] = None + params['EvtVnd1'] = None + params['EvtVnd2'] = None + params['EvtVnd3'] = None + params['EvtVnd4'] = None + except Exception as e: + raise der.DERError(str(e)) + + return params def settings(self, params=None): """ @@ -178,13 +300,70 @@ def settings(self, params=None): :return: Dictionary of active settings for connect. """ - der.DERError('Unimplemented function: settings') + wlim_reg = {'02.02.30.R': 31405, '02.84.01.R': 31405, '02.83.03.R': 31405, '02.63.33.S': 31405} + v_ref_reg = {'02.02.30.R': 40472, '02.84.01.R': 40472, '02.83.03.R': 40472, '02.63.33.S': 40472} # PV sys cntrl + v_ref_ofs_reg = {'02.02.30.R': 40474, '02.84.01.R': 40474, '02.83.03.R': 40474, '02.63.33.S': 40474} # PV sys cntrl + v_max_reg = {'02.02.30.R': 41125, '02.84.01.R': 41125, '02.83.03.R': 41125, '02.63.33.S': 41125} + v_min_reg = {'02.02.30.R': 41123, '02.84.01.R': 41123, '02.83.03.R': 41123, '02.63.33.S': 41123} + va_max_reg = {'02.02.30.R': None, '02.84.01.R': 40185, '02.83.03.R': 40185, '02.63.33.S': None} + var_max_reg = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': None} + wgra_reg = {'02.02.30.R': 40234, '02.84.01.R': 40234, '02.83.03.R': 40234, '02.63.33.S': 40234} + pfmin_reg = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': None} + varact_reg = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': None} + + try: + params = {} + params['WMax'] = util.data_to_u32(self.inv.read(wlim_reg[self.firmware], 2)) / 1000. + params['VRef'] = util.data_to_u32(self.inv.read(v_ref_reg[self.firmware], 2)) # V + params['VRefOfs'] = util.data_to_s32(self.inv.read(v_ref_ofs_reg[self.firmware], 2)) # V + params['VMax'] = util.data_to_u32(self.inv.read(v_max_reg[self.firmware], 2)) / 100. # V, for reconnect + params['VMin'] = util.data_to_u32(self.inv.read(v_min_reg[self.firmware], 2)) / 100. # V, for reconnect + params['VAMax'] = util.data_to_u32(self.inv.read(va_max_reg[self.firmware], 2)) + params['VArMaxQ1'] = None + params['VArMaxQ2'] = None + params['VArMaxQ3'] = None + params['VArMaxQ4'] = None + params['WGra'] = util.data_to_s32(self.inv.read(wgra_reg[self.firmware], 2)) # V + params['PFMinQ1'] = None + params['PFMinQ2'] = None + params['PFMinQ3'] = None + params['PFMinQ4'] = None + params['VArAct'] = None + except Exception as e: + raise der.DERError(str(e)) + + return params def conn_status(self, params=None): """ Get status of controls (binary True if active). :return: Dictionary of active controls. """ - der.DERError('Unimplemented function: cons_status') + + # SMA Operating status: + # 295 = MPP + # 1467 = Start + # 381 = Stop + # 2119 = Derating + # 1469 = Shut down + # 1392 = Fault + # 1480 = Waiting for utilities company + # 1393 = Waiting for PV voltage + # 443 = Constant voltage + # 1855 = Stand-alone operation + + op_reg = {'02.02.30.R': 40029, '02.84.01.R': 40029, '02.83.03.R': 40029, '02.63.33.S': 40029} + + try: + params = {} + status_enum = util.data_to_u32(self.inv.read(op_reg[self.firmware], 2)) + if status_enum == 295: + params['Status'] = 'Connected' + else: + params['Status'] = 'Not Connected' + except Exception as e: + raise der.DERError(str(e)) + + return params def controls_status(self, params=None): """ Get status of controls (binary True if active). @@ -204,7 +383,37 @@ def connect(self, params=None): :return: Dictionary of active settings for connect. """ - der.DERError('Unimplemented function: connect') + if self.inv is None: + raise der.DERError('DER not initialized') + + # SMA Fast shut-down: + # 381 = Stop + # 1467 = Start + # 1749 = Full stop + + conn_reg = {'02.02.30.R': 40018, '02.84.01.R': 40018, '02.83.03.R': 40018, '02.63.33.S': 40018} + + try: + if params is not None: + conn = params.get('Conn') + if conn is not None: + if conn is True: + reg = 1467 # start + else: + reg = 1749 # Full stop (AC and DC side) + # reg = 381 # Stop (AC side) + self.inv.write(conn_reg[self.firmware], util.u32_to_data(int(reg))) + else: + params = {} + reg = self.inv.read(conn_reg[self.firmware], 2) + if util.data_to_u32(reg) == 1467: + params['Conn'] = True + else: + params['Conn'] = False + except Exception as e: + raise der.DERError(str(e)) + + return params def fixed_pf(self, params=None): """ Get/set fixed power factor control settings. @@ -222,45 +431,65 @@ def fixed_pf(self, params=None): if self.inv is None: der.DERError('DER not initialized') + excitation_reg = {'02.02.30.R': 40208, '02.84.01.R': 40208, '02.83.03.R': 40025, '02.63.33.S': 40025} + pf_s32_reg = {'02.02.30.R': None, '02.84.01.R': 40206, '02.83.03.R': 40025, '02.63.33.S': 40025} + pf_u16_reg = {'02.02.30.R': 40206, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': None} + pf_eei_reg = {'02.02.30.R': None, '02.84.01.R': 40999, '02.83.03.R': 40999, '02.63.33.S': None} + # 1075 = cos phi, specified by PV system control + # 1074 = cos phi, direct specific. + q_mode_val = {'02.02.30.R': 1074, '02.84.01.R': 1074, '02.83.03.R': 1074, '02.63.33.S': 1074} + q_mode_reg = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + try: if params is not None: pf = params.get('PF') - # Configuring Grid Management Services Control with Sunny Explorer - # Cos phi (if supported by the device): Read Modbus register 30825. If the value 1075 can be read - # from this register, the power factor is specified via system control. - if pf is not None: + # write excitation register if pf > 0: - reg = 1042 # leading + reg = 1042 # Lagging, Underexcited else: - reg = 1041 # lagging - self.inv.write(40025, util.u32_to_data(int(reg))) + reg = 1041 # Leading, Overexcited + self.inv.write(excitation_reg[self.firmware], util.u32_to_data(int(reg))) - reg = int(abs(round(pf, 4) * 10000)) - self.inv.write(40024, util.u16_to_data(int(reg))) + # write pf value register + if isinstance(pf_s32_reg[self.firmware], int): + self.inv.write(pf_s32_reg[self.firmware], util.s32_to_data(int(abs(pf)*100))) + else: + reg = int(abs(round(pf, 4) * 10000)) + self.inv.write(pf_u16_reg[self.firmware], util.u16_to_data(int(reg))) ena = params.get('Ena') if ena is not None: + # configure the reactive power mode to PF if ena is True: - reg = 1075 # 1075 = cos phi, specified by PV system control - # reg = 1074 # 1075 = cos phi, direct specific. + reg = q_mode_val[self.firmware] # cos phi else: - reg = 303 - if reg != util.data_to_u32(self.inv.read(40200, 2)): - self.inv.write(40200, util.u32_to_data(int(reg))) + reg = 303 # off + self.inv.write(q_mode_reg[self.firmware], util.u32_to_data(int(reg))) else: params = {} - reg = self.inv.read(40200, 2) - if util.data_to_u32(reg) == 1075: + reg = self.inv.read(q_mode_reg[self.firmware], 2) + if util.data_to_u32(reg) == q_mode_val[self.firmware]: params['Ena'] = True else: params['Ena'] = False - pf = None - params['PF'] = pf - except Exception, e: - der.DERError(str(e)) + + # Read back option: Operating mode of stat.V stab., stat.V stab. config.: + # q_mode_meas = util.data_to_u32(self.inv.read(30825, 2)) + # if q_mode_meas == q_mode_val[self.firmware]: + # self.ts.log_debug('PF mode is enabled') + + if isinstance(pf_s32_reg[self.firmware], int): # s32 + params['PF'] = float(util.data_to_s32(self.inv.read(pf_s32_reg[self.firmware], 2)))/100.0 + # params['PF'] = float(util.data_to_s32(self.inv.read(pf_eei_reg[self.firmware], 2)))/10000.0 + else: + pf = self.inv.read(pf_u16_reg[self.firmware], 2) + params['PF'] = float(util.data_to_u16(pf))/100.0 + + except Exception as e: + raise der.DERError(str(e)) return params @@ -277,8 +506,380 @@ def limit_max_power(self, params=None): :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for limit max power. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + control_mode = {'02.02.30.R': 'PMAX', '02.84.01.R': 'PMAX', '02.83.03.R': 'PMAX', '02.63.33.S': 'PMAX'} + # Active power setpoint P, in % of the maximum active power (PMAX) of the inverter + wlim_pmaxpct_reg = {'02.02.30.R': 40016, '02.84.01.R': 40214, '02.83.03.R': 40016, '02.63.33.S': 40016} + # Normalized active power limitation by PV system ctrl, in % + wlim_pvpct_reg = {'02.02.30.R': 40023, '02.84.01.R': None, '02.83.03.R': 40023, '02.63.33.S': 40023} + # Active power setpoint for the operating mode "Active power limitation P via PV system control" (A) + wlim_pvcurrent_reg = {'02.02.30.R': 40143, '02.84.01.R': None, '02.83.03.R': 40143, '02.63.33.S': 40143} + # Generator active power limitation for the operating mode "Active power limitation P via system control" (A) + wlim_current_reg = {'02.02.30.R': 40147, '02.84.01.R': None, '02.83.03.R': 40147, '02.63.33.S': 40147} + # Active power setpoint for the operating mode "Active power limitation P via system control" (W) + wlim_power_reg = {'02.02.30.R': 40149, '02.84.01.R': 40212, '02.83.03.R': 40149, '02.63.33.S': 40149} + + # Operating mode of feed-in management: + # 303 = Off + # 1077 = Active power limitation P in W + # 1078 = Act. power lim. as % of Pmax + # 1079 = Act. power lim. via PV system ctrl + wlim_ena_reg = {'02.02.30.R': 40151, '02.84.01.R': 40210, '02.83.03.R': 40210, '02.63.33.S': 40151} + wlim_type_val = {'02.02.30.R': 1078, '02.84.01.R': 1078, '02.83.03.R': 1078, '02.63.33.S': 1078} + + try: + if params is not None: + ena = params.get('Ena') + if ena is not None: + if ena is True: + self.inv.write(wlim_ena_reg[self.firmware], util.u32_to_data(wlim_type_val[self.firmware])) + else: + self.inv.write(wlim_ena_reg[self.firmware], util.u32_to_data(303)) + + if params.get('WMaxPct') is not None: + power = int(params.get('WMaxPct')) + if control_mode[self.firmware] == 'PMAX': + self.inv.write(wlim_pmaxpct_reg[self.firmware], util.u32_to_data(int(power))) + elif control_mode[self.firmware] == 'PVPCT': + self.inv.write(wlim_pvpct_reg[self.firmware], util.s16_to_data(int(power))) + elif control_mode[self.firmware] == 'PVCURRENT': + self.inv.write(wlim_pvcurrent_reg[self.firmware], util.s32_to_data(int(power))) + elif control_mode[self.firmware] == 'CURRENT': + self.inv.write(wlim_current_reg[self.firmware], util.u32_to_data(int(power))) + elif control_mode[self.firmware] == 'POWER': + self.inv.write(wlim_power_reg[self.firmware], util.u32_to_data(int(power))) + else: + raise der.DERError('Unknowned Limit Power operating mode') + + else: + params = {} + if util.data_to_u32(self.inv.read(wlim_ena_reg[self.firmware], 2)) == 303: + params['Ena'] = False + else: + params['Ena'] = True + + if control_mode[self.firmware] == 'PMAX': + params['WMaxPct'] = util.data_to_u32(self.inv.read(wlim_pmaxpct_reg[self.firmware], 2)) + elif control_mode[self.firmware] == 'PVPCT': + params['WMaxPct'] = util.data_to_s16(self.inv.read(wlim_pvpct_reg[self.firmware], 1)) + elif control_mode[self.firmware] == 'PVCURRENT': + params['WMaxPct'] = util.data_to_s32(self.inv.read(wlim_pvcurrent_reg[self.firmware], 2)) + elif control_mode[self.firmware] == 'CURRENT': + params['WMaxPct'] = util.data_to_u32(self.inv.read(wlim_current_reg[self.firmware], 2)) + elif control_mode[self.firmware] == 'POWER': + params['WMaxPct'] = util.data_to_u32(self.inv.read(wlim_power_reg[self.firmware], 2)) + else: + der.DERError('Unknowned Limit Power operating mode') + + except Exception as e: + raise der.DERError(str(e)) + + return params + + def get_curve_registers(self, id): + """ + Returns dictionary of dictionaries with curve parameters + + :param id: SMA Curve Number + :return: dict with registers or the number of points, x and y units, and 4 x and y points + + NPTS = Number of supported curve points to be used + TIME_CHAR = Adjustment time of characteristic operating point, conf. of grid integr. char. 1 + RAMP_DOWN = Decrease ramp, conf. of grid integr. char. 1 + RAMP_UP = Increase ramp, conf. of grid integr. char. 1 + + Units for characteristic curve - X units + 1975 = Voltage in V + 1976 = Voltage in percentages of Un + 3158 = Active power as a percentage of Pmax + 3420 = Hertz + 3421 = Hertz as the difference from the nominal frequency + + Units for characteristic curve - Y units + 1977 = Var in percentages of Pmax + 1978 = Power in percentages of Pmax + 1979 = Power in percentages of frozen active power + 2272 = cos Phi (EEI convention) + """ + + if id == 1: # curve 1 + NPTS = {'02.02.30.R': 40262, '02.84.01.R': 41023, '02.83.03.R': 41023, '02.63.33.S': 40262} + TIME_CHAR = {'02.02.30.R': 41017, '02.84.01.R': 41017, '02.83.03.R': 41017, '02.63.33.S': 41017} + RAMP_UP = {'02.02.30.R': 41021, '02.84.01.R': 41021, '02.83.03.R': 41021, '02.63.33.S': 41021} + RAMP_DOWN = {'02.02.30.R': 41019, '02.84.01.R': 41019, '02.83.03.R': 41019, '02.63.33.S': 41019} + X_UNITS = {'02.02.30.R': 40977, '02.84.01.R': 41025, '02.83.03.R': 40977, '02.63.33.S': 40977} + X1 = {'02.02.30.R': 41077, '02.84.01.R': 41029, '02.83.03.R': 41029, '02.63.33.S': 40282} + X2 = {'02.02.30.R': 41081, '02.84.01.R': 41033, '02.83.03.R': 41033, '02.63.33.S': 40284} + X3 = {'02.02.30.R': 41085, '02.84.01.R': 41037, '02.83.03.R': 41037, '02.63.33.S': 40286} + X4 = {'02.02.30.R': 41089, '02.84.01.R': 41041, '02.83.03.R': 41041, '02.63.33.S': 40288} + Y_UNITS = {'02.02.30.R': 40957, '02.84.01.R': 41027, '02.83.03.R': 40957, '02.63.33.S': 40957} + Y1 = {'02.02.30.R': 41079, '02.84.01.R': 41031, '02.83.03.R': 41031, '02.63.33.S': 40306} + Y2 = {'02.02.30.R': 41083, '02.84.01.R': 41035, '02.83.03.R': 41035, '02.63.33.S': 40308} + Y3 = {'02.02.30.R': 41087, '02.84.01.R': 41039, '02.83.03.R': 41039, '02.63.33.S': 40310} + Y4 = {'02.02.30.R': 41091, '02.84.01.R': 41043, '02.83.03.R': 41043, '02.63.33.S': 40312} + elif id == 2: + NPTS = {'02.02.30.R': 40262, '02.84.01.R': 41071, '02.83.03.R': 41071, '02.63.33.S': 40262} + TIME_CHAR = {'02.02.30.R': 41065, '02.84.01.R': 41065, '02.83.03.R': 41065, '02.63.33.S': 41065} + RAMP_UP = {'02.02.30.R': 41067, '02.84.01.R': 41067, '02.83.03.R': 41067, '02.63.33.S': 41067} + RAMP_DOWN = {'02.02.30.R': 41069, '02.84.01.R': 41069, '02.83.03.R': 41069, '02.63.33.S': 41069} + X_UNITS = {'02.02.30.R': 40979, '02.84.01.R': 41073, '02.83.03.R': 40979, '02.63.33.S': 40979} + X1 = {'02.02.30.R': None, '02.84.01.R': 41077, '02.83.03.R': 41077, '02.63.33.S': 40330} + X2 = {'02.02.30.R': None, '02.84.01.R': 41081, '02.83.03.R': 41081, '02.63.33.S': 40332} + X3 = {'02.02.30.R': None, '02.84.01.R': 41085, '02.83.03.R': 41085, '02.63.33.S': 40334} + X4 = {'02.02.30.R': None, '02.84.01.R': 41089, '02.83.03.R': 41089, '02.63.33.S': 40336} + Y_UNITS = {'02.02.30.R': 40959, '02.84.01.R': 41075, '02.83.03.R': 40959, '02.63.33.S': 40959} + Y1 = {'02.02.30.R': None, '02.84.01.R': 41079, '02.83.03.R': 41079, '02.63.33.S': 40354} + Y2 = {'02.02.30.R': None, '02.84.01.R': 41083, '02.83.03.R': 41083, '02.63.33.S': 40356} + Y3 = {'02.02.30.R': None, '02.84.01.R': 41087, '02.83.03.R': 41087, '02.63.33.S': 40358} + Y4 = {'02.02.30.R': None, '02.84.01.R': 41091, '02.83.03.R': 41091, '02.63.33.S': 40360} + else: # id == 3 + NPTS = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': None} + TIME_CHAR = {'02.02.30.R': 41065, '02.84.01.R': 41065, '02.83.03.R': 41065, '02.63.33.S': 41065} + RAMP_UP = {'02.02.30.R': 41067, '02.84.01.R': 41067, '02.83.03.R': 41067, '02.63.33.S': 41067} + RAMP_DOWN = {'02.02.30.R': 41069, '02.84.01.R': 41069, '02.83.03.R': 41069, '02.63.33.S': 41069} + X_UNITS = {'02.02.30.R': 40981, '02.84.01.R': None, '02.83.03.R': 40981, '02.63.33.S': 40981} + X1 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40378} + X2 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40380} + X3 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40382} + X4 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40384} + Y_UNITS = {'02.02.30.R': 40961, '02.84.01.R': None, '02.83.03.R': 40961, '02.63.33.S': 40961} + Y1 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40402} + Y2 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40404} + Y3 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40406} + Y4 = {'02.02.30.R': None, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40408} + + return {'NPts': NPTS, 'x_units': X_UNITS, 'y_units': Y_UNITS, 'x1': X1, 'x2': X2, 'x3': X3, 'x4': X4, 'y1': Y1, + 'y2': Y2, 'y3': Y3, 'y4': Y4, 'TIME_CHAR': TIME_CHAR, 'RAMP_UP': RAMP_UP, 'RAMP_DOWN': RAMP_DOWN} + + def volt_watt(self, params=None): + """volt/watt control - der.DERError('Unimplemented function: limit_max_power') + :param params: Dictionary of parameters to be updated. + 'Ena': True/False + 'ActCrv': 0 + 'NCrv': 1 + 'NPt': 4 + 'WinTms': 0 + 'RvrtTms': 0 + 'RmpTms': 0 + 'curve': { + 'ActPt': 3 + 'v': [95, 101, 105] + 'w': [100, 100, 0] + 'DeptRef': 1 + 'RmpPt1Tms': 0 + 'RmpDecTmm': 0 + 'RmpIncTmm': 0 + } + :return: Dictionary of active settings for volt_watt + """ + if self.inv is None: + raise der.DERError('DER not initialized') + + # 2269 = Reactive power charact. curve + q_mode_ena = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + q_mode_ena_val = {'02.02.30.R': 1069, '02.84.01.R': 2269, '02.83.03.R': 2269, '02.63.33.S': 1069} + + # Curve 1 = Characteristic curve number, configuration of characteristic curve mode [1] + nonactive_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 40937, '02.83.03.R': 40937, '02.63.33.S': 40937} + active_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 41063, '02.83.03.R': 40937, '02.63.33.S': 40937} + vw_ena_curve_val = {'02.02.30.R': 308, '02.84.01.R': 308, '02.83.03.R': 308, '02.63.33.S': 308} + # 2nd characteristic curve number, configuration of characteristic curve mode + # This maps the characteristic curve points to the characteristic behavior + vw_curve_num = {'02.02.30.R': 40937, '02.84.01.R': 41061, '02.83.03.R': 40917, '02.63.33.S': 40937} + + # Use curve 2 + reg = self.get_curve_registers(2) + n_pts = reg['NPts'] + v_units_val = {'02.02.30.R': 1976, '02.84.01.R': 1976, '02.83.03.R': 1976, '02.63.33.S': 1976} + p_units_val = {'02.02.30.R': 1978, '02.84.01.R': 1978, '02.83.03.R': 1978, '02.63.33.S': 1978} + v_adrs = [reg['x1'][self.firmware], reg['x2'][self.firmware]] + w_adrs = [reg['y1'][self.firmware], reg['y2'][self.firmware]] + + if params is not None: + if params['Ena']: + # put in characteristic curve mode + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(q_mode_ena_val[self.firmware])) + + # enable/disable curves + self.inv.write(nonactive_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(active_crv_activation[self.firmware], + util.u32_to_data(vw_ena_curve_val[self.firmware])) + + # set configuration characteristic to the active curve + self.ts.log('Using Curve 2 in SMA for the VW Write') + params['ActCrv'] = 2 + self.inv.write(vw_curve_num[self.firmware], util.u32_to_data(params['ActCrv'])) + + # set curve units to %Vnom and %PMax + self.inv.write(reg['x_units'][self.firmware], util.u32_to_data(int(v_units_val[self.firmware]))) + self.inv.write(reg['y_units'][self.firmware], util.u32_to_data(int(p_units_val[self.firmware]))) + else: + self.inv.write(active_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(303)) + if params.get('NPt') is not None: + self.inv.write(n_pts[self.firmware], util.u32_to_data(params['NPt'])) + if params.get('curve') is not None: + if params['curve'].get('v') is not None: + v = [params['curve']['v'][0], params['curve']['v'][1]] + if len(v) != 2: + self.ts.log_warning('Only two VW voltage points used!') + if params['curve']['v'][0] is not None: + self.inv.write(v_adrs[0], util.s32_to_data(int(v[0]*1000))) + if params['curve']['v'][1] is not None: + self.inv.write(v_adrs[1], util.s32_to_data(int(v[1]*1000))) + if params['curve'].get('w') is not None: + w = [params['curve']['w'][0], params['curve']['w'][1]] + if len(w) != 2: + self.ts.log_warning('Only two VW power points used!') + if params['curve']['w'][0] is not None: + self.inv.write(w_adrs[0], util.s32_to_data(int(w[0]*1000))) + if params['curve']['w'][1] is not None: + self.inv.write(w_adrs[1], util.s32_to_data(int(w[1]*1000))) + + # self.debug_read_curves() + + else: + params = {} + q_mode = util.data_to_u32(self.inv.read(q_mode_ena[self.firmware], 2)) == q_mode_ena_val[self.firmware] + curve_ena = util.data_to_u32(self.inv.read(active_crv_activation[self.firmware], 2)) == \ + vw_ena_curve_val[self.firmware] + if q_mode and curve_ena: + params['Ena'] = True + else: + params['Ena'] = False + + params['NPt'] = util.data_to_u32(self.inv.read(n_pts[self.firmware], 2)) + + v0 = util.data_to_s32(self.inv.read(v_adrs[0], 2))/1000. + v1 = util.data_to_s32(self.inv.read(v_adrs[1], 2))/1000. + w0 = util.data_to_s32(self.inv.read(w_adrs[0], 2))/1000. + w1 = util.data_to_s32(self.inv.read(w_adrs[1], 2))/1000. + + params['curve'] = {'id': 1, 'v': [v0, v1], 'w': [w0, w1]} + + return params + + def watt_var(self, params=None): + """watt/var control + + :param params: Dictionary of parameters to be updated. + 'Ena': True/False + 'ActCrv': 0 + 'NCrv': 1 + 'NPt': 4 + 'WinTms': 0 + 'RvrtTms': 0 + 'RmpTms': 0 + 'curve': { + 'ActPt': 3 + 'w': [50, 75, 100] + 'var': [0, 0, -100] + 'DeptRef': 1 + 'RmpPt1Tms': 0 + 'RmpDecTmm': 0 + 'RmpIncTmm': 0 + } + :return: Dictionary of active settings for volt_watt + """ + if self.inv is None: + raise der.DERError('DER not initialized') + + # 2269 = Reactive power charact. curve + q_mode_ena = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + q_mode_ena_val = {'02.02.30.R': 1069, '02.84.01.R': 2269, '02.83.03.R': 2269, '02.63.33.S': 1069} + + # Curve 1 = Characteristic curve number, configuration of characteristic curve mode [1] + nonactive_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 40937, '02.83.03.R': 40937, '02.63.33.S': 40937} + active_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 41063, '02.83.03.R': 40937, '02.63.33.S': 40937} + wv_ena_curve_val = {'02.02.30.R': 308, '02.84.01.R': 308, '02.83.03.R': 308, '02.63.33.S': 308} + # 2nd characteristic curve number, configuration of characteristic curve mode + # This maps the characteristic curve points to the characteristic behavior + wv_curve_num = {'02.02.30.R': 40937, '02.84.01.R': 41061, '02.83.03.R': 40917, '02.63.33.S': 40937} + + # Use curve 2 + reg = self.get_curve_registers(2) + n_pts = reg['NPts'] + w_units_val = {'02.02.30.R': 3158, '02.84.01.R': 3158, '02.83.03.R': 3158, '02.63.33.S': 3158} + var_units_val = {'02.02.30.R': 1977, '02.84.01.R': 1977, '02.83.03.R': 1977, '02.63.33.S': 1977} + w_adrs = [reg['x1'][self.firmware], reg['x2'][self.firmware], + reg['x3'][self.firmware], reg['x4'][self.firmware]] + var_adrs = [reg['y1'][self.firmware], reg['y2'][self.firmware], + reg['y3'][self.firmware], reg['y4'][self.firmware]] + + if params is not None: + if params['Ena']: + # put in characteristic curve mode + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(q_mode_ena_val[self.firmware])) + + # enable/disable curves + self.inv.write(nonactive_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(active_crv_activation[self.firmware], + util.u32_to_data(wv_ena_curve_val[self.firmware])) + + # set configuration characteristic to the active curve + self.ts.log('Using Curve 2 in SMA for the Watt/Var Write') + params['ActCrv'] = 2 + self.inv.write(wv_curve_num[self.firmware], util.u32_to_data(params['ActCrv'])) + + # set curve units to p = %PMax and var = %PMax + self.inv.write(reg['x_units'][self.firmware], util.u32_to_data(int(w_units_val[self.firmware]))) + self.inv.write(reg['y_units'][self.firmware], util.u32_to_data(int(var_units_val[self.firmware]))) + else: + self.inv.write(active_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(303)) + if params.get('NPt') is not None: + self.inv.write(n_pts[self.firmware], util.u32_to_data(params['NPt'])) + if params.get('RmpTms') is not None: + time_const = params['RmpTms'] + self.inv.write(reg['TIME_CHAR'][self.firmware], util.u32_to_data(int(round(time_const*10)))) + + if params.get('curve') is not None: + w = params['curve'].get('w') + if w is not None: + w_len = len(w) + for i in range(w_len): # SunSpec point index starts at 1 + self.inv.write(w_adrs[i], util.s32_to_data(int(round(w[i], 3) * 1000))) + self.ts.log_debug('Writing w point %s to reg %s with value %s' % (i, w_adrs[i], w[i])) + + # set var points + var = params['curve'].get('var') + if var is not None: + var_len = len(var) + for i in range(var_len): # SunSpec point index starts at 1 + self.inv.write(var_adrs[i], util.s32_to_data(int(round(var[i], 3) * 1000))) + self.ts.log_debug('Writing v point %s to reg %s with value %s' % (i, var_adrs[i], var[i])) + + else: + params = {} + q_mode = util.data_to_u32(self.inv.read(q_mode_ena[self.firmware], 2)) == q_mode_ena_val[self.firmware] + curve_ena = util.data_to_u32(self.inv.read(active_crv_activation[self.firmware], 2)) == \ + wv_ena_curve_val[self.firmware] + if q_mode and curve_ena: + params['Ena'] = True + else: + params['Ena'] = False + + params['NPt'] = util.data_to_u32(self.inv.read(n_pts[self.firmware], 2)) + params['RmpTms'] = util.data_to_u32(self.inv.read(reg['TIME_CHAR'][self.firmware], 2))/10. + params['ActCrv'] = 2 + params['NCrv'] = 3 + + w = [] + var = [] + if reg['NPts'][self.firmware] is not None: + n_pt = int(util.data_to_u32(self.inv.read(reg['NPts'][self.firmware], 2))) + else: + n_pt = 3 + for i in range(int(n_pt)): + w.append(util.data_to_s32(self.inv.read(w_adrs[i], 2))/1000.) + var.append(util.data_to_s32(self.inv.read(var_adrs[i], 2))/1000.) + + params['curve'] = {'id': 1, 'w': w, 'var': var} + + return params def volt_var(self, params=None): """ Get/set volt/var control @@ -291,14 +892,85 @@ def volt_var(self, params=None): WinTms - Randomized start time delay in seconds RmpTms - Ramp time in seconds to updated output level RvrtTms - Reversion time in seconds - + 'curve': { + 'v': [50, 75, 100] + 'var': [0, 0, -100] + 'DeptRef': 1 + 'RmpDecTmm': 0 + 'RmpIncTmm': 0 + } :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for volt/var control. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + # 2269 = Reactive power charact. curve + q_mode_ena = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + q_mode_ena_val = {'02.02.30.R': 1069, '02.84.01.R': 2269, '02.83.03.R': 2269, '02.63.33.S': 1069} + + # Curve 1 = Characteristic curve number, configuration of characteristic curve mode [1] + nonactive_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 40937, '02.83.03.R': 40937, '02.63.33.S': 40937} + active_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 41063, '02.83.03.R': 40937, '02.63.33.S': 40937} + vw_ena_curve_val = {'02.02.30.R': 308, '02.84.01.R': 308, '02.83.03.R': 308, '02.63.33.S': 308} + # 2nd characteristic curve number, configuration of characteristic curve mode + # This maps the characteristic curve points to the characteristic behavior + vw_curve_num = {'02.02.30.R': 40937, '02.84.01.R': 41061, '02.83.03.R': 40917, '02.63.33.S': 40937} + + # Use curve 2 + reg = self.get_curve_registers(2) + n_pts = reg['NPts'] + # Units for characteristic curve 1. Voltage in %Vnom and Var in %Pmax + v_units_val = {'02.02.30.R': 1976, '02.84.01.R': 1976, '02.83.03.R': 1976, '02.63.33.S': 1976} + q_units_val = {'02.02.30.R': 1977, '02.84.01.R': 1977, '02.83.03.R': 1977, '02.63.33.S': 1977} + + if params is not None: + curve = params.get('curve') # Must write curve first because there is a read() in volt_var_curve + if curve is not None: + self.volt_var_curve(id=2, params=curve) + + ena = params.get('Ena') + if ena is not None: + # put in Reactive power charact. curve, not Q(V) mode + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(q_mode_ena_val[self.firmware])) + + # enable/disable curves + self.inv.write(nonactive_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(active_crv_activation[self.firmware], + util.u32_to_data(vw_ena_curve_val[self.firmware])) + + # set configuration characteristic to the active curve + self.ts.log('Using Curve 2 in SMA for the VV Write') + params['ActCrv'] = 2 + self.inv.write(vw_curve_num[self.firmware], util.u32_to_data(params['ActCrv'])) + + # set curve units to %Vnom and %PMax + self.inv.write(reg['x_units'][self.firmware], util.u32_to_data(int(v_units_val[self.firmware]))) + self.inv.write(reg['y_units'][self.firmware], util.u32_to_data(int(q_units_val[self.firmware]))) + else: + self.inv.write(active_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(303)) + + if params.get('NPt') is not None: + self.inv.write(n_pts[self.firmware], util.u32_to_data(params['NPt'])) + + else: + params = {} + reg = self.inv.read(q_mode_ena_val[self.firmware], 2) + if util.data_to_u32(reg) == q_mode_ena[self.firmware]: + params['Ena'] = True + else: + params['Ena'] = False + + params['ActCrv'] = 2 - der.DERError('Unimplemented function: volt_var') + params['NCrv'] = 3 # SMA supports 3 curves (...or sometimes 2) + if params.get('ActCrv') is not None: + params['curve'] = self.volt_var_curve(id=params['ActCrv']) + + return params - def volt_var_curve(self, id, params=None): + def volt_var_curve(self, id=2, params=None): """ Get/set volt/var curve v [] - List of voltage curve points var [] - List of var curve points based on DeptRef @@ -310,8 +982,74 @@ def volt_var_curve(self, id, params=None): :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for volt/var curve control. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + reg = self.get_curve_registers(2) + v_adrs = [reg['x1'][self.firmware], reg['x2'][self.firmware], + reg['x3'][self.firmware], reg['x4'][self.firmware]] + var_adrs = [reg['y1'][self.firmware], reg['y2'][self.firmware], + reg['y3'][self.firmware], reg['y4'][self.firmware]] + + volt_var_dept_ref = { + 'W_MAX_PCT': 1, + 'VAR_MAX_PCT': 2, + 'VAR_AVAL_PCT': 3, + 1: 'W_MAX_PCT', + 2: 'VAR_MAX_PCT', + 3: 'VAR_AVAL_PCT' + } + + + if int(id) > 3: + raise der.DERError('Curve id out of range: %s' % id) + + if params is not None: + n_pt = int(util.data_to_s32(self.inv.read(reg['NPts'][self.firmware], 2))) + self.ts.log_debug('Number of points in the curve is %s' % n_pt) + if n_pt != len(params['v']): + self.inv.write(reg['NPts'][self.firmware], util.u32_to_data(int(len(params['v'])))) + self.ts.log_debug('Wrote number of points (%d) to Reg %s.' % + (int(len(params['v'])), reg['NPts'][self.firmware])) + + # set voltage points + v = params.get('v') + if v is not None: + v_len = len(v) + for i in range(v_len): # SunSpec point index starts at 1 + self.inv.write(v_adrs[i], util.s32_to_data(int(round(v[i], 3) * 1000))) + # v_val = int(util.data_to_s32(self.inv.read(v_adrs[i], 2))) + # self.ts.log_debug('Voltage point %s is %s' % (i, v_val)) + # self.ts.log_debug('Writing v point %s to reg %s with value %s' % (i, v_adrs[i], v[i])) + + # set var points + var = params.get('var') + if var is not None: + var_len = len(var) + for i in range(var_len): # SunSpec point index starts at 1 + self.inv.write(var_adrs[i], util.s32_to_data(int(round(var[i], 3)*1000))) - der.DERError('Unimplemented function: volt_var_curve') + else: + self.ts.log_debug('Reading VV curve in SMA') + params = {} + v = [] + var = [] + if reg['NPts'][self.firmware] is not None: + n_pt = int(util.data_to_u32(self.inv.read(reg['NPts'][self.firmware], 2))) + else: + n_pt = 4 + for i in range(int(n_pt)): + self.ts.log('Getting V%s' % i) + v.append(util.data_to_s32(self.inv.read(v_adrs[i], 2))/1000.) + self.ts.log('Getting Q%s' % i) + var.append(util.data_to_s32(self.inv.read(var_adrs[i], 2))/1000.) + + params['DeptRef'] = volt_var_dept_ref.get(1) # 'W_MAX_PCT' + params['id'] = id # also store the curve number + params['v'] = v + params['var'] = var + + return params def freq_watt(self, params=None): """ Get/set freq/watt control @@ -324,42 +1062,125 @@ def freq_watt(self, params=None): WinTms - Randomized start time delay in seconds RmpTms - Ramp time in seconds to updated output level RvrtTms - Reversion time in seconds + curve - dict of curve parameters: + hz [] - List of frequency curve points + w [] - List of power curve points + CrvNam - Optional description for curve. (Max 16 chars) + RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of + snap shot function. + SnptW - 1=enable snapshot/capture mode + WRef - Reference active power (default = WMax). + WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining + power output. + WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. + ReadOnly - 0 = READWRITE, 1 = READONLY :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for freq/watt control. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + # 2269 = Reactive power charact. curve --- Use the curve to create FW curve + q_mode_ena = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + q_mode_ena_val = {'02.02.30.R': 1069, '02.84.01.R': 2269, '02.83.03.R': 2269, '02.63.33.S': 1069} + + # Curve 1 = Characteristic curve number, configuration of characteristic curve mode [1] + nonactive_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 40937, '02.83.03.R': 40937, '02.63.33.S': 40937} + active_crv_activation = {'02.02.30.R': 40937, '02.84.01.R': 41063, '02.83.03.R': 40937, '02.63.33.S': 40937} + fw_ena_curve_val = {'02.02.30.R': 308, '02.84.01.R': 308, '02.83.03.R': 308, '02.63.33.S': 308} + # 2nd characteristic curve number, configuration of characteristic curve mode + # This maps the characteristic curve points to the characteristic behavior + fw_curve_num = {'02.02.30.R': 40937, '02.84.01.R': 41061, '02.83.03.R': 40917, '02.63.33.S': 40937} + + self.ts.log_warning('Using Curve 2 for the FW function.') + reg = self.get_curve_registers(2) + + n_pts = reg['NPts'] + f_units_val = {'02.02.30.R': 3420, '02.84.01.R': 3420, '02.83.03.R': 3420, '02.63.33.S': 3420} + p_units_val = {'02.02.30.R': 1978, '02.84.01.R': 1978, '02.83.03.R': 1978, '02.63.33.S': 1978} + + f_adrs = [] + p_adrs = [] + for i in range(4): # Prepolulate 4 register values + f_adrs.append(reg['x%d' % (i+1)][self.firmware]) + p_adrs.append(reg['y%d' % (i+1)][self.firmware]) + + if params is not None: + if params['Ena']: + # put in characteristic curve mode + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(q_mode_ena_val[self.firmware])) + + # enable/disable curves + self.inv.write(nonactive_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(active_crv_activation[self.firmware], + util.u32_to_data(fw_ena_curve_val[self.firmware])) + + # set configuration characteristic to the active curve + self.ts.log('Using Curve 2 in SMA for the FW Write') + params['ActCrv'] = 2 + self.inv.write(fw_curve_num[self.firmware], util.u32_to_data(params['ActCrv'])) + + # set curve units to %Vnom and %PMax + self.inv.write(reg['x_units'][self.firmware], util.u32_to_data(int(f_units_val[self.firmware]))) + self.inv.write(reg['y_units'][self.firmware], util.u32_to_data(int(p_units_val[self.firmware]))) + else: + self.inv.write(active_crv_activation[self.firmware], util.u32_to_data(303)) + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(303)) + if params.get('NPt') is not None: + self.inv.write(n_pts[self.firmware], util.u32_to_data(params['NPt'])) + + if params.get('curve') is not None: + curve = params['curve'] + + # set freq points + f = curve.get('hz') + if f is not None: + f_len = len(f) + for i in range(f_len): # point name starts at 1 but index starts at 0 + self.inv.write(f_adrs[i], util.s32_to_data(int(round(f[i], 3) * 1000))) + + # set power points + p = curve.get('w') + if p is not None: + p_len = len(p) + for i in range(p_len): # point name starts at 1 but index starts at 0 + self.inv.write(p_adrs[i], util.s32_to_data(int(round(p[i], 3) * 1000))) + # self.ts.log_debug('Writing Power point %s @ %s' % (int(round(p[i], 3) * 1000), p_adrs[i])) + # self.ts.log_debug('Writing P point %s to reg %s with value %s' % (i, p_adrs[i], p[i])) + else: + params = {} + q_mode = util.data_to_u32(self.inv.read(q_mode_ena[self.firmware], 2)) == q_mode_ena_val[self.firmware] + curve_ena = util.data_to_u32(self.inv.read(active_crv_activation[self.firmware], 2)) == \ + fw_ena_curve_val[self.firmware] + if q_mode and curve_ena: + params['Ena'] = True + else: + params['Ena'] = False - der.DERError('Unimplemented function: freq_watt') + params['NPt'] = util.data_to_u32(self.inv.read(n_pts[self.firmware], 2)) # number of points + params['ActCrv'] = 2 # active curve + params['NCrv'] = 3 # number of supported curves - def freq_watt_curve(self, id, params=None): - """ Get/set volt/var curve - hz [] - List of frequency curve points - w [] - List of power curve points - CrvNam - Optional description for curve. (Max 16 chars) - RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). - RmpDecTmm - Ramp decrement timer - RmpIncTmm - Ramp increment timer - RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of - snap shot function. - SnptW - 1=enable snapshot/capture mode - WRef - Reference active power (default = WMax). - WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining - power output. - WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. - ReadOnly - 0 = READWRITE, 1 = READONLY + f = [] + w = [] + for i in range(params['NPt']): + f.append(util.data_to_s32(self.inv.read(f_adrs[i], 2))/1000.) + w.append(util.data_to_s32(self.inv.read(p_adrs[i], 2))/1000.) - :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for freq/watt curve. - """ + params['curve'] = {'id': 2, 'hz': f, 'w': w, 'WRef': 'WMax'} - der.DERError('Unimplemented function: freq_watt_curve') + return params def freq_watt_param(self, params=None): """ Get/set frequency-watt with parameters Params: Ena - Enabled (True/False) - HysEna - Enable hysterisis (True/False) + HysEna - Enable hysteresis (True/False) WGra - The slope of the reduction in the maximum allowed watts output as a function of frequency. HzStr - The frequency deviation from nominal frequency (ECPNomHz) at which a snapshot of the instantaneous power output is taken to act as the CAPPED power level (PM) and above which reduction in power @@ -373,63 +1194,407 @@ def freq_watt_param(self, params=None): :return: Dictionary of active settings for frequency-watt with parameters control. """ - der.DERError('Unimplemented function: freq_watt_param') + if self.inv is None: + raise der.DERError('DER not initialized') + + self.ts.log_warning('freq_watt_param() is not programmed. Use freq_droop()') + + if params is not None: + return params + else: + return self.freq_droop(params) - def frt_stay_connected_high(self, params=None): - """ Get/set high frequency ride through (must stay connected curve) + def freq_droop(self, params=None): + """ Get/set freq droop control Params: Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - Tms# - Time point in the curve - Hz# - Frequency point in the curve + dbOF - single-sided deadband value for high-frequency and low-frequency, respectively, in Hz + dbUF - single-sided deadband value for high-frequency and low-frequency, respectively, in Hz + kOF - per-unit frequency change corresponding to 1 per-unit power output change (frequency droop), unitless + kUF - per-unit frequency change corresponding to 1 per-unit power output change (frequency droop), unitless + Note: a 5% droop per 0.1 Hz is created with a kOF,kUF = (0.1/60)/0.05 (this will change the EUT power + from 100% to 0% output as frequency increases to 2 Hz above nominal) + + 'RspTms' :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. + :return: Dictionary of active settings for freq droop control. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + # Operating mode of active power reduction in case of overfrequency P(f): + fdroop_ena = {'02.02.30.R': 40216, '02.84.01.R': 40216, '02.83.03.R': 40216, '02.63.33.S': 40216} + # 303 = Off + # 1132 = Linear gradient + # 3175 = Linear gradient of the maximum active power + fdroop_val = {'02.02.30.R': 1132, '02.84.01.R': 1132, '02.83.03.R': 1132, '02.63.33.S': 1132} + + # Difference between starting frequency and grid frequency, linear instantaneous power gradient configuration + dbf1 = {'02.02.30.R': 40218, '02.84.01.R': 40218, '02.83.03.R': 40218, '02.63.33.S': 40218} + dbf2 = {'02.02.30.R': 40220, '02.84.01.R': None, '02.83.03.R': None, '02.63.33.S': 40220} + # Difference between reset frequency and grid frequency, linear instantaneous power gradient configuration + dbf_return = {'02.02.30.R': 40220, '02.84.01.R': 40220, '02.83.03.R': 40220, '02.63.33.S': 40220} + kof = {'02.02.30.R': 40234, '02.84.01.R': 40234, '02.83.03.R': 40234, '02.63.33.S': 40234} + # Active power gradient after reset frequency, linear instantaneous power gradient configuration + kof_return = {'02.02.30.R': 40242, '02.84.01.R': 40242, '02.83.03.R': 40242, '02.63.33.S': 40242} + + if params is not None: + ena = params['Ena'] + if ena: + self.inv.write(fdroop_ena[self.firmware], util.u32_to_data(int(fdroop_val[self.firmware]))) # lin grad + else: + self.inv.write(fdroop_ena[self.firmware], util.u32_to_data(int(303))) # off + + if params.get('dbOF') is not None: + dbf_set = int(round(params['dbOF'], 2)*100) + self.inv.write(dbf1[self.firmware], util.u32_to_data(dbf_set)) # dbf + if dbf2[self.firmware] is not None: + self.inv.write(dbf2[self.firmware], util.u32_to_data(dbf_set)) # dbf + # set the return curve to be the same + self.inv.write(dbf_return[self.firmware], util.u32_to_data(dbf_return)) # dbf + + if params.get('kOF') is not None: + kof_set = int(1/(params['kOF'])) + self.inv.write(kof[self.firmware], util.u32_to_data(kof_set)) # dbf + # set the return curve to be the same + if kof_return[self.firmware] is not None: + self.inv.write(kof_return[self.firmware], util.u32_to_data(kof_set)) # dbf - der.DERError('Unimplemented function: frt_stay_connected_high') + else: + dbf = float(util.data_to_u32(self.inv.read(dbf1[self.firmware], 2)))/100.0 + kof = util.data_to_u32(self.inv.read(kof[self.firmware], 2)) + params = {'dbOF': dbf, 'dbUF': dbf, 'kOF': kof, 'kUF': kof} - def frt_stay_connected_low(self, params=None): - """ Get/set high frequency ride through (must stay connected curve) + return params - Params: - Ena - Enabled (True/False) - ActCrv - Active curve number (0 - no active curve) - NCrv - Number of curves supported - NPt - Number of points supported per curve - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds - Tms# - Time point in the curve - Hz# - Frequency point in the curve + def frt_trip_high(self, params=None): + """ Get/set high frequency ride through (trip curve) + + Params: params = {'curve': 't': [299., 10.], 'Hz': [61.0, 61.8]} + curve: + t - Time point in the curve + Hz - Frequency point in the curve :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for HFRT control. + :return: Dictionary of active settings for HFT control. + + ^ + | T0 Median Max + F |-----------+ F0 + | | T1 Lower Max + | +--------------+ F1 + | | + Fnom-------------------------------> Time + | | + | | + | +--------------+ F1 Upper Min + | | T1 + |-----------+ F0 + | T0 Median Min + """ - der.DERError('Unimplemented function: frt_stay_connected_low') + # Frequency monitoring median maximum threshold Hz U32 FIX2 RW + f0 = {'02.02.30.R': 40428, '02.84.01.R': 40428, '02.83.03.R': 40428, '02.63.33.S': 40428} + # Frq. monitoring median max. threshold trip. time ms U32 FIX0 RW + t0 = {'02.02.30.R': 40430, '02.84.01.R': 40430, '02.83.03.R': 40430, '02.63.33.S': 40430} + # Frequency monitoring lower maximum threshold Hz U32 FIX2 RW + f1 = {'02.02.30.R': 40432, '02.84.01.R': 40432, '02.83.03.R': 40432, '02.63.33.S': 40432} + # Frq. monitoring lower max. threshold trip. time ms U32 FIX0 RW + t1 = {'02.02.30.R': 40434, '02.84.01.R': 40434, '02.83.03.R': 40434, '02.63.33.S': 40434} + + if params is not None: + if params.get('curve') is not None: + if params.get('curve').get('Hz') is not None: + hz = params.get('curve').get('Hz') + t = params.get('curve').get('t') + if len(hz) != 2: + f0_set = int(round(hz[0], 2) * 100) + self.inv.write(f0[self.firmware], util.u32_to_data(f0_set)) # Hz + f1_set = int(round(hz[0], 2) * 100) + self.inv.write(f1[self.firmware], util.u32_to_data(f1_set)) # Hz + t0_set = int(t[0]*1000.) + self.inv.write(t0[self.firmware], util.u32_to_data(t0_set)) # ms + t1_set = int(t[1]*1000.) + self.inv.write(t1[self.firmware], util.u32_to_data(t1_set)) # ms + else: + self.ts.log_warning('Use 2 points for FRT curves') + + else: + f0 = float(util.data_to_u32(self.inv.read(f0[self.firmware], 2))) / 100.0 + t0 = util.data_to_u32(self.inv.read(t0[self.firmware], 2)) / 1000. + f1 = float(util.data_to_u32(self.inv.read(f1[self.firmware], 2))) / 100.0 + t1 = util.data_to_u32(self.inv.read(t1[self.firmware], 2)) / 1000. + + params = {'curve': {'Hz': [f0, f1], 't': [t0, t1]}} + + return params + + def frt_trip_low(self, params=None): + """ Get/set lower frequency ride through (trip curve) + + Params: params = {'curve': 't': [299., 10.], 'Hz': [59.0, 58.2]} + curve: + t - Time point in the curve + Hz - Frequency point in the curve + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LFT control. + """ + + # Frequency monitoring median minimum threshold Hz U32 FIX2 RW + f0 = {'02.02.30.R': 40440, '02.84.01.R': 40440, '02.83.03.R': 40440, '02.63.33.S': 40440} + # Frq. monitoring median min. threshold trip. time ms U32 FIX0 RW + t0 = {'02.02.30.R': 40442, '02.84.01.R': 40442, '02.83.03.R': 40442, '02.63.33.S': 40442} + # Frequency monitoring upper minimum threshold Hz U32 FIX2 RW + f1 = {'02.02.30.R': 40436, '02.84.01.R': 40436, '02.83.03.R': 40436, '02.63.33.S': 40436} + # Frq. monitoring upper min. threshold trip. time ms U32 FIX0 RW + t1 = {'02.02.30.R': 40438, '02.84.01.R': 40438, '02.83.03.R': 40438, '02.63.33.S': 40438} + + if params is not None: + if params.get('curve') is not None: + if params.get('curve').get('Hz') is not None: + hz = params.get('curve').get('Hz') + t = params.get('curve').get('t') + if len(hz) != 2: + f0_set = int(round(hz[0], 2) * 100) + self.inv.write(f0[self.firmware], util.u32_to_data(f0_set)) # Hz + f1_set = int(round(hz[0], 2) * 100) + self.inv.write(f1[self.firmware], util.u32_to_data(f1_set)) # Hz + t0_set = int(t[0] * 1000.) + self.inv.write(t0[self.firmware], util.u32_to_data(t0_set)) # ms + t1_set = int(t[1] * 1000.) + self.inv.write(t1[self.firmware], util.u32_to_data(t1_set)) # ms + else: + self.ts.log_warning('Use 2 points for FRT curves') + + else: + f0 = float(util.data_to_u32(self.inv.read(f0[self.firmware], 2))) / 100.0 + t0 = util.data_to_u32(self.inv.read(t0[self.firmware], 2)) / 1000. + f1 = float(util.data_to_u32(self.inv.read(f1[self.firmware], 2))) / 100.0 + t1 = util.data_to_u32(self.inv.read(t1[self.firmware], 2)) / 1000. + + params = {'curve': {'Hz': [f0, f1], 't': [t0, t1]}} + + return params + + def vrt_trip_high(self, params=None): + """ Get/set high voltage ride through (trip curve) + + Params: params = {'curve': 't': [60., 10.], 'V': [110.0, 120.0]} + curve: + t - Time point in the curve + V - Voltage point in the curve % of Vnom + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HVT control. + + ^ T0 Upper Max + |-------+ V0 + | | T1 Median Max + V | +----------+ V1 + | | T2 Lower Max + | +--------------+ V2 + | | + Vnom-------------------------------> Time + | | + | | + | +--------------+ V2 Upper Min + | | T2 + | +-----------+ V1 + | | T1 Median Min + |------+ V0 + | T0 Lower Min + """ + + # Voltage monitoring of upper maximum threshold as RMS value V U32 FIX2 RW + v0 = {'02.02.30.R': 41115, '02.84.01.R': 41115, '02.83.03.R': 41115, '02.63.33.S': 41115} + # Voltage monitoring of upper max. thresh. as RMS value for tripping time ms U32 FIX0 RW + t0 = {'02.02.30.R': 41117, '02.84.01.R': 41117, '02.83.03.R': 41117, '02.63.33.S': 41117} + # Voltage monitoring upper max. threshold trip. time ms U32 FIX3 RW + # t0 = {'02.02.30.R': 40446, '02.84.01.R': 40446, '02.83.03.R': 40446, '02.63.33.S': 40446} + + # Voltage monitoring median maximum threshold V U32 FIX2 RW + v1 = {'02.02.30.R': 40448, '02.84.01.R': 40448, '02.83.03.R': 40448, '02.63.33.S': 40448} + # Voltage monitoring median max. threshold trip.time ms U32 FIX0 RW + t1 = {'02.02.30.R': 40450, '02.84.01.R': 40450, '02.83.03.R': 40450, '02.63.33.S': 40450} + + # Voltage monitoring lower maximum threshold V U32 FIX2 RW + v2 = {'02.02.30.R': 40452, '02.84.01.R': 40452, '02.83.03.R': 40452, '02.63.33.S': 40452} + # Voltage monitoring lower max. threshold trip. time ms U32 FIX0 RW + t2 = {'02.02.30.R': 40456, '02.84.01.R': 40456, '02.83.03.R': 40456, '02.63.33.S': 40456} + + if params is not None: + if params.get('curve') is not None: + if params.get('curve').get('V') is not None: + v = params.get('curve').get('V') + t = params.get('curve').get('t') + if len(v) != 3: + v0_set = int(round(v[0], 2) * 100) + self.inv.write(v0[self.firmware], util.u32_to_data(v0_set)) # V + v1_set = int(round(v[1], 2) * 100) + self.inv.write(v1[self.firmware], util.u32_to_data(v1_set)) # V + v2_set = int(round(v[2], 2) * 100) + self.inv.write(v1[self.firmware], util.u32_to_data(v2_set)) # V + t0_set = int(t[0] * 1000.) + self.inv.write(t0[self.firmware], util.u32_to_data(t0_set)) # ms + t1_set = int(t[1] * 1000.) + self.inv.write(t1[self.firmware], util.u32_to_data(t1_set)) # ms + t2_set = int(t[2] * 1000.) + self.inv.write(t2[self.firmware], util.u32_to_data(t2_set)) # ms + else: + self.ts.log_warning('Use 3 points for FRT curves') + + else: + v0 = float(util.data_to_u32(self.inv.read(v0[self.firmware], 2))) / 100.0 + t0 = util.data_to_u32(self.inv.read(t0[self.firmware], 2)) / 1000. + v1 = float(util.data_to_u32(self.inv.read(v1[self.firmware], 2))) / 100.0 + t1 = util.data_to_u32(self.inv.read(t1[self.firmware], 2)) / 1000. + v2 = float(util.data_to_u32(self.inv.read(v2[self.firmware], 2))) / 100.0 + t2 = util.data_to_u32(self.inv.read(t2[self.firmware], 2)) / 1000. + params = {'curve': {'V': [v0, v1, v2], 't': [t0, t1, t2]}} + + return params + + def vrt_trip_low(self, params=None): + """ Get/set lower voltage ride through (trip curve) + + Params: params = {'curve': 't': [60., 10.], 'V': [110.0, 120.0]} + curve: + t - Time point in the curve + V - Voltage point in the curve % of Vnom + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for LVT control. + """ + + # Voltage monitoring of lower minimum threshold as RMS value V U32 FIX2 RW + v0 = {'02.02.30.R': 41111, '02.84.01.R': 41111, '02.83.03.R': 41111, '02.63.33.S': 41111} + # Voltage monitoring of lower min.threshold as RMS value for tripping time ms U32 FIX0 RW + t0 = {'02.02.30.R': 41113, '02.84.01.R': 41113, '02.83.03.R': 41113, '02.63.33.S': 41113} + + # Voltage monitoring of median minimum threshold V U32 FIX2 RW + v1 = {'02.02.30.R': 40464, '02.84.01.R': 40464, '02.83.03.R': 40464, '02.63.33.S': 40464} + # Voltage monitoring median min. threshold trip.time ms U32 FIX0 RW + t1 = {'02.02.30.R': 40466, '02.84.01.R': 40466, '02.83.03.R': 40466, '02.63.33.S': 40466} + + # Voltage monitoring upper minimum threshold V U32 FIX2 RW + v2 = {'02.02.30.R': 40458, '02.84.01.R': 40458, '02.83.03.R': 40458, '02.63.33.S': 40458} + # Voltage monitoring upper min. threshold trip. time ms U32 FIX0 RW + t2 = {'02.02.30.R': 40462, '02.84.01.R': 40462, '02.83.03.R': 40462, '02.63.33.S': 40462} + + if params is not None: + if params.get('curve') is not None: + if params.get('curve').get('V') is not None: + v = params.get('curve').get('V') + t = params.get('curve').get('t') + if len(v) != 3: + v0_set = int(round(v[0], 2) * 100) + self.inv.write(v0[self.firmware], util.u32_to_data(v0_set)) # V + v1_set = int(round(v[1], 2) * 100) + self.inv.write(v1[self.firmware], util.u32_to_data(v1_set)) # V + v2_set = int(round(v[2], 2) * 100) + self.inv.write(v1[self.firmware], util.u32_to_data(v2_set)) # V + t0_set = int(t[0] * 1000.) + self.inv.write(t0[self.firmware], util.u32_to_data(t0_set)) # ms + t1_set = int(t[1] * 1000.) + self.inv.write(t1[self.firmware], util.u32_to_data(t1_set)) # ms + t2_set = int(t[2] * 1000.) + self.inv.write(t2[self.firmware], util.u32_to_data(t2_set)) # ms + else: + self.ts.log_warning('Use 3 points for FRT curves') + + else: + v0 = float(util.data_to_u32(self.inv.read(v0[self.firmware], 2))) / 100.0 + t0 = util.data_to_u32(self.inv.read(t0[self.firmware], 2)) / 1000. + v1 = float(util.data_to_u32(self.inv.read(v1[self.firmware], 2))) / 100.0 + t1 = util.data_to_u32(self.inv.read(t1[self.firmware], 2)) / 1000. + v2 = float(util.data_to_u32(self.inv.read(v2[self.firmware], 2))) / 100.0 + t2 = util.data_to_u32(self.inv.read(t2[self.firmware], 2)) / 1000. + params = {'curve': {'V': [v0, v1, v2], 't': [t0, t1, t2]}} + + return params def reactive_power(self, params=None): """ Set the reactive power Params: Ena - Enabled (True/False) - Q - Reactive power as %Qmax (positive is overexcited, negative is underexcited) - WinTms - Randomized start time delay in seconds - RmpTms - Ramp time in seconds to updated output level - RvrtTms - Reversion time in seconds + VArPct_Mod - Reactive power mode + 'None' : 0, + 'WMax': 1, + 'VArMax': 2, + 'VArAval': 3, + VArWMaxPct - Reactive power in percent of WMax. + VArMaxPct - Reactive power in percent of VArMax. + VArAvalPct - Reactive power in percent of VArAval. :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for Q control. """ - der.DERError('Unimplemented function: reactive_power') + reactive_power_dept_ref = { + 'None': 0, + 'WMax': 1, + 'VArMax': 2, + 'VArAval': 3, + 0: 'None', + 1: 'WMax', + 2: 'VArMax', + 3: 'VArAval' + } + + if self.inv is None: + raise der.DERError('DER not initialized') + + # 1070 - Reactive power Q, direct spec. + q_mode_ena = {'02.02.30.R': 40200, '02.84.01.R': 40200, '02.83.03.R': 40200, '02.63.33.S': 40200} + q_mode_ena_val = {'02.02.30.R': 1070, '02.84.01.R': 1070, '02.83.03.R': 1070, '02.63.33.S': 1070} + + # 40204 - Reactive power set value as a % % S32 FIX1 RW + var_wmax_pct_reg = {'02.02.30.R': 40204, '02.84.01.R': 40204, '02.83.03.R': 40204, '02.63.33.S': 40204} + # var_wmax_pct_reg = {'02.02.30.R': 40015, '02.84.01.R': 40015, '02.83.03.R': 40015, '02.63.33.S': 40015} + # var_varmax_pct_reg = {'02.02.30.R': 40204, '02.84.01.R': 40204, '02.83.03.R': 40204, '02.63.33.S': 40204} + # var_varaval_pct_reg = {'02.02.30.R': 40204, '02.84.01.R': 40204, '02.83.03.R': 40204, '02.63.33.S': 40204} + + if params is not None: + ena = params.get('Ena') + if ena is not None: + if ena is True: + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(q_mode_ena_val[self.firmware])) + else: + self.inv.write(q_mode_ena[self.firmware], util.u32_to_data(303)) + + var_pct_mod = params.get('VArPct_Mod') + if isinstance(reactive_power_dept_ref, int): + var_pct_mod = reactive_power_dept_ref.get(reactive_power_dept_ref) # use the string format + if var_pct_mod != 'WMax': + var_pct_mod = 'WMAX' + self.ts.log_warning('Using WMAX for reactive_power VArPct_Mod because ' + 'this is the only supported mode for SMA EUTs.') + + if var_pct_mod == 'WMAX': + q_target = params.get('VArWMaxPct') + if q_target is not None: + self.inv.write(var_wmax_pct_reg[self.firmware], util.s32_to_data(int(q_target*10.))) + else: + self.ts.log_warning('Unsupported reactive power mode. VArPct_Mod = %s' % var_pct_mod) + + else: + params = {} + if util.data_to_u32(self.inv.read(q_mode_ena[self.firmware], 2)) == q_mode_ena_val[self.firmware]: + params['Ena'] = False + else: + params['Ena'] = True + + params['VArPct_Mod'] = reactive_power_dept_ref.get('WMax') # return the integer + params['VArWMaxPct'] = util.data_to_s32(self.inv.read(var_wmax_pct_reg[self.firmware], 2)) / 10. + params['VArMaxPct'] = None + params['VArAvalPct'] = None + + return params def active_power(self, params=None): """ Get/set active power of EUT @@ -444,8 +1609,28 @@ def active_power(self, params=None): :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for HFRT control. """ + if self.inv is None: + raise der.DERError('DER not initialized') + + p_mode_ena = {'02.02.30.R': 40210, '02.84.01.R': 40210, '02.83.03.R': 40210, '02.63.33.S': 40210} + # 303 = Off + # 1077 = Active power limitation P in W + # 1078 = Act. power lim. as % of Pmax + # 1079 = Act. power lim. via PV system ctrl + p_mode_val = {'02.02.30.R': 1078, '02.84.01.R': 1078, '02.83.03.R': 1078, '02.63.33.S': 1078} + + p_in_watts = {'02.02.30.R': 40212, '02.84.01.R': 40212, '02.83.03.R': 40212, '02.63.33.S': 40212} + p_in_pct = {'02.02.30.R': 40214, '02.84.01.R': 40214, '02.83.03.R': 40214, '02.63.33.S': 40214} + + # map to limit_max_power() parameters + lim_p_params = {'Ena': params['Ena'], 'WMaxPct': params['P'], 'WinTms': params['WinTms'], + 'RmpTms': params['RmpTms'], 'RvrtTms': params['RvrtTms']} + returned_params = self.limit_max_power(lim_p_params) + params = {'Ena': returned_params['Ena'], 'P': returned_params['WMaxPct'], + 'WinTms': returned_params['WinTms'], 'RmpTms': returned_params['RmpTms'], + 'RvrtTms': returned_params['RvrtTms']} - der.DERError('Unimplemented function: active_power') + return params def storage(self, params=None): """ Get/set storage parameters @@ -473,3 +1658,574 @@ def storage(self, params=None): der.DERError('Unimplemented function: storage') + def debug_read_curves(self): + """ + Curves for SMA PKG 2.84 + + :return: None + """ + self.ts.log('----------------') + self.ts.log('Characteristic curve number, configuration of characteristic curve mode [1]: %s' % + util.data_to_u32(self.inv.read(40917, 2))) + self.ts.log('Activation of the characteristic curve, configuration of characteristic curve mode: [1]: %s' % + util.data_to_u32(self.inv.read(40937, 2))) + self.ts.log('Adjustment time of characteristic operating point, conf. of grid integr. char. 1: %s' % + util.data_to_u32(self.inv.read(41017, 2))) + self.ts.log('Decrease ramp, conf. of grid integr. char. 1: %s' % util.data_to_u32(self.inv.read(41019, 2))) + self.ts.log('Increase ramp, conf. of grid integr. char. 1: %s' % util.data_to_u32(self.inv.read(41021, 2))) + self.ts.log('Number of points to be used, conf. of grid integr. char. 1: %s' % + util.data_to_u32(self.inv.read(41023, 2))) + self.ts.log('X-axes reference, conf. of grid integration char. 1: %s' % + util.data_to_u32(self.inv.read(41025, 2))) + self.ts.log('Y-axes reference, conf. of grid integration char. 1: %s' % + util.data_to_u32(self.inv.read(41027, 2))) + self.ts.log('X value 1, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41029, 2))) + self.ts.log('Y value 1, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41031, 2))) + self.ts.log('X value 2, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41033, 2))) + self.ts.log('Y value 2, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41035, 2))) + self.ts.log('X value 3, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41037, 2))) + self.ts.log('Y value 3, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41039, 2))) + self.ts.log('X value 4, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41041, 2))) + self.ts.log('Y value 4, conf. of grid integr. char. 1: %s' % util.data_to_s32(self.inv.read(41043, 2))) + self.ts.log('----------------') + self.ts.log('2nd characteristic curve number, configuration of characteristic curve mode: %s' % + util.data_to_u32(self.inv.read(41061, 2))) + self.ts.log('2nd activation of the characteristic curve, configuration of characteristic curve mode: %s' % + util.data_to_u32(self.inv.read(41063, 2))) + self.ts.log('Adjustment time of char. operating point, conf. of grid integration char. 2: %s' % + util.data_to_u32(self.inv.read(41065, 2))) + self.ts.log('Decrease ramp, conf. of grid integr. char. 2: %s' % util.data_to_u32(self.inv.read(41067, 2))) + self.ts.log('Increase ramp, conf. of grid integr. char. 2: %s' % util.data_to_u32(self.inv.read(41069, 2))) + self.ts.log('Number of points to be used, conf. of grid integr. char. 2: %s' % + util.data_to_u32(self.inv.read(41071, 2))) + self.ts.log('X-axes reference, conf. of grid integration char. 2: %s' % + util.data_to_u32(self.inv.read(41073, 2))) + self.ts.log('Y-axes reference, conf. of grid integration char. 2: %s' % + util.data_to_u32(self.inv.read(41075, 2))) + self.ts.log('X value 1, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41077, 2))) + self.ts.log('Y value 1, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41079, 2))) + self.ts.log('X value 2, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41081, 2))) + self.ts.log('Y value 2, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41083, 2))) + self.ts.log('X value 3, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41085, 2))) + self.ts.log('Y value 3, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41087, 2))) + self.ts.log('X value 4, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41089, 2))) + self.ts.log('Y value 4, conf. of grid integr. char. 2: %s' % util.data_to_s32(self.inv.read(41091, 2))) + self.ts.log('----------------') + + def ui(self, params=None): + """ + Unintentional islanding configuration + + :param params: + :return: + """ + return None + + def country_code(self, params=None): + """ + + :param params: + ui_set - bool to turn on/off anti-islanding + + :return: params dictionary + """ + set_country_code = {'02.02.30.R': 41121, '02.84.01.R': 41121, '02.83.03.R': 41121, '02.63.33.S': 41121} + # Set country standard: U32 FUNKTION_SEC RW + # 306 = Island mode 60 Hz + # 1013 = Other standard + # 7519 = UL1741/2010/277 + + country_code = {'02.02.30.R': 40109, '02.84.01.R': 40109, '02.83.03.R': 40109, '02.63.33.S': 40109} + # Country standard set: U32 ENUM RO + # 27 = Special setting + # 306 = Island mode 60 Hz + # 1013 = Other standard + # 7519 = UL1741/2010/277 + # 16777213 = Information not available + + plant_conn = {'02.02.30.R': 30881, '02.84.01.R': 30881, '02.83.03.R': 30881, '02.63.33.S': 30881} + # "Plant mains connection: U32 ENUM RO + # 1779 = Separated + # 1780 = Public electricity mains + # 1781 = Island mains" + + if params is not None: + ena = params.get('Ena') + if ena is not None: + if ena is True: + self.inv.write(set_country_code[self.firmware], util.u32_to_data(27)) + else: + self.inv.write(set_country_code[self.firmware], util.u32_to_data(306)) + + else: + params = {} + + set_cc = util.data_to_u32(self.inv.read(set_country_code[self.firmware], 2)) + if set_cc == 306: + params['Country Code'] = 'Island mode 60 Hz' + elif set_cc == 1013: + params['Country Code'] = 'Other standard' + elif set_cc == 7519: + params['Country Code'] = 'UL1741/2010/277' + else: + params['Country Code'] = str(set_cc) + + cc = util.data_to_u32(self.inv.read(country_code[self.firmware], 2)) + if cc == 27: + params['Country Code Read'] = 'Special setting' + elif cc == 306: + params['Country Code Read'] = 'Island mode 60 Hz' + elif cc == 1013: + params['Country Code Read'] = 'Other standard' + elif cc == 7519: + params['Country Code Read'] = 'UL1741/2010/277' + elif cc == 16777213: + params['Country Code Read'] = 'Information not available' + else: + params['Country Code Read'] = str(cc) + + plant_conn = util.data_to_u32(self.inv.read(plant_conn[self.firmware], 2)) + if plant_conn == 1779: + params['Plant Conn'] = 'Separated' + elif plant_conn == 1780: + params['Plant Conn'] = 'Public electricity mains' + elif plant_conn == 1781: + params['Plant Conn'] = 'Island mains' + else: + params['Plant Conn'] = str(plant_conn) + + if params['Country Code'] == 'Island mode 60 Hz': # Island mode + params['ui_mode_enable_as'] = False + else: + params['ui_mode_enable_as'] = True + + return params + + +if __name__ == "__main__": + pass + + +''' +SMA Data Formats +----------------------------------------------------------------------------------------------------------------------- +Format Explanation +Duration Time in seconds, in minutes or in hours, depending on the Modbus register. +DT Date/time, in accordance with country setting. Transmission in seconds since 1970-01-01. +ENUM Coded numerical values. The breakdown of the possible codes can be found directly under the designation of + the Modbus register in the SMA Modbus profile - assignment tables. +FIX0 Decimal number, commercially rounded, no decimal place. +FIX1 Decimal number, commercially rounded, one decimal place. +FIX2 Decimal number, commercially rounded, two decimal places. +FIX3 Decimal number, commercially rounded, three decimal places. +FIX4 Decimal number, commercially rounded, four decimal places. +FW Firmware version (see Section 3.8, "SMA Firmware Data Format (FW)", 15) +HW Hardware version e.g. 24. +IP4 4-byte IP address (IPv4) of the form XXX.XXX.XXX.XXX. +RAW Text or number. A RAW number has no decimal places and no thousand or other separation indicators. +TEMP Temperature values are stored in special Modbus registers in degrees Celsius (deg C), in degrees Fahrenheit + (dge F), or in Kelvin K. The values are commercially rounded, with one decimal place. + +----------------------------------------------------------------------------------------------------------------------- +Sunny Tripower US version with "Speedwire data module" +"Sunny Tripower: STP 12000TL-US-10, STP 15000TL-US-10, STP 20000TL-US-10, STP 24000TL-US-10 and STP 30000TL-US-10" +Speedwire data module: SWDM-10 +Starting with software package: 02.84.01.R +----------------------------------------------------------------------------------------------------------------------- + +30051 Device class: + 8001 = Solar Inverters U32 ENUM RO +30053 Device type: + 9194 = STP 12000TL-US-10 + 9195 = STP 15000TL-US-10 + 9196 = STP 20000TL- U32 ENUM RO +30057 Serial number U32 RAW RO +30059 Software package U32 FW RO +30197 Current event number U32 FIX0 RO +30199 Waiting time until feed-in s U32 Duration RO +30201 Condition: + 35 = Fault + 303 = Off + 307 = Ok + 455 = Warning U32 ENUM RO +30203 Nominal power in Ok Mode W U32 FIX0 RO +30205 Nominal power in Warning Mode W U32 FIX0 RO +30207 Nominal power in Fault Mode W U32 FIX0 RO +30211 Recommended action: + 336 = Contact manufacturer + 337 = Contact installer + 338 = inval U32 ENUM RO +30213 Message: + 886 = none U32 ENUM RO +30215 Fault correction measure: + 885 = none U32 ENUM RO +30217 Grid relay/contactor: + 51 = Closed + 311 = Open + 16777213 = Information not available U32 ENUM RO +30219 Derating: + 557 = Temperature derating + 884 = not active + 16777213 = Information not a U32 ENUM RO +30225 Insulation resistance Ohms U32 FIX0 RO +30231 Maximum active power device W U32 FIX0 RO +30233 Set active power limit W U32 FIX0 RO +30235 Backup mode status: + 1440 = Grid mode + 1441 = Separate network mode + 16777213 = Infor U32 ENUM RO +30247 Current event number for manufacturer U32 FIX0 RO +30513 Total yield Wh U64 FIX0 RO +30517 Daily yield Wh U64 FIX0 RO +30521 Operating time s U64 Duration RO +30525 Feed-in time s U64 Duration RO +30529 Total yield Wh U32 FIX0 RO +30531 Total yield kWh U32 FIX0 RO +30533 Total yield MWh U32 FIX0 RO +30535 Daily yield Wh U32 FIX0 RO +30537 Daily yield kWh U32 FIX0 RO +30539 Daily yield MWh U32 FIX0 RO +30541 Operating time s U32 Duration RO +30543 Feed-in time s U32 Duration RO +30559 Number of events for user U32 FIX0 RO +30561 Number of events for installer U32 FIX0 RO +30563 Number of events for service U32 FIX0 RO +30583 Grid feed-in counter reading Wh U32 FIX0 RO +30599 Number of grid connections U32 FIX0 RO +30769 DC current input [1] A S32 FIX3 RO +30771 DC voltage input [1] V S32 FIX2 RO +30773 DC power input [1] W S32 FIX0 RO +30775 Power W S32 FIX0 RO +30777 Power L1 W S32 FIX0 RO +30779 Power L2 W S32 FIX0 RO +30781 Power L3 W S32 FIX0 RO +30783 Grid voltage phase L1 V U32 FIX2 RO +30785 Grid voltage phase L2 V U32 FIX2 RO +30787 Grid voltage phase L3 V U32 FIX2 RO +30795 Grid current A U32 FIX3 RO +30803 Grid frequency Hz U32 FIX2 RO +30805 Reactive power VAr S32 FIX0 RO +30807 Reactive power L1 VAr S32 FIX0 RO +30809 Reactive power L2 VAr S32 FIX0 RO +30811 Reactive power L3 VAr S32 FIX0 RO +30813 Apparent power VA S32 FIX0 RO +30815 Apparent power L1 VA S32 FIX0 RO +30817 Apparent power L2 VA S32 FIX0 RO +30819 Apparent power L3 VA S32 FIX0 RO +30825 Operating mode of stat.V stab., stat.V stab. config.: + 303 = Off + 1069 = React. power/volt. char. Q(U) + 1070 = Reactive power Q, direct spec. + 1072 = Q specified by PV system control + 1074 = cosPhi, direct specific. + 1075 = cosPhi, specified by PV system control + 1076 = cosPhi(P) characteristic + 2269 = Reactive power charact. curve + 2270 = cos Phi or Q specification through optimum PV system control" U32 ENUM RO +30829 Reactive power set value as a % % S32 FIX1 RO +30831 cosPhi setpoint, cosPhi config., direct specif. S32 FIX2 RO +30833 cosPhi excit.type, cosPhi config., direct spec.: + 1041 = Overexcited + 1042 = Underexcited U32 ENUM RO +30835 Operating mode of feed-in management: + 303 = Off + 1077 = Active power limitation P in W + 1078 = Act. power lim. as % of Pmax + 1079 = Act. power lim. via PV system ctrl" U32 ENUM RO +30837 Active power limitation P, active power configuration W U32 FIX0 RO +30839 Active power limitation P, active power configuration % U32 FIX0 RO +30881 Plant mains connection: + 1779 = Separated + 1780 = Public electricity mains + 1781 = Is U32 ENUM RO +30919 Oper.mode vol.maint.at Q on Dem., st.vol.maint.conf.: + 303 = Off + 2476 = As static v U32 ENUM RO +30925 Connection speed of SMACOM A: + 302 = ------- + 1720 = 10 Mbit/s + 1721 = 100 Mbit/s U32 ENUM RO +30927 Duplex mode of SMACOM A: + 302 = ------- + 1726 = Half duplex + 1727 = Full duplex U32 ENUM RO +30929 Speedwire connection status of SMACOM A: + 35 = Fault + 307 = Ok + 455 = Warning + 1725 = U32 ENUM RO +30931 Connection speed of SMACOM B: + 302 = ------- + 1720 = 10 Mbit/s + 1721 = 100 Mbit/s U32 ENUM RO +30933 Duplex mode of SMACOM B: + 302 = ------- + 1726 = Half duplex + 1727 = Full duplex U32 ENUM RO +30935 Speedwire connection status of SMACOM B: + 35 = Fault + 307 = Ok + 455 = Warning + 1725 = U32 ENUM RO +30949 Displacement power factor U32 FIX3 RO +30953 Internal temperature C S32 TEMP RO +30957 DC current input [2] A S32 FIX3 RO +30959 DC voltage input [2] V S32 FIX2 RO +30961 DC power input [2] W S32 FIX0 RO +30975 Intermediate circuit voltage V S32 FIX2 RO +30977 Grid current phase L1 A S32 FIX3 RO +30979 Grid current phase L2 A S32 FIX3 RO +30981 Grid current phase L3 A S32 FIX3 RO +31017 Current speedwire IP address STR32 UTF8 RO +31025 Current speedwire subnet mask STR32 UTF8 RO +31033 Current speedwire gateway address STR32 UTF8 RO +31041 Current speedwire DNS server address STR32 UTF8 RO +31085 Nominal power in Ok Mode W U32 FIX0 RO +31159 Current spec. reactive power Q VAr S32 FIX0 RO +31221 EEI displacement power factor S32 FIX3 RO +31247 Residual current A S32 FIX3 RO +31405 Current spec. active power limitation P W U32 FIX0 RO +31407 Current spec. cos Phi U32 FIX4 RO +31409 Current spec. stimulation type cos Phi: +1041 = Overexcited +1042 = Underexcited +167 U32 ENUM RO +31411 Current spec. reactive power Q VAr S32 FIX0 RO +31793 DC current input [1] A S32 FIX3 RO +31795 DC current input [2] A S32 FIX3 RO +34113 Internal temperature C S32 TEMP RO +35377 Number of events for user U64 FIX0 RO +35381 Number of events for installer U64 FIX0 RO +35385 Number of events for service U64 FIX0 RO +40009 Operating condition: + 295 = MPP + 381 = Stop + 443 = Constant voltage U32 ENUM RW +40013 Language of the user interface: + 777 = Deutsch + 778 = English + 779 = Italiano + 780 = E U32 ENUM RW +40015 Normalized reactive power limitation by PV system ctrl % S16 FIX1 W +40016 Normalized active power limitation by PV system ctrl % S16 FIX0 W +40018 Fast shut-down: + 381 = Stop + 1467 = Start + 1749 = Full stop U32 ENUM W +40022 Normalized reactive power limitation by PV system ctrl % S16 FIX2 W +40023 Normalized active power limitation by PV system ctrl % S16 FIX2 W +40024 Dis.pow.factor that can be changed via PV system ctrl U16 FIX4 W +40025 Excitation type that can be changed by PV system ctrl: + 1041 = Overexcited + 1042 = U U32 ENUM W +40029 Operating status: + 295 = MPP + 1467 = Start + 381 = Stop + 2119 = Derating + 1469 = Shut do U32 ENUM RO +40063 Firmware version of the main processor U32 FW RO +40065 Firmware version of the logic component U32 FW RO +40067 Serial number U32 RAW RO +40095 Voltage monitoring upper maximum threshold V U32 FIX2 RW +40109 Country standard set: + 27 = Special setting + 306 = Island mode 60 Hz + 1013 = Other st U32 ENUM RO +40133 Grid nominal voltage V U32 FIX0 RW +40135 Nominal frequency Hz U32 FIX2 RW +40157 Automatic speedwire configureation switched on: + 1129 = Yes + 1130 = No U32 ENUM RW +40159 Speedwire IP address STR32 IP4 RW +40167 Speedwire subnet mask STR32 IP4 RW +40175 Speedwire gateway address STR32 IP4 RW +40185 Maximum apparent power device VA U32 FIX0 RO +40195 Currently set apparent power limit VA U32 FIX0 RW +40200 Operating mode of stat.V stab., stat.V stab. config.: + 303 = Off + 1069 = React. powe U32 ENUM RW +40204 Reactive power set value as a % % S32 FIX1 RW +40206 cosPhi setpoint, cosPhi config., direct specif. S32 FIX2 RW +40208 cosPhi excit.type, cosPhi config., direct spec.: + 1041 = Overexcited + 1042 = Underex U32 ENUM RW +40210 Operating mode of feed-in management: + 303 = Off + 1077 = Active power limitation P i U32 ENUM RW +40212 Active power limitation P, active power configuration W U32 FIX0 RW +40214 Active power limitation P, active power configuration % U32 FIX0 RW +40216 Operating mode of active power reduction in case of overfrequency P(f): +303 = Off + U32 ENUM RW +40218 Difference between starting frequency and grid frequency, linear instantaneou Hz U32 FIX2 RW +40220 Difference between reset frequency and grid frequency, linear instantaneous p Hz U32 FIX2 RW +40222 cosPhi at start point, cosPhi(P) char. config. U32 FIX2 RW +40224 Excit. type at start point, cosPhi(P) char. conf.: + 1041 = Overexcited + 1042 = Under U32 ENUM RW +40226 cosPhi at end point, cosPhi(P) char. config. U32 FIX2 RW +40228 Excit. type at end point, cosPhi(P) char. config.: + 1041 = Overexcited + 1042 = Under U32 ENUM RW +40230 Act. power at start point, cosPhi(P) char. config. % U32 FIX0 RW +40232 Act. power at end point, cosPhi(P) char. config. % U32 FIX0 RW +40234 Active power gradient % U32 FIX0 RW +40238 Active power gradient, linear instantaneous power gradient configuration % U32 FIX0 RW +40240 Activation of stay-set indicator function, linear instantaneous power gradient con U32 ENUM RW +40242 Active power gradient after reset frequency, linear instantaneous power gradi % U32 FIX0 RW +40244 Reactive current droop, full dynamic grid support configuration: + 1233 = SDLWindV + 1 U32 ENUM RW +40246 Grad.K react.curr.stat.for UV for dyn.grid support % U32 FIX2 RW +40248 Grad.K reac.curr.stat.for dyn.grid support OV % U32 FIX2 RW +40250 Operating mode of dynamic grid support, dynamic grid support configuration: + 1265 = U32 ENUM RW +40252 Lower limit, voltage dead band, full dynamic grid support configuration % S32 FIX0 RW +40254 Upper limit, voltage dead band, full dynamic support configuration % U32 FIX0 RW +40256 PWM inverse voltage, dynamic grid support configuration % U32 FIX0 RW +40258 PWM inversion delay, dynamic grid support configuration s U32 FIX2 RW +40428 Frequency monitoring median maximum threshold Hz U32 FIX2 RW +40430 Frq. monitoring median max. threshold trip. time ms U32 FIX0 RW +40432 Frequency monitoring lower maximum threshold Hz U32 FIX2 RW +40434 Frq. monitoring lower max. threshold trip. time ms U32 FIX0 RW +40436 Frequency monitoring upper minimum threshold Hz U32 FIX2 RW +40438 Frq. monitoring upper min. threshold trip. time ms U32 FIX0 RW +40440 Frequency monitoring median minimum threshold Hz U32 FIX2 RW +40442 Frq. monitoring median min. threshold trip. time ms U32 FIX0 RW +40446 Voltage monitoring upper max. threshold trip. time ms U32 FIX3 RW +40448 Voltage monitoring median maximum threshold V U32 FIX2 RW +40450 Voltage monitoring median max. threshold trip.time ms U32 FIX0 RW +40452 Voltage monitoring lower maximum threshold V U32 FIX2 RW +40456 Voltage monitoring lower max. threshold trip. time ms U32 FIX0 RW +40458 Voltage monitoring upper minimum threshold V U32 FIX2 RW +40462 Voltage monitoring upper min. threshold trip. time ms U32 FIX0 RW +40464 Voltage monitoring of median minimum threshold V U32 FIX2 RW +40466 Voltage monitoring median min. threshold trip.time ms U32 FIX0 RW +40472 Reference voltage, PV system control V U32 FIX0 RW +40474 Reference correction voltage, PV system control V S32 FIX0 RW +40482 Reactive power gradient % U32 FIX0 RW +40484 Activation of active power gradient: + 303 = Off + 308 = On U32 ENUM RW +40490 Reactive power gradient, reactive power/voltage characteristic curve configur % U32 FIX1 RW +40497 MAC address STR32 UTF8 RO +40513 Speedwire DNS server address STR32 IP4 RW +40575 Operating mode of multifunction relay: [1] + 258 = Switching status grid relay + 1341 U32 ENUM RW +40631 Device name STR32 UTF8 RW +40789 Communication version U32 REV RO +40791 Timeout for communication fault indication s U32 FIX0 RW +40809 Revision status of the logic component U32 FIX0 RW +40915 Set active power limit W U32 FIX0 RW +40917 Characteristic curve number, configuration of characteristic curve mode [1] U32 FIX0 RW +40937 Activation of the characteristic curve, configuration of characteristic curve mode U32 ENUM RW +40997 Hysteresis voltage, dynamic grid support configuration % U32 FIX0 RW +40999 Setpoint cos(phi) as per EEI convention S32 FIX4 W +41001 Maximum achievable reactive power quadrant 1 VAr S32 FIX0 RO +41007 Maximum achievable reactive power quadrant 4 VAr S32 FIX0 RO +41009 Minimum achievable cos(phi) quadrant 1 S32 FIX3 RO +41015 Minimum achievable cos(phi) quadrant 4 S32 FIX3 RO +41017 Adjustment time of characteristic operating point, conf. of grid integr. char s U32 FIX1 RW +41019 Decrease ramp, conf. of grid integr. char. 1 % U32 FIX1 RW +41021 Increase ramp, conf. of grid integr. char. 1 % U32 FIX1 RW +41023 Number of points to be used, conf. of grid integr. char. 1 U32 FIX0 RW +41025 X-axes reference, conf. of grid integration char. 1: + 1975 = Voltage in V + 1976 = Vo U32 ENUM RW +41027 Y-axes reference, conf. of grid integration char. 1: + 1977 = Var in percentages of U32 ENUM RW +41029 X value 1, conf. of grid integr. char. 1 S32 FIX3 RW +41031 Y value 1, conf. of grid integr. char. 1 S32 FIX3 RW +41033 X value 2, conf. of grid integr. char. 1 S32 FIX3 RW +41035 Y value 2, conf. of grid integr. char. 1 S32 FIX3 RW +41037 X value 3, conf. of grid integr. char. 1 S32 FIX3 RW +41039 Y value 3, conf. of grid integr. char. 1 S32 FIX3 RW +41041 X value 4, conf. of grid integr. char. 1 S32 FIX3 RW +41043 Y value 4, conf. of grid integr. char. 1 S32 FIX3 RW +41045 X value 5, conf. of grid integr. char. 1 S32 FIX3 RW +41047 Y value 5, conf. of grid integr. char. 1 S32 FIX3 RW +41049 X value 6, conf. of grid integr. char. 1 S32 FIX3 RW +41051 Y value 6, conf. of grid integr. char. 1 S32 FIX3 RW +41053 X value 7, conf. of grid integr. char. 1 S32 FIX3 RW +41055 Y value 7, conf. of grid integr. char. 1 S32 FIX3 RW +41057 X value 8, conf. of grid integr. char. 1 S32 FIX3 RW +41059 Y value 8, conf. of grid integr. char. 1 S32 FIX3 RW +41061 2nd characteristic curve number, configuration of characteristic curve mode U32 FIX0 RW +41063 2nd activation of the characteristic curve, configuration of characteristic curve U32 ENUM RW +41065 Adjustment time of char. operating point, conf. of grid integration char. 2 s U32 FIX1 RW +41067 Decrease ramp, conf. of grid integration char. 2 % U32 FIX1 RW +41069 Increase ramp, conf. of grid integration char. 2 % U32 FIX1 RW +41071 Number of points to be used, conf. of grid integr. char. 2 U32 FIX0 RW +41073 Input unit, conf. of grid integration char. 2: + 1975 = Voltage in V + 1976 = Voltage U32 ENUM RW +41075 Output frequency, conf. of grid integration char. 2: + 1977 = Var in percentages of U32 ENUM RW +41077 X value 1, conf. of grid integr. char. 2 S32 FIX3 RW +41079 Y value 1, conf. of grid integr. char. 2 S32 FIX3 RW +41081 X value 2, conf. of grid integr. char. 2 S32 FIX3 RW +41083 Y value 2, conf. of grid integr. char. 2 S32 FIX3 RW +41085 X value 3, conf. of grid integr. char. 2 S32 FIX3 RW +41087 Y value 3, conf. of grid integr. char. 2 S32 FIX3 RW +41089 X value 4, conf. of grid integr. char. 2 S32 FIX3 RW +41091 Y value 4, conf. of grid integr. char. 2 S32 FIX3 RW +41093 X value 5, conf. of grid integr. char. 2 S32 FIX3 RW +41095 Y value 5, conf. of grid integr. char. 2 S32 FIX3 RW +41097 X value 6, conf. of grid integr. char. 2 S32 FIX3 RW +41099 Y value 6, conf. of grid integr. char. 2 S32 FIX3 RW +41101 X value 7, conf. of grid integr. char. 2 S32 FIX3 RW +41103 Y value 7, conf. of grid integr. char. 2 S32 FIX3 RW +41105 X value 8, conf. of grid integr. char. 2 S32 FIX3 RW +41107 Y value 8, conf. of grid integr. char. 2 S32 FIX3 RW +41111 Voltage monitoring of lower minimum threshold as RMS value V U32 FIX2 RW +41113 Voltage monitoring of lower min.threshold as RMS value for tripping time ms U32 FIX0 RW +41115 Voltage monitoring of upper maximum threshold as RMS value V U32 FIX2 RW +41117 Voltage monitoring of upper max. thresh. as RMS value for tripping time ms U32 FIX0 RW +41121 Set country standard: + 306 = Island mode 60 Hz + 1013 = Other standard + 7519 = UL1741/2010/277 U32 FUNKTION_SEC RW +41123 Min. voltage for reconnection V U32 FIX2 RW +41125 Max. voltage for reconnection V U32 FIX2 RW +41127 Lower frequency for reconnection Hz U32 FIX2 RW +41129 Upper frequency for reconnection Hz U32 FIX2 RW +41131 Minimum voltage input [1] V U32 FIX2 RW +41133 Minimum voltage input [2] V U32 FIX2 RW +41155 Start delay input [1] s U32 FIX0 RW +41157 Start delay input [2] s U32 FIX0 RW +41169 Minimum insulation resistance Ohms U32 FIX0 RW +41171 Set total yield kWh U32 FIX0 RW +41173 Set total operating time at grid connection point h U32 Duration RW +41193 Operating mode for absent active power limitation: + 2506 = Values maintained + 2507 = U32 ENUM RW +41195 Timeout for absent active power limitation s U32 Duration RW +41197 Fallback act power lmt P in % of WMax for absent act power lmt % U32 FIX2 RW +41201 Active power gradient in feeding operation % U32 FIX0 RW +41219 Operating mode for absent reactive power control: + 2506 = Values maintained + 2507 = U32 ENUM RW +41221 Timeout for absent reactive power control s U32 Duration RW +41223 Fallback react power Q in % of WMax for absent react power ctr % S32 FIX2 RW +41225 Operating mode for absent cos Phi spec: + 2506 = Values maintained + 2507 = Use fallba U32 ENUM RW +41227 Timeout for absent cos Phi spec s U32 Duration RW +41229 Fallback cos Phi for absent cos Phi spec S32 FIX4 RW +41253 Fast shut-down: + 381 = Stop + 1467 = Start + 1749 = Full stop U32 ENUM RW +41255 Normalized active power limitation by PV system ctrl % S16 FIX2 RW +41256 Normalized reactive power limitation by PV system ctrl % S16 FIX2 RW +41257 Setpoint cos(phi) as per EEI convention S32 FIX4 RW +41265 AFCI switched on: + 1129 = Yes + 1130 = No U32 ENUM RW +43090 Login with Grid Guard-Code U32 FIX0 RW + +----------------------------------------------------------------------------------------------------------------------- + +''' diff --git a/Lib/svpelab/der_solaredge.py b/Lib/svpelab/der_solaredge.py index b929955..aabfa62 100644 --- a/Lib/svpelab/der_solaredge.py +++ b/Lib/svpelab/der_solaredge.py @@ -35,7 +35,7 @@ import sunspec.core.client as client import sunspec.core.util as util -import der +from . import der import script solaredge_info = { @@ -131,7 +131,7 @@ def info(self): params['SerialNumber'] = self.inv.common[1].SN else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -177,7 +177,7 @@ def nameplate(self): params['MaxDisChaRte'] = self.inv.nameplate.MaxDisChaRte else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -230,7 +230,7 @@ def measurements(self): params['EvtVnd4'] = self.inv.inverter.EvtVnd4 else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -263,7 +263,7 @@ def settings(self, params=None): raise der.DERError('DER settings not supported.') else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -276,7 +276,7 @@ def conn_status(self, params=None): try: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -290,7 +290,7 @@ def controls_status(self, params=None): try: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -314,7 +314,7 @@ def connect(self, params=None): raise der.DERError('DER settings not supported.') else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -354,7 +354,7 @@ def fixed_pf(self, params=None): params['Ena'] = True params['PF'] = util.data_to_float(self.inv.device.read(0xf002, 2)) - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -392,7 +392,7 @@ def limit_max_power(self, params=None): params['Ena'] = True params['WMaxPct'] = util.data_to_u16(self.inv.device.read(0xf001, 1)) - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -459,7 +459,7 @@ def volt_var(self, params=None): params['curve'] = self.volt_var_curve(id=self.inv.volt_var.ActCrv) else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -511,7 +511,7 @@ def volt_var_curve(self, id, params=None): v_len = len(v) if v_len > n_pt: raise der.DERError('Voltage point count out of range: %d' % (v_len)) - for i in xrange(v_len): # SunSpec point index starts at 1 + for i in range(v_len): # SunSpec point index starts at 1 v_point = 'V%d' % (i + 1) setattr(curve, v_point, v[i]) # set var points @@ -520,7 +520,7 @@ def volt_var_curve(self, id, params=None): var_len = len(var) if var_len > n_pt: raise der.DERError('VAr point count out of range: %d' % (var_len)) - for i in xrange(var_len): # SunSpec point index starts at 1 + for i in range(var_len): # SunSpec point index starts at 1 var_point = 'VAr%d' % (i + 1) setattr(curve, var_point, var[i]) @@ -539,7 +539,7 @@ def volt_var_curve(self, id, params=None): v = [] var = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 v_point = 'V%d' % i var_point = 'VAr%d' % i v.append(getattr(curve, v_point)) @@ -549,7 +549,7 @@ def volt_var_curve(self, id, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -616,7 +616,7 @@ def freq_watt(self, params=None): params['curve'] = self.freq_watt_curve(id=self.inv.freq_watt.ActCrv) else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -690,7 +690,7 @@ def freq_watt_curve(self, id, params=None): hz_len = len(hz) if hz_len > n_pt: raise der.DERError('Freq point count out of range: %d' % (hz_len)) - for i in xrange(hz_len): # SunSpec point index starts at 1 + for i in range(hz_len): # SunSpec point index starts at 1 hz_point = 'Hz%d' % (i + 1) setattr(curve, hz_point, hz[i]) # set watt points @@ -699,7 +699,7 @@ def freq_watt_curve(self, id, params=None): w_len = len(w) if w_len > n_pt: raise der.DERError('Watt point count out of range: %d' % (w_len)) - for i in xrange(w_len): # SunSpec point index starts at 1 + for i in range(w_len): # SunSpec point index starts at 1 w_point = 'W%d' % (i + 1) setattr(curve, w_point, w[i]) @@ -720,7 +720,7 @@ def freq_watt_curve(self, id, params=None): params['id'] = id #also store the curve number hz = [] w = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 hz_point = 'Hz%d' % i w_point = 'VAr%d' % i hz.append(getattr(curve, hz_point)) @@ -730,7 +730,7 @@ def freq_watt_curve(self, id, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -804,7 +804,7 @@ def freq_watt_param(self, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -849,7 +849,7 @@ def frt_stay_connected_high(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.hfrtc.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) if param_time_point is not None: @@ -873,7 +873,7 @@ def frt_stay_connected_high(self, params=None): params['RmpTms'] = self.inv.hfrtc.RmpTms params['RvrtTms'] = self.inv.hfrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -919,7 +919,7 @@ def frt_stay_connected_low(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.lfrtc.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) if param_time_point is not None: @@ -943,7 +943,7 @@ def frt_stay_connected_low(self, params=None): params['RmpTms'] = self.inv.lfrtc.RmpTms params['RvrtTms'] = self.inv.lfrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1016,7 +1016,7 @@ def reactive_power(self, params=None): params['curve'] = self.volt_var_curve(id=self.inv.volt_var.ActCrv) params['Q'] = self.inv.volt_var_curve.var[0] - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1077,7 +1077,7 @@ def active_power(self, params=None): params['RmpTms'] = storage_params['InOutWRte_RmpTms'] params['RvrtTms'] = storage_params['InOutWRte_RvrtTms'] - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1166,7 +1166,7 @@ def storage(self, params=None): params['InOutWRte_RvrtTms'] = self.inv.volt_var.InOutWRte_RvrtTms params['InOutWRte_RmpTms'] = self.inv.volt_var.InOutWRte_RmpTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params diff --git a/Lib/svpelab/der_sunrex.py b/Lib/svpelab/der_sunrex.py new file mode 100644 index 0000000..7ffd735 --- /dev/null +++ b/Lib/svpelab/der_sunrex.py @@ -0,0 +1,460 @@ + +import os +from . import der +import sunspec.core.client as client +import socket + +sunrex_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Sunrex' +} + +def der_info(): + return sunrex_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sunrex_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + # TCP parameters + info.param(pname('ipaddr'), label='IP Address', default='127.0.0.1', active=pname('ifc_type'), active_value=[client.TCP]) + info.param(pname('ipport'), label='IP Port', default=2001, active=pname('ifc_type'), active_value=[client.TCP]) + +GROUP_NAME = 'sunrex' + + +def to_uint(integer=None): + if integer < 0: + integer += 65536 + return int(integer) + + +def two_digit_hex(integer=None): + return format((to_uint(integer)), '02X') + + +def four_digit_hex(integer=None): + return format((to_uint(integer)), '04X') + + +def str(cmd_str=None): + bcc = 0 + cmd_str_chars = list(cmd_str) + for cmd_chars in cmd_str_chars: + v_xor = ord(cmd_chars) + bcc = bcc ^ v_xor + return bcc + + +def der_init(ts, id=None): + """ + Function to create specific der implementation instances. + """ + group_name = 'der' + if id is not None: + group_name = group_name + '_' + str(id) + print('run group_name = %s' % group_name) + mode = ts.param_value(group_name + '.' + 'mode') + sim_module = der_modules.get(mode) + if sim_module is not None: + sim = sim_module.DER(ts, group_name) + else: + raise der.DERError('Unknown data acquisition system mode: %s' % mode) + + return sim + +class DER(der.DER): + + def __init__(self, ts, group_name): + der.DER.__init__(self, ts, group_name) + self.inv = None + self.clientsock = None + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.open() + + def open(self): + ipaddr = self.param_value('ipaddr') + ipport = self.param_value('ipport') + try: + self.clientsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.clientsock.connect((ipaddr, ipport)) + except Exception as e: + raise der.DERError('Connect-Failure(Monitor),%s,%s' % (ipaddr, ipport)) + + def close(self): + if self.clientsock is not None: + self.clientsock.close() + + def info(self): + """ Get DER device information. + + Params: + Manufacturer + Model + Version + Options + SerialNumber + + :return: Dictionary of information elements. + """ + + try: + params = {} + params['Manufacturer'] = 'Sunrex' + params['Model'] = None + params['Options'] = None + params['Version'] = None + params['SerialNumber'] = None + except Exception as e: + raise der.DERError(str(e)) + + return params + + def send_command(self, cmd=None): + if cmd is not None: + try: + self.clientsock.send(cmd_str) + except Exception as e: + raise der.DERError('Communication Failure with IP: %s, Port: %s, Error: %s' % + (self.ipaddr, self.ipport, e)) + + def freq_watt(self, params=None): + """ Get/set freq/watt control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for freq/watt control. + """ + try: + if params is not None: # write the parameters + cmd_str = '' + + ena = params.get('Ena') + if ena is not None: + if ena is True: + pass # command to enable + else: + pass # command to disable + + curve = params.get('curve') + if curve is not None: + # construct the FW command string + cmd_str = ':PCS:SABT F2 ' + for fw in curve: + cmd_str += four_digit_hex(fw) + ',' + bcc = str(cmd_str) + cmd_str += two_digit_hex(bcc) + '\n' + + win_tms = params.get('WinTms') + if win_tms is not None: + pass # add time window to the cmd_str + + rmp_tms = params.get('RmpTms') + if rmp_tms is not None: + pass # add ramp time to the cmd_str + + rvrt_tms = params.get('RvrtTms') + if rvrt_tms is not None: + pass # add revert time to the cmd_str + + self.ts.debug(cmd_str) + self.send_command(cmd_str) + + else: # read the parameters + params = {} + params['Ena'] = True + params['ActCrv'] = 1 + params['NCrv'] = 1 + params['NPt'] = 4 + params['WinTms'] = None + params['RmpTms'] = None + params['RvrtTms'] = None + params['curve'] = self.freq_watt_curve(id=1) + except Exception as e: + raise der.DERError(str(e)) + + def freq_watt_curve(self, id, params=None): + """ Get/set freq/watt curve + hz [] - List of frequency curve points + w [] - List of power curve points + CrvNam - Optional description for curve. (Max 16 chars) + RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of + snap shot function. + SnptW - 1=enable snapshot/capture mode + WRef - Reference active power (default = WMax). + WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining + power output. + WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. + ReadOnly - 0 = READWRITE, 1 = READONLY + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for freq/watt curve. + """ + return None + + def active_power(self, params=None): + """ Get/set active power of EUT + + Params: + Ena - Enabled (True/False) + P - Active power in %Wmax (positive is exporting (discharging), negative is importing (charging) power) + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HFRT control. + """ + try: + if params is not None: + + ena = params.get('Ena') + if ena is not None: + if ena is True: + pass + else: + pass + + power_set = params.get('P') + + # construct the power command string + cmd_str = ':PCS:SABT ES ' + for p in power_set: + cmd_str += four_digit_hex(p) + ',' + bcc = str(cmd_str) + cmd_str += two_digit_hex(bcc) + '\n' + self.ts.debug(cmd_str) + self.send_command(cmd_str) + + else: + params = {} + params['Ena'] = True + params['P'] = None + params['WinTms'] = None + params['RmpTms'] = None + params['RvrtTms'] = None + + except Exception as e: + raise der.DERError(str(e)) + + return params + + def reactive_power(self, params=None): + """ Set the reactive power + + Params: + Ena - Enabled (True/False) + VArPct_Mod - Reactive power mode + # 'None' : 0, + # 'WMax': 1, + # 'VArMax': 2, + # 'VArAval': 3, + VArWMaxPct - Reactive power in percent of WMax. + VArMaxPct - Reactive power in percent of VArMax. + VArAvalPct - Reactive power in percent of VArAval. + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for Q control. + """ + try: + if params is not None: + + ena = params.get('Ena') + if ena is not None: + if ena is True: + pass + else: + pass + + var_pct_mod = params.get('VArPct_Mod') + var_w_max_pct = params.get('VArWMaxPct') + var_max_pct = params.get('VArMaxPct') + var_aval_pct = params.get('VArAvalPct') + if var_pct_mod is not None: + q_set = 0 + elif var_w_max_pct is not None: + q_set = var_w_max_pct + elif var_max_pct is not None: + q_set = var_max_pct + elif var_aval_pct is not None: + q_set = var_aval_pct + else: + self.ts.log_warning('No reactive power setting provided.') + q_set = [] + + # create reactive power command + cmd_str = ':PCS:SABT V3 ' + for q in q_set: + cmd_str += four_digit_hex(q) + ',' + bcc = str(cmd_str) + cmd_str += two_digit_hex(bcc) + '\n' + + self.ts.debug(cmd_str) + self.send_command(cmd_str) + + else: + params = {} + params['Ena'] = True + params['VArPct_Mod'] = None + params['VArWMaxPct'] = None + params['VArMaxPct'] = None + params['VArAvalPct'] = None + + except Exception as e: + raise der.DERError(str(e)) + + return params + + + def volt_var(self, params=None): + """ Get/set volt/var control + + Params: + Ena - Enabled (True/False) + ActCrv - Active curve number (0 - no active curve) + NCrv - Number of curves supported + NPt - Number of points supported per curve + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/var control. + """ + if self.inv is None: + raise der.DERError('DER not initialized') + + try: + if params is not None: + cmd_str = '' + ena = params.get('Ena') + if ena is not None: + if ena is True: + cmd_str = ':PCS:SABT V1 ' + else: + cmd_str = ':PCS:SABT V4 ' + + curve = params.get('curve') # Must write curve first because there is a read() in volt_var_curve + # construct the power command string + for vv in curve: + cmd_str += four_digit_hex(vv) + ',' + bcc = str(cmd_str) + cmd_str += two_digit_hex(bcc) + '\n' + + win_tms = params.get('WinTms') + if win_tms is not None: + pass + rmp_tms = params.get('RmpTms') + if rmp_tms is not None: + pass + rvrt_tms = params.get('RvrtTms') + if rvrt_tms is not None: + pass + + self.ts.debug(cmd_str) + self.send_command(cmd_str) + + else: + params = {} + params['Ena'] = True + params['ActCrv'] = None + params['NCrv'] = None + params['NPt'] = None + params['WinTms'] = None + params['RmpTms'] = None + params['RvrtTms'] = None + params['curve'] = self.volt_var_curve(id=1) # use 1 as default + + except Exception as e: + raise der.DERError(str(e)) + + return params + + def volt_var_curve(self, id, params=None): + """ Get/set volt/var curve + v [] - List of voltage curve points + var [] - List of var curve points based on DeptRef + DeptRef - Dependent reference type: 'VAR_MAX_PCT', 'VAR_AVAL_PCT', 'VA_MAX_PCT', 'W_MAX_PCT' + RmpTms - Ramp timer + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for volt/var curve control. + """ + return None + + + def fixed_pf(self, params=None): + """ Get/set fixed power factor control settings. + + Params: + Ena - Enabled (True/False) + PF - Power Factor set point + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for fixed factor. + """ + + try: + if params is not None: + + ena = params.get('Ena') + if ena is not None: + if ena is True: + pass + else: + pass + + pf = params.get('PF') + # create reactive power command + cmd_str = ':PCS:SABT N3 ' + cmd_str += four_digit_hex(pf) + ',' + bcc = str(cmd_str) + cmd_str += two_digit_hex(bcc) + '\n' + + win_tms = params.get('WinTms') + if win_tms is not None: + pass + rmp_tms = params.get('RmpTms') + if rmp_tms is not None: + pass + rvrt_tms = params.get('RvrtTms') + if rvrt_tms is not None: + pass + + self.ts.debug(cmd_str) + self.send_command(cmd_str) + + else: + params = {} + params['Ena'] = True + params['PF'] = None + params['WinTms'] = None + params['RmpTms'] = None + params['RvrtTms'] = None + except Exception as e: + raise der.DERError(str(e)) + + return params diff --git a/Lib/svpelab/der_sunspec.py b/Lib/svpelab/der_sunspec.py index 134094b..e5ad988 100644 --- a/Lib/svpelab/der_sunspec.py +++ b/Lib/svpelab/der_sunspec.py @@ -31,13 +31,10 @@ """ import os - import sunspec.core.client as client - -import der +from . import der import script - sunspec_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], 'mode': 'SunSpec' @@ -53,30 +50,36 @@ def params(info, group_name): info.param_add_value(gname('mode'), mode) info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) - info.param(pname('ifc_type'), label='Interface Type', default=client.RTU, values=[client.RTU, client.TCP, client.MAPPED]) + info.param(pname('ifc_type'), label='Interface Type', default=client.RTU, + values=[client.RTU, client.TCP, client.MAPPED]) # RTU parameters - info.param(pname('ifc_name'), label='Interface Name', default='COM3', active=pname('ifc_type'), active_value=[client.RTU], + info.param(pname('ifc_name'), label='Interface Name', default='COM3', active=pname('ifc_type'), + active_value=[client.RTU], desc='Select the communication port from the UMS computer to the EUT.') info.param(pname('baudrate'), label='Baud Rate', default=9600, values=[9600, 19200], active=pname('ifc_type'), active_value=[client.RTU]) info.param(pname('parity'), label='Parity', default='N', values=['N', 'E'], active=pname('ifc_type'), active_value=[client.RTU]) # TCP parameters - info.param(pname('ipaddr'), label='IP Address', default='192.168.0.170', active=pname('ifc_type'), + info.param(pname('ipaddr'), label='IP Address', default='192.168.137.60', active=pname('ifc_type'), active_value=[client.TCP]) - info.param(pname('ipport'), label='IP Port', default=502, active=pname('ifc_type'), active_value=[client.TCP]) - info.param(pname('tls'), label='TLS Client', default=False, active=pname('ifc_type'), active_value=[client.TCP], - desc='Enable TLS (Modbus/TCP Security).') - info.param(pname('cafile'), label='CA Certificate', default=None, active=pname('ifc_type'), active_value=[client.TCP], + info.param(pname('ipport'), label='IP Port', default=1502, active=pname('ifc_type'), active_value=[client.TCP]) + info.param(pname('tls'), label='TLS Client', default="False", values=['True', 'False'], active=pname('ifc_type'), + active_value=[client.TCP], desc='Enable TLS (Modbus/TCP Security).') + info.param(pname('cafile'), label='CA Certificate', default="False", values=['True', 'False'], active=pname('tls'), + active_value=['True'], desc='Path to certificate authority (CA) certificate to use for validating server certificates.') - info.param(pname('certfile'), label='Client TLS Certificate', default=None, active=pname('ifc_type'), active_value=[client.TCP], + info.param(pname('certfile'), label='Client TLS Certificate', default="False", values=['True', 'False'], + active=pname('tls'), active_value=['True'], desc='Path to client TLS certificate to use for client authentication.') - info.param(pname('keyfile'), label='Client TLS Key', default=None, active=pname('ifc_type'), active_value=[client.TCP], + info.param(pname('keyfile'), label='Client TLS Key', default="False", values=['True', 'False'], + active=pname('tls'), active_value=['True'], desc='Path to client TLS key to use for client authentication.') - info.param(pname('insecure_skip_tls_verify'), label='Skip TLS Verification', default=False, active=pname('ifc_type'), active_value=[client.TCP], + info.param(pname('insecure_skip_tls_verify'), label='Skip TLS Verification', default="False", + values=['True', 'False'], active=pname('tls'), active_value=['True'], desc='Skip Verification of Server TLS Certificate.') # Mapped parameters - info.param(pname('map_name'), label='Map File', default='mbmap.xml',active=pname('ifc_type'), + info.param(pname('map_name'), label='Map File', default='mbmap.xml', active=pname('ifc_type'), active_value=[client.MAPPED], ptype=script.PTYPE_FILE) info.param(pname('slave_id'), label='Slave Id', default=1) @@ -148,11 +151,13 @@ def params(info, group_name): VOLTVAR_VARMAX = 2 VOLTVAR_VARAVAL = 3 + class DER(der.DER): def __init__(self, ts, group_name): der.DER.__init__(self, ts, group_name) self.inv = None + self.ts = ts def param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) @@ -176,9 +181,18 @@ def open(self): skip_verify = self.param_value('insecure_skip_tls_verify') slave_id = self.param_value('slave_id') - self.inv = client.SunSpecClientDevice(ifc_type, slave_id=slave_id, name=ifc_name, baudrate=baudrate, - parity=parity, ipaddr=ipaddr, ipport=ipport, - tls=tls, cafile=cafile, certfile=certfile, keyfile=keyfile, insecure_skip_tls_verify=skip_verify) + try: # attempt to use pysunspec that supports TLS encryption + self.inv = client.SunSpecClientDevice(ifc_type, slave_id=slave_id, name=ifc_name, baudrate=baudrate, + parity=parity, ipaddr=ipaddr, ipport=ipport, + tls=tls, cafile=cafile, certfile=certfile, keyfile=keyfile, + insecure_skip_tls_verify=skip_verify) + except Exception as e: # fallback to unencrypted version + if self.ts is not None: + self.ts.log('Could not create Modbus client with encryption: %s. Attempted unencrypted option.') + else: + print('Could not create Modbus client with encryption: %s. Attempted unencrypted option.') + self.inv = client.SunSpecClientDevice(ifc_type, slave_id=slave_id, name=ifc_name, baudrate=baudrate, + parity=parity, ipaddr=ipaddr, ipport=ipport) def close(self): if self.inv is not None: @@ -212,7 +226,7 @@ def info(self): params['SerialNumber'] = self.inv.common.SN else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -258,7 +272,7 @@ def nameplate(self): params['MaxDisChaRte'] = self.inv.nameplate.MaxDisChaRte else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -311,7 +325,7 @@ def measurements(self): params['EvtVnd4'] = self.inv.inverter.EvtVnd4 else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -342,7 +356,7 @@ def settings(self, params=None): try: if 'settings' in self.inv.models: if params is not None: - for key, value in params.iteritems(): + for key, value in params.items(): self.inv.settings[key] = value self.inv.settings.write() else: @@ -366,7 +380,7 @@ def settings(self, params=None): params['VArAct'] = self.inv.settings.VArAct else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -399,7 +413,7 @@ def conn_status(self, params=None): params['EPC_Connected'] = (ecp_conn_bitfield & ECPCONN_CONNECTED) == ECPCONN_CONNECTED else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -433,7 +447,7 @@ def controls_status(self, params=None): params['HFRT'] = (status_bitfield & STACTCTL_HFRT) == STACTCTL_HFRT else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -479,7 +493,7 @@ def connect(self, params=None): params['RvrtTms'] = self.inv.controls.Conn_RvrtTms else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -536,7 +550,7 @@ def fixed_pf(self, params=None): params['RvrtTms'] = self.inv.controls.OutPFSet_RvrtTms else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -592,7 +606,7 @@ def limit_max_power(self, params=None): params['RvrtTms'] = self.inv.controls.WMaxLimPct_RvrtTms else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -614,11 +628,14 @@ def volt_var(self, params=None): """ if self.inv is None: raise der.DERError('DER not initialized') - try: if 'volt_var' in self.inv.models: + self.inv.volt_var.read() if params is not None: - curve = params.get('curve') ## Must write curve first because there is a read() in volt_var_curve + curve = params.get('curve') # Must write curve first because there is a read() in volt_var_curve + act_crv = params.get('ActCrv') + if act_crv is None: + act_crv = 1 if curve is not None: self.volt_var_curve(id=act_crv, params=curve) ena = params.get('Ena') @@ -627,12 +644,12 @@ def volt_var(self, params=None): self.inv.volt_var.ModEna = 1 else: self.inv.volt_var.ModEna = 0 - act_crv = params.get('ActCrv') if act_crv is not None: self.inv.volt_var.ActCrv = act_crv else: self.inv.volt_var.ActCrv = 1 win_tms = params.get('WinTms') + self.ts.log(win_tms) if win_tms is not None: self.inv.volt_var.WinTms = win_tms rmp_tms = params.get('RmpTms') @@ -642,10 +659,11 @@ def volt_var(self, params=None): if rvrt_tms is not None: self.inv.volt_var.RvrtTms = rvrt_tms self.inv.volt_var.write() + else: params = {} self.inv.volt_var.read() - if self.inv.volt_var.ModEna == 0: + if self.inv.volt_var.ModEna == 0 or self.inv.volt_var.ModEna is None: params['Ena'] = False else: params['Ena'] = True @@ -655,15 +673,49 @@ def volt_var(self, params=None): params['WinTms'] = self.inv.volt_var.WinTms params['RmpTms'] = self.inv.volt_var.RmpTms params['RvrtTms'] = self.inv.volt_var.RvrtTms - if self.inv.volt_var.ActCrv != 0: - params['curve'] = self.volt_var_curve(id=self.inv.volt_var.ActCrv) + + act_crv = self.inv.volt_var.ActCrv + if act_crv != 0: + if act_crv is not None: + params['curve'] = self.volt_var_curve(id=act_crv) + else: + params['curve'] = self.volt_var_curve(id=1) # use 1 as default + else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params + def validate_volt_var(self, params=None): + """ validate volt/var curve data. + v [] - List of voltage curve points + var [] - List of var curve points based on DeptRef + + :param params: Dictionary of parameters; we only use the v[] and var[]. + """ + if params is None: + return + + v = params['v'] + var = params['var'] + + # Simple check to validate length correspondence betwee points. + if len(v) != len(var): + raise der.DERError('Unaligned v/var point totals; (%d) v and (%d) var' % (len(v), len(var))) + + # We validate quadrant of each v/var pair; the origin starts at (100, 0). + for idx in range(len(v)): + v_measure = v[idx] #The values are pu, so it is necessary multiplicate per 100 to validate the test + var_measure = var[idx] #The values are pu, so it is necessary multiplicate per 100 to validate the test + + if (v_measure > 100 and var_measure > 0) or (v_measure < 100 and var_measure < 0): + raise der.DERError( + 'Unsecure quadrant location for power system operations @ index %d; (%d) v and (%d) var' + % (idx, v_measure, var_measure) + ) + def volt_var_curve(self, id, params=None): """ Get/set volt/var curve v [] - List of voltage curve points @@ -682,11 +734,15 @@ def volt_var_curve(self, id, params=None): try: if 'volt_var' in self.inv.models: self.inv.volt_var.read() - if int(id) > int(self.inv.volt_var.NCrv): - raise der.DERError('Curve id out of range: %s' % (id)) + n_crv = self.inv.volt_var.NCrv + self.ts.log(n_crv) + if n_crv is not None: + if int(id) > int(n_crv): + raise der.DERError('Curve id out of range: %s' % (id)) curve = self.inv.volt_var.curve[id] if params is not None: + self.validate_volt_var(params=params) dept_ref = params.get('DeptRef') if dept_ref is not None: dept_ref_id = volt_var_dept_ref.get(dept_ref) @@ -704,14 +760,16 @@ def volt_var_curve(self, id, params=None): if rmp_inc_tmm is not None: curve.RmpIncTmm = rmp_inc_tmm - n_pt = int(self.inv.volt_var.NPt) + n_pt = self.inv.volt_var.NPt + if n_pt is None: + n_pt = 4 # Assume 4 points in the curve # set voltage points v = params.get('v') if v is not None: v_len = len(v) - if v_len > n_pt: + if v_len > int(n_pt): raise der.DERError('Voltage point count out of range: %d' % (v_len)) - for i in xrange(v_len): # SunSpec point index starts at 1 + for i in range(v_len): # SunSpec point index starts at 1 v_point = 'V%d' % (i + 1) setattr(curve, v_point, v[i]) # set var points @@ -720,7 +778,7 @@ def volt_var_curve(self, id, params=None): var_len = len(var) if var_len > n_pt: raise der.DERError('VAr point count out of range: %d' % (var_len)) - for i in xrange(var_len): # SunSpec point index starts at 1 + for i in range(var_len): # SunSpec point index starts at 1 var_point = 'VAr%d' % (i + 1) setattr(curve, var_point, var[i]) @@ -730,7 +788,7 @@ def volt_var_curve(self, id, params=None): act_pt = curve.ActPt dept_ref = volt_var_dept_ref.get(curve.DeptRef) if dept_ref is None: - raise der.DERError('DeptRef out of range: %s' % (dept_ref)) + der.DERError('DeptRef out of range: %s' % (dept_ref)) params['DeptRef'] = dept_ref params['RmpTms'] = curve.RmpTms params['RmpDecTmm'] = curve.RmpDecTmm @@ -739,17 +797,18 @@ def volt_var_curve(self, id, params=None): v = [] var = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 - v_point = 'V%d' % i - var_point = 'VAr%d' % i - v.append(getattr(curve, v_point)) - var.append(getattr(curve, var_point)) + if act_pt is not None: + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 + v_point = 'V%d' % i + var_point = 'VAr%d' % i + v.append(getattr(curve, v_point)) + var.append(getattr(curve, var_point)) params['v'] = v params['var'] = var else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -765,6 +824,21 @@ def freq_watt(self, params=None): WinTms - Randomized start time delay in seconds RmpTms - Ramp time in seconds to updated output level RvrtTms - Reversion time in seconds + curve - dict of curve parameters: + hz [] - List of frequency curve points + w [] - List of power curve points + CrvNam - Optional description for curve. (Max 16 chars) + RmpPT1Tms - The time of the PT1 in seconds (time to accomplish a change of 95%). + RmpDecTmm - Ramp decrement timer + RmpIncTmm - Ramp increment timer + RmpRsUp - The maximum rate at which the power may be increased after releasing the frozen value of + snap shot function. + SnptW - 1=enable snapshot/capture mode + WRef - Reference active power (default = WMax). + WRefStrHz - Frequency deviation from nominal frequency at the time of the snapshot to start constraining + power output. + WRefStopHz - Frequency deviation from nominal frequency at which to release the power output. + ReadOnly - 0 = READWRITE, 1 = READONLY :param params: Dictionary of parameters to be updated. :return: Dictionary of active settings for freq/watt control. @@ -817,13 +891,13 @@ def freq_watt(self, params=None): params['curve'] = self.freq_watt_curve(id=self.inv.freq_watt.ActCrv) else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params def freq_watt_curve(self, id, params=None): - """ Get/set volt/var curve + """ Get/set freq/watt curve hz [] - List of frequency curve points w [] - List of power curve points CrvNam - Optional description for curve. (Max 16 chars) @@ -891,7 +965,7 @@ def freq_watt_curve(self, id, params=None): hz_len = len(hz) if hz_len > n_pt: raise der.DERError('Freq point count out of range: %d' % (hz_len)) - for i in xrange(hz_len): # SunSpec point index starts at 1 + for i in range(hz_len): # SunSpec point index starts at 1 hz_point = 'Hz%d' % (i + 1) setattr(curve, hz_point, hz[i]) # set watt points @@ -900,7 +974,7 @@ def freq_watt_curve(self, id, params=None): w_len = len(w) if w_len > n_pt: raise der.DERError('Watt point count out of range: %d' % (w_len)) - for i in xrange(w_len): # SunSpec point index starts at 1 + for i in range(w_len): # SunSpec point index starts at 1 w_point = 'W%d' % (i + 1) setattr(curve, w_point, w[i]) @@ -921,9 +995,9 @@ def freq_watt_curve(self, id, params=None): params['id'] = id #also store the curve number hz = [] w = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 hz_point = 'Hz%d' % i - w_point = 'VAr%d' % i + w_point = 'W%d' % i hz.append(getattr(curve, hz_point)) w.append(getattr(curve, w_point)) params['hz'] = hz @@ -931,7 +1005,7 @@ def freq_watt_curve(self, id, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -941,7 +1015,7 @@ def freq_watt_param(self, params=None): Params: Ena - Enabled (True/False) - HysEna - Enable hysterisis (True/False) + HysEna - Enable hysteresis (True/False) WGra - The slope of the reduction in the maximum allowed watts output as a function of frequency. HzStr - The frequency deviation from nominal frequency (ECPNomHz) at which a snapshot of the instantaneous power output is taken to act as the CAPPED power level (PM) and above which reduction in power @@ -1005,11 +1079,16 @@ def freq_watt_param(self, params=None): else: params = None - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params + def soft_start_ramp_rate(self, params=None): + pass + + def ramp_rate(self, params=None): + pass def volt_watt(self, params=None): """ Get/set volt/watt control @@ -1031,7 +1110,7 @@ def volt_watt(self, params=None): RmpIncTmm - Ramp increment timer :param params: Dictionary of parameters to be updated. - :return: Dictionary of active settings for volt/var control. + :return: Dictionary of active settings for volt/watt control. """ if self.inv is None: raise der.DERError('DER not initialized') @@ -1064,6 +1143,7 @@ def volt_watt(self, params=None): curve = params.get('curve') if curve is not None: # curve paramaters + id = self.inv.volt_watt.ActCrv if int(id) > int(self.inv.volt_watt.NCrv): raise der.DERError('Curve id out of range: %s' % (id)) curve = self.inv.volt_watt.curve[id] @@ -1072,7 +1152,6 @@ def volt_watt(self, params=None): dept_ref_id = volt_watt_dept_ref.get(dept_ref) if dept_ref_id is None: raise der.DERError('Unsupported DeptRef: %s' % (dept_ref)) - curve.DeptRef = dept_ref_id rmp_tms = params.get('RmpTms') if rmp_tms is not None: @@ -1091,7 +1170,7 @@ def volt_watt(self, params=None): v_len = len(v) if v_len > n_pt: raise der.DERError('Voltage point count out of range: %d' % (v_len)) - for i in xrange(v_len): # SunSpec point index starts at 1 + for i in range(v_len): # SunSpec point index starts at 1 v_point = 'V%d' % (i + 1) setattr(curve, v_point, v[i]) # set watt points @@ -1100,7 +1179,7 @@ def volt_watt(self, params=None): watt_len = len(watt) if watt_len > n_pt: raise der.DERError('W point count out of range: %d' % (watt_len)) - for i in xrange(watt_len): # SunSpec point index starts at 1 + for i in range(watt_len): # SunSpec point index starts at 1 watt_point = 'W%d' % (i + 1) setattr(curve, watt_point, watt[i]) @@ -1110,6 +1189,7 @@ def volt_watt(self, params=None): params = {} c_params = {} self.inv.volt_watt.read() + id = self.inv.volt_watt.ActCrv curve = self.inv.volt_watt.curve[id] if self.inv.volt_watt.ModEna == 0: params['Ena'] = False @@ -1126,26 +1206,25 @@ def volt_watt(self, params=None): # curve parameters act_pt = curve.ActPt dept_ref = volt_watt_dept_ref.get(curve.DeptRef) - if dept_ref is None: - raise der.DERError('DeptRef out of range: %s' % (dept_ref)) c_params['DeptRef'] = dept_ref - c_params['RmpTms'] = curve.RmpTms - c_params['RmpDecTmm'] = curve.RmpDecTmm - c_params['RmpIncTmm'] = curve.RmpIncTmm + # c_params['RmpTms'] = curve.RmpTms + # c_params['RmpDecTmm'] = curve.RmpDecTmm + # c_params['RmpIncTmm'] = curve.RmpIncTmm c_params['id'] = id # also store the curve number v = [] - var = [] - for i in xrange(1, act_pt + 1): # SunSpec point index starts at 1 + w = [] + for i in range(1, act_pt + 1): # SunSpec point index starts at 1 v_point = 'V%d' % i - var_point = 'VAr%d' % i + w_point = 'W%d' % i v.append(getattr(curve, v_point)) - var.append(getattr(curve, var_point)) + w.append(getattr(curve, w_point)) c_params['v'] = v - c_params['var'] = var + c_params['w'] = w params['curve'] = c_params else: params = None - except Exception, e: + + except Exception as e: raise der.DERError(str(e)) return params @@ -1156,10 +1235,10 @@ def reactive_power(self, params=None): Params: Ena - Enabled (True/False) VArPct_Mod - Reactive power mode - # 'None' : 0, - # 'WMax': 1, - # 'VArMax': 2, - # 'VArAval': 3, + 'None' : 0, + 'WMax': 1, + 'VArMax': 2, + 'VArAval': 3, VArWMaxPct - Reactive power in percent of WMax. VArMaxPct - Reactive power in percent of VArMax. VArAvalPct - Reactive power in percent of VArAval. @@ -1205,7 +1284,7 @@ def reactive_power(self, params=None): params['VArMaxPct'] = self.inv.controls.VArMaxPct params['VArAvalPct'] = self.inv.controls.VArAvalPct - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1278,7 +1357,7 @@ def reactive_power_via_vv(self, params=None): params['curve'] = self.volt_var_curve(id=self.inv.volt_var.ActCrv) params['Q'] = self.inv.volt_var_curve.var[0] - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1339,7 +1418,7 @@ def active_power(self, params=None): params['RmpTms'] = storage_params['InOutWRte_RmpTms'] params['RvrtTms'] = storage_params['InOutWRte_RvrtTms'] - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1427,7 +1506,7 @@ def storage(self, params=None): params['InOutWRte_RvrtTms'] = self.inv.volt_var.InOutWRte_RvrtTms params['InOutWRte_RmpTms'] = self.inv.volt_var.InOutWRte_RmpTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1472,7 +1551,7 @@ def frt_stay_connected_high(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.hfrt.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.hfrt.ActCrv # assume the active curve is the one being changed @@ -1499,7 +1578,7 @@ def frt_stay_connected_high(self, params=None): params['RmpTms'] = self.inv.hfrt.RmpTms params['RvrtTms'] = self.inv.hfrt.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1544,7 +1623,7 @@ def frt_stay_connected_low(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.lfrt.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.lfrt.ActCrv # assume the active curve is the one being changed @@ -1571,7 +1650,7 @@ def frt_stay_connected_low(self, params=None): params['RmpTms'] = self.inv.lfrt.RmpTms params['RvrtTms'] = self.inv.lfrt.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1602,7 +1681,7 @@ def frt_trip_high(self, params=None): else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1634,7 +1713,7 @@ def frt_trip_low(self, params=None): else: params = {} - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1679,7 +1758,7 @@ def vrt_stay_connected_high(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.hvrtc.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.hvrtc.ActCrv # assume the active curve is the one being changed @@ -1706,7 +1785,7 @@ def vrt_stay_connected_high(self, params=None): params['RmpTms'] = self.inv.hvrtc.RmpTms params['RvrtTms'] = self.inv.hvrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1752,7 +1831,7 @@ def vrt_stay_connected_low(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.lvrtc.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.lvrtc.ActCrv # assume the active curve is the one being changed @@ -1779,7 +1858,7 @@ def vrt_stay_connected_low(self, params=None): params['RmpTms'] = self.inv.lvrtc.RmpTms params['RvrtTms'] = self.inv.lvrtc.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params @@ -1824,7 +1903,7 @@ def vrt_trip_high(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.hvrtd.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.hvrtd.ActCrv # assume the active curve is the one being changed @@ -1851,12 +1930,11 @@ def vrt_trip_high(self, params=None): params['RmpTms'] = self.inv.hvrtd.RmpTms params['RvrtTms'] = self.inv.hvrtd.RvrtTms - except Exception, e: + except Exception as e: raise der.DERError(str(e)) return params - def vrt_trip_low(self, params=None): """ Get/set low voltage ride through (must trip curve) @@ -1897,7 +1975,7 @@ def vrt_trip_low(self, params=None): rvrt_tms = params.get('RvrtTms') if rvrt_tms is not None: self.inv.lvrtd.RvrtTms = rvrt_tms - for i in xrange(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) + for i in range(1, params['NPt'] + 1): # Uses the SunSpec indexing rules (start at 1) time_point = 'Tms%d' % i param_time_point = params.get(time_point) curve_num = self.inv.lvrtd.ActCrv # assume the active curve is the one being changed @@ -1924,7 +2002,38 @@ def vrt_trip_low(self, params=None): params['RmpTms'] = self.inv.lvrtd.RmpTms params['RvrtTms'] = self.inv.lvrtd.RvrtTms - except Exception, e: + except Exception as e: + raise der.DERError(str(e)) + + return params + + def ramp_rates(self, params=None): + """ Get/set ramp rate control + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for ramp rate control. + """ + if self.inv is None: + raise der.DERError('DER not initialized') + + try: + if 'ext_settings' in self.inv.models: + if params is not None: + rr = params.get('ramp_rate') + ss = params.get('soft_start') + if rr is not None: + self.inv.ext_settings.NomRmpUpRte = rr + if ss is not None: + self.inv.ext_settings.ConnRmpUpRte = ss + self.inv.ext_settings.write() + else: + params = {} + self.inv.ext_settings.read() + params['ramp_rate'] = self.inv.ext_settings.NomRmpUpRte + params['soft_start'] = self.inv.ext_settings.ConnRmpUpRte + else: + params = None + except Exception as e: raise der.DERError(str(e)) - return params \ No newline at end of file + return params diff --git a/Lib/svpelab/device_awg400.py b/Lib/svpelab/device_awg400.py index 8abc903..a47c8d4 100644 --- a/Lib/svpelab/device_awg400.py +++ b/Lib/svpelab/device_awg400.py @@ -30,10 +30,7 @@ Questions can be directed to support@sunspec.org """ -import time - -import vxi11 - +import pyvisa as visa class DeviceError(Exception): """ @@ -51,124 +48,191 @@ def __init__(self, params): self.rm = None # Connection to instrument for VISA-GPIB self.conn = None - self.open() - def open(self): try: + self.rm = visa.ResourceManager() if self.params['comm'] == 'Network': try: - self.vx = vxi11.Instrument(self.params['ip_addr']) - except Exception, e: + self._host = self.params['ip_addr'] + self._port = 4000 + self.conn = self.rm.open_resource("TCPIP::{0}::{1}::SOCKET".format(self._host,self._port),read_termination='\n') + except Exception as e: raise DeviceError('AWG400 communication error: %s' % str(e)) elif self.params['comm'] == 'GPIB': raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + 'Please use VISA which supports also GPIB devices') elif self.params['comm'] == 'VISA': try: - # sys.path.append(os.path.normpath(self.visa_path)) - import visa - self.rm = visa.ResourceManager() self.conn = self.rm.open_resource(self.params['visa_address']) - except Exception, e: + except Exception as e: raise DeviceError('AWG400 communication error: %s' % str(e)) else: raise ValueError('Unknown communication type %s. Use GPIB or VISA' % self.params['comm']) - except Exception, e: + except Exception as e: raise DeviceError(str(e)) + + self.funcgen_mode() + pass def close(self): if self.params['comm'] == 'Network': - if self.vx is not None: - self.vx.close() - self.vx = None + try: + if self.conn is not None: + self.conn.close() + except Exception as e: + raise DeviceError('AWG400 communication error: %s' % str(e)) elif self.params['comm'] == 'GPIB': raise NotImplementedError('The driver for plain GPIB is not implemented yet.') elif self.params['comm'] == 'VISA': try: - if self.rm is not None: - if self.conn is not None: - self.conn.close() - self.rm.close() - except Exception, e: + if self.conn is not None: + self.conn.close() + except Exception as e: raise DeviceError('AWG400 communication error: %s' % str(e)) else: raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.params['comm']) def cmd(self, cmd_str): - if self.params['comm'] == 'Network': - try: - self.vx.write(cmd_str) - except Exception, e: - raise DeviceError('AWG400 communication error: %s' % str(e)) - - elif self.params['comm'] == 'VISA': - try: - self.conn.write(cmd_str) - except Exception, e: - raise DeviceError('AWG400 communication error: %s' % str(e)) + if self.params['comm'] == 'VISA' or self.params['comm'] == 'Network': + self.conn.write(cmd_str) def query(self, cmd_str): - resp = '' - if self.params['comm'] == 'Network': - try: - resp = self.vx.ask(cmd_str) - except Exception, e: - raise DeviceError('AWG400 communication error: %s' % str(e)) - elif self.params['comm'] == 'VISA': + if self.params['comm'] == 'VISA' or self.params['comm'] == 'Network': self.cmd(cmd_str) resp = self.conn.read() return resp def info(self): - return self.query('*IDN?') + try: + resp = self.conn.query("*IDN?") + except Exception as e: + raise DeviceError('AWG400 communication error: %s' % str(e)) + return resp - def load_config(self, params): + def load_config(self,sequence = None): """ Enable channels :param params: dict containing following possible elements: 'sequence_filename': :return: """ - pass + + # Load configuration settings from sequence file textbox + self.cmd("AWGControl:SREStore '{}','MAIN'".format(sequence)) + + def funcgen_mode(self): + """ + Set the AWG in function generator + :return: The generator mode + """ + + if self.params['gen_mode'] == 'ON': + self.cmd("AWGControl:FG ON") + else: + self.cmd("AWGControl:FG OFF") + + def start(self): """ Start sequence execution :return: """ + # self.conn.write("AWGControl:RUN:IMMediate") + self.conn.write("AWGControl:EVENt:LOGic:IMMediate") pass def stop(self): """ - Start sequence execution + Stop sequence execution :return: """ + + self.conn.write("AWGControl:STOP:IMMediate") + # Turn off all channel + for i in range(1,4): + self.conn.write("OUTput{}:STATe OFF".format(i)) + pass - def chan_enable(self, chans): + def trigger(self): + """ + Info : This command is equivalent to pressing the FORCE TRIGGER button front panel + Send trigger event execution + :return: + """ + self.cmd("*TRG") + + def next_event(self): + """ + Send event transient execution + :return: + """ + self.conn.write("AWGControl:EVENt:LOGic:IMMediate") + + def chan_state(self, chans): """ Enable channels :param chans: list of channels to enable :return: """ + i = 1 + for chan in chans: + if chan == True : + self.cmd("OUTput{}:STATe ON".format(i)) + elif chan == False : + self.cmd("OUTput{}:STATe OFF".format(i)) + i = i + 1 pass - def chan_disable(self, chans): + + + def error(self): """ - Disable channels - :param chans: list of channels to disable - :return: + This only to have a feedback of the last operation + :return: The error of last operation """ - pass + return self.query("SYSTem:ERRor:NEXT?") + def voltage(self, voltage, channel): + """ + This command adjusts peak to peak voltage of the function waveform on selected channel. + :param voltage: The amplitude of the waveform in step of 1mV withing the range of 0.020Vpp to 2.000Vpp + :param channel: Channel to configure + """ + if self.params['gen_mode'] == 'ON': + if channel == 1: + voltage *=0.005941 + elif channel == 2: + voltage *= 0.005925 + elif channel == 3: + voltage *= 0.005891 + print(("AWGControl:FG{}:VOLTage {}".format(channel, voltage))) + self.cmd("AWGControl:FG{}:VOLTage {}".format(channel, voltage)) + + def frequency(self, frequency): + """ + This command adjusts peak to peak voltage of the function waveform on selected channel. + :param frequency: The frequency of the waveform on all channels + """ + self.cmd("AWGControl:FG:FREQuency {}".format(frequency)) -if __name__ == "__main__": + def phase(self, phase, channel): + """ + This command adjusts peak to peak voltage of the function waveform on selected channel. + :param phase: The amplitude of the waveform in step of 1mV withing the range of 0.020Vpp to 2.000Vpp + :param channel: Channel to configure + """ + self.cmd("AWGControl:FG{}:PHASe {}DEGree".format(channel, phase)) + + +if __name__ == "__main__": pass diff --git a/Lib/svpelab/device_battsim_nhr.py b/Lib/svpelab/device_battsim_nhr.py new file mode 100644 index 0000000..991fbf3 --- /dev/null +++ b/Lib/svpelab/device_battsim_nhr.py @@ -0,0 +1,879 @@ +""" +Driver for the SCPI interface for NH Research, Inc. 9200 and 9300 Battery Simulators +""" + +import pyvisa +import numpy as np + + +MODES = { + 'OFF_MODE':'0', + 'STANDBY_MODE':'1', + 'CHARGE_MODE':'2', + 'DISCHARGE_MODE':'3', + 'BATTERY_MODE':'4', + '0':'OFF_MODE', + '1':'STANDBY_MODE', + '2':'CHARGE_MODE', + '3':'DISCHARGE_MODE', + '4':'BATTERY_MODE' +} + +VOLTAGE_ENABLED = 0 # bit for 1 +CURRENT_ENABLED = 1 # bit for 2 +POWER_ENABLED = 2 # bit for 4 +RESISTANCE_ENABLED = 3 # bit for 8 + +class NHResearchError(Exception): + pass + + +class NHResearch(object): + + def __init__(self, ipaddr='127.0.0.1', ipport=5025, timeout=5): + self.ipaddr = ipaddr + self.ipport = ipport + self.timeout = timeout + self.buffer_size = 1024 + self.conn = None + self.rm = pyvisa.ResourceManager() + resource_loc = 'TCPIP::%s::INSTR' % ipaddr + self.pmod = self.rm.open_resource(resource_loc) # power module + self.clear() + self.current_sign() + + def cmd(self, cmd_str): + try: + self.pmod.write(cmd_str) + + # returns the next error number followed by its corresponding error message string + resp = self.pmod.query('SYSTem:ERRor?').strip() + if resp != '0,"No error"': + print('Error with command %s: %s' % (cmd_str, resp)) + + except Exception as e: + raise NHResearchError(str(e)) + + return resp + + def query(self, cmd_str): + try: + resp = self.pmod.query(cmd_str).strip() + except Exception as e: + raise NHResearchError(str(e)) + + return resp + + def info(self): + return self.query('*IDN?') + + def reset(self): + return self.cmd('*RST') + + def close(self): + try: + if self.pmod is not None: + self.pmod.close() + except Exception as e: + pass + finally: + self.conn = None + + def clear(self): + """ + Clears all event status registers and queues + """ + return self.cmd('*CLS') + + def status(self): + """ + Status Byte Register + 0* Busy Module is busy and NOT able to process any command 0x01 + 1* Remote Module is in remote mode 0x02 + 2* Error Queue Error in queue, use SYST:ERR? 0x04 + 3 QUES Questionable status summary. 0x08 + 4 MAV Message available 0x10 + 5 ESB Event status byte summary. See event status register. 0x20 + 6 RQS Request for service. 0x40 + 7 OPER Operation event summary. See operation event register. 0x80 + + Event Status Register + 0 OPC Operation Complete 0x01 + 1 RQC Request Control 0x02 + 2 QYE Query Error 0x04 + 3 DDE Device Dependent Error 0x08 + 4 EXE Execution Error 0x10 + 5 CME Command Error 0x20 + 6 URQ User Request 0x40 + 7 PON Power On 0x80 + + Operation Register - instrument specific, (see STATus:OPERation[:EVENt]? + TODO + + Questionable Status Register (see STATus:QUEStionable[:EVENt]?) + TODO + + :return: dict with status + """ + decimal = int(self.query('*STB?')) + flags = {} + if (decimal & (1 << 0)) == (1 << 0): + flags['Busy'] = 'True' + if (decimal & (1 << 1)) == (1 << 1): + flags['Remote'] = 'True' + if (decimal & (1 << 2)) == (1 << 2): + flags['Error Queue'] = 'True' + if (decimal & (1 << 3)) == (1 << 3): + flags['Questionable status'] = 'True' + if (decimal & (1 << 4)) == (1 << 4): + flags['Message available'] = 'True' + if (decimal & (1 << 5)) == (1 << 5): + flags['Event status byte summary'] = 'True' + if (decimal & (1 << 6)) == (1 << 6): + flags['Request for service'] = 'True' + if (decimal & (1 << 7)) == (1 << 7): + flags['Operation event summary'] = 'True' + + decimal = int(self.query('*ESR?')) + if (decimal & (1 << 0)) == (1 << 0): + flags['Operation Complete'] = 'True' + if (decimal & (1 << 1)) == (1 << 1): + flags['Request Control'] = 'True' + if (decimal & (1 << 2)) == (1 << 2): + flags['Query Error'] = 'True' + if (decimal & (1 << 3)) == (1 << 3): + flags['Device Dependent Error '] = 'True' + if (decimal & (1 << 4)) == (1 << 4): + flags['Execution Error'] = 'True' + if (decimal & (1 << 5)) == (1 << 5): + flags['Command Error'] = 'True' + if (decimal & (1 << 6)) == (1 << 6): + flags['User Request'] = 'True' + if (decimal & (1 << 7)) == (1 << 7): + flags['Power On'] = 'True' + + return flags + + def self_test(self): + if self.query('*TST?') == '0': + return 'No Errors' + + def wait(self): + return self.cmd('*WAI') + + def current_sign(self, der_ref=1): + """ + This command sets or reads the desired current sign. TRUE will return negative current that is charging, + FALSE will return negative current that is discharging. + + DER reference point will have a negative current when charging, so this value should be set to 1 + + :param der_ref: 0 or 1 for False or True + :return: + """ + if der_ref not in [0, 1]: + raise NHResearchError('Setting current direction without correct input') + + return self.cmd('CONFigure:CINegative %s' % der_ref) + + def dsp_version(self): + return self.query('DIAGnostic:DL:VERSion?') + + def scpi_version(self): + return self.query('SYSTem:VERSion?') + + def set_local(self): + """ + This command places the module in local mode during SCPI operation. The front panel keys are functional. + """ + return self.cmd('SYSTem:LOCal') + + def set_remote(self): + return self.cmd('SYSTem:REMote') + + def current_ranges(self): + ivals = {} + # These queries will retrieve the absolute minimum and maximum aperture setting for the Module. + ivals['i_min'] = self.query('INSTrument:CAPabilities:APERture:MINimum?') + ivals['i_max'] = self.query('INSTrument:CAPabilities:APERture:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum current setting for the Module. + ivals['i_charge_min'] = self.query('INSTrument:CAPabilities:CURRent:CHARge:MINimum?') + ivals['i_charge_max'] = self.query('INSTrument:CAPabilities:CURRent:CHARge:MAXimum?') + + # These queries will retrieve the range’s maximum current setting for the Module. + ivals['i_charge_range_max'] = self.query('INSTrument:CAPabilities:CURRent:CHARge:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum current setting for the Module. + ivals['i_discharge_min'] = self.query('INSTrument:CAPabilities:CURRent:DISCharge:MINimum?') + ivals['i_discharge_max'] = self.query('INSTrument:CAPabilities:CURRent:DISCharge:MAXimum?') + + # These queries will retrieve the range’s maximum current setting for the Module. + ivals['i_discharge_range_max'] = self.query('INSTrument:CAPabilities:CURRent:DISCharge:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum currents for measuring. + ivals['i_meas_min'] = self.query('INSTrument:CAPabilities:CURRent:MEASurement:MINimum?') + ivals['i_meas_max'] = self.query('INSTrument:CAPabilities:CURRent:MEASurement:MAXimum?') + + # These queries will retrieve the range’s maximum currents for measuring. + ivals['i_meas_range_max'] = self.query('INSTrument:CAPabilities:CURRent:MEASurement:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum current slew rate for the Module. + ivals['i_slew_min'] = self.query('INSTrument:CAPabilities:CURRent:SLEW:MINimum?') + ivals['i_slew_max'] = self.query('INSTrument:CAPabilities:CURRent:SLEW:MAXimum?') + + # These queries will retrieve the range’s minimum and maximum current slew rate for the Module. + ivals['i_slew_range_min'] = self.query('INSTrument:CAPabilities:CURRent:SLEW:RANGe:MINimum?') + ivals['i_slew_range_max'] = self.query('INSTrument:CAPabilities:CURRent:SLEW:RANGe:MAXimum?') + + return ivals + + def power_ranges(self): + pvals = {} + # These queries will retrieve the absolute minimum and maximum power setting for the Module. + pvals['p_charge_min'] = self.query('INSTrument:CAPabilities:POWer:CHARge:MINimum?') + pvals['p_charge_max'] = self.query('INSTrument:CAPabilities:POWer:CHARge:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum power setting for the Module. + pvals['p_discharge_min'] = self.query('INSTrument:CAPabilities:POWer:DISCharge:MINimum?') + pvals['p_discharge_max'] = self.query('INSTrument:CAPabilities:POWer:DISCharge:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum power slew rate for the Module. + pvals['p_slew_min'] = self.query('INSTrument:CAPabilities:POWer:SLEW:MINimum?') + pvals['p_slew_max'] = self.query('INSTrument:CAPabilities:POWer:SLEW:MAXimum?') + + return pvals + + def voltage_ranges(self): + vvals = {} + # These queries will retrieve the absolute minimum and maximum voltage setting for the Module. + vvals['v_min'] = self.query('INSTrument:CAPabilities:VOLT:MINimum?') + vvals['v_max'] = self.query('INSTrument:CAPabilities:VOLT:MAXimum?') + + # This query will retrieve the range’s maximum voltage setting for the Module. + vvals['v_range_max'] = self.query('INSTrument:CAPabilities:VOLT:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum voltages for measuring. + vvals['v_meas_min'] = self.query('INSTrument:CAPabilities:VOLT:MEASurement:MINimum?') + vvals['v_meas_max'] = self.query('INSTrument:CAPabilities:VOLT:MEASurement:MAXimum?') + vvals['v_meas_range_max'] = self.query('INSTrument:CAPabilities:VOLT:MEASurement:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum voltage slew rate for the Module. + vvals['v_slew_min'] = self.query('INSTrument:CAPabilities:VOLT:SLEW:MINimum?') + vvals['v_slew_max'] = self.query('INSTrument:CAPabilities:VOLT:SLEW:MAXimum?') + + # These queries will retrieve the range’s minimum and maximum voltage slew rate for the Module. + vvals['v_slew_range_min'] = self.query('INSTrument:CAPabilities:VOLT:SLEW:RANGe:MINimum?') + vvals['v_slew_range_max'] = self.query('INSTrument:CAPabilities:VOLT:SLEW:RANGe:MAXimum?') + + return vvals + + def resistance_ranges(self): + rvals = {} + # These queries will retrieve the absolute minimum and maximum resistance setting for the Module. + rvals['r_min'] = self.query('INSTrument:CAPabilities:RESistance:MINimum?') + rvals['r_max'] = self.query('INSTrument:CAPabilities:RESistance:MAXimum?') + + # These queries will retrieve the range’s minimum and maximum resistance setting for the Module. + rvals['r_range_min'] = self.query('INSTrument:CAPabilities:RESistance:RANGe:MINimum?') + rvals['r_range_max'] = self.query('INSTrument:CAPabilities:RESistance:RANGe:MAXimum?') + + # These queries will retrieve the absolute minimum and maximum resistance slew rate for the Module. + rvals['r_slew_min'] = self.query('INSTrument:CAPabilities:RESistance:SLEW:RANGe:MINimum?') + rvals['r_slew_max'] = self.query('INSTrument:CAPabilities:RESistance:SLEW:RANGe:MAXimum?') + + return rvals + + def gain_ranges(self): + gvals = {} + # These queries will retrieve the absolute minimum and maximum legal values for regulation gain and the nominal + # (default) value. + gvals['reg_gain_min'] = self.query('INSTrument:CAPabilities:RGAin:MINimum?') + gvals['reg_gain_nom'] = self.query('INSTrument:CAPabilities:RGAin:NOMinal?') + gvals['reg_gain_max'] = self.query('INSTrument:CAPabilities:RGAin:MAXimum?') + return gvals + + + def abort(self, timeout=2): + """ + This command resets the list and measurement trigger systems to the Idle state. Any list or measurement that + is in progress is immediately aborted. ABORt also resets the WTG bit in the Operation Condition Status register. + + :return: + """ + try: + return self.cmd('ABORt') + except NHResearchError: + print('Abort failure') + raise + + def measurements_get(self): + """ + Measure the voltage, current, and power of the Module + + The input voltage and current are digitized whenever a measure command is given or whenever an acquire + (INIT) trigger occurs. The capture aperture is set by SENSe:SWEep:APERture. + + MEAS:VOLT? // starts acquisition sequence then returns the voltage calculated during the acquisition. + FETCH:CURR? // returns the current calculated during the SAME acquisition as voltage. + FETCH:POW? // returns the power calculated during the SAME acquisition as voltage. + + :return: dictionary with power data with keys: 'DC_V', 'DC_I', 'DC_P' + """ + meas = {'DC_V': float(self.query('MEAS:VOLT?')), + 'DC_I': float(self.query('FETCH:CURR?')), + 'DC_P': float(self.query('FETCH:POW?'))} + return meas + + def measure_all(self): + """ + This query returns the most recent measurements that are always being made by the hardware in the background + (except while a waveform capture is being made). + + :return: dict with data + """ + + str_data = self.query('FETCh:BACKground:ALL?') + data = [float(i) for i in str_data.split(',')] + # print(data) + # print(len(data)) + data_dict = {'Voltage': data[0], + 'Current': data[1], + 'Power': data[2], + 'Ah Total': data[3], + 'Ah Charge': data[4], + 'Ah Discharge': data[5], + 'Ah Time': data[6], + 'kWA Total': data[7], + 'kWA Charge': data[8], + 'kWA Discharge': data[9], + 'kWA Time': data[10], + 'Charge Time': data[11], + 'Discharge Time': data[12], + 'VoltageNegativePeak': data[13], + 'VoltagePositivePeak': data[14], + 'CurrentNegativePeak': data[15], + 'CurrentPositivePeak': data[16], + 'PowerNegativePeak': data[17], + 'PowerPositivePeak': data[18], + 'Timestamp': data[19], + } + + return data_dict + + def measure_peaks(self): + """ + This query returns the most recent measurements that are always being made by the hardware in the background + (except while a waveform capture is being made). + + :return: dict with data + """ + + str_data = self.query('FETCh:BACKground:PEAKs?') + data = [float(i) for i in str_data.split(',')] + # print(data) + # print(len(data)) + data_dict = {'VoltageNegativePeak': data[0], + 'VoltagePositivePeak': data[1], + 'CurrentNegativePeak': data[2], + 'CurrentPositivePeak': data[3], + 'PowerNegativePeak': data[4], + 'PowerPositivePeak': data[5] + } + + return data_dict + + def measure_temp(self): + """ + Applies to 9300 ONLY. + + This query returns the most recent UUT temperature measurements that are always being made by the hardware in the + background (except while a waveform capture is being made). Temperature is degrees centigrade. + + :return: float, temp in C + """ + + return float(self.query('FETCh:BACKground:TEMP?')) + + def waverform_capture(self): + """ + These queries return an array containing the instantaneous input voltage. The array starts at index 0 if + none is specified, otherwise it starts at the start index. Waveforms are captured with every initiated + measurement based on the SENSe:SWEep:APERture, SENSe:SWEep:POINts, and SENSe:SWEep:TINTerval commands. + + :return: dict, with voltage, current, and time lists + """ + + self.cmd('INIT') # This command starts the measurement aperture. + + sample_rate = self.get_sample_rate() #SENSe:SWEep:APERture + sample_duration = self.get_aperture() # SENSe:SWEep:TINTerval + n_samples = self.get_max_samples() # SENSe:SWEep:POINts + print('sample_rate = %s, sample_duration = %s, n_samples = %s' % (sample_rate, sample_duration, n_samples)) + + # v_str += self.query('MEASure:ARRay:VOLTage?') # This would start a new sample + v_array = [] + index = 0 + more_data = True + while more_data: + data = self.query('FETCh:ARRay:VOLTage? %s' % index) + # print(data) + if data.strip() == '' or data.strip() == '3.40282347e+38' or index > n_samples: + # print('Ending loop at index = %d' % index) + more_data = False + else: + v_array += [float(i) for i in data.split(',')] + index = len(v_array) # e.g., if returning 3 values, start at index 3 next time + # print(len(v_array)) + + i_array = [] + index = 0 + more_data = True + while more_data: + data = self.query('FETCh:ARRay:VOLTage? %s' % index) + # print(data) + if data.strip() == '' or data.strip() == '3.40282347e+38' or index > n_samples: + # print('Ending loop at index = %d' % index) + more_data = False + else: + i_array += [float(i) for i in data.split(',')] + index = len(i_array) # e.g., if returning 3 values, start at index 3 next time + # print(len(v_array)) + + t_array = list(np.linspace(0, sample_duration, num=len(i_array))) + # print(len(t_array)) + + return {'v_pts': v_array, 'i_pts': i_array, 'time_pts': t_array} + + def reset_accumulators(self): + """ + Resets all accumulated measurements: AmpereHour, KilowattHour, VoltageNegativePeak, VoltagePositivePeak, + CurrentNegativePeak, CurrentPositivePeak, PowerNegativePeak, PowerPositivePeak + + :return: SCPI response + """ + return self.cmd('MEASure:RESet:ALL') + + def set_aperture(self, aperture=0.0010005): + """ + + This command specifies the measurement aperture (capture duration). The Module will pick the sample rate. + The actual number of samples may be less than that specified in SENS:SWE:POINTS. Applies to both voltage and + current measurements. + + Command Parameters + + :param aperture: sample rate + :return: SCPI response + """ + return self.cmd('SENSe:SWEep:APERture "%s"' % aperture) + + def get_aperture(self): + """ + + This command specifies the measurement aperture (capture duration). The Module will pick the sample rate. + The actual number of samples may be less than that specified in SENS:SWE:POINTS. Applies to both voltage and + current measurements. + + :return: float, , e.g., 0.0010005 + """ + return float(self.query('SENSe:SWEep:APERture?')) + + def set_max_samples(self, samples): + """ + This command specifies the maximum number of samples. Applies to both voltage and current measurements. + + :return: None + """ + return self.cmd('SENSe:SWEep:POINts %s' % int(samples)) + + def get_max_samples(self): + """ + This command gets the maximum number of samples. Applies to both voltage and current measurements. + + :return: int, e.g., 1200 + """ + return int(self.query('SENSe:SWEep:POINts?')) + + def get_sample_rate(self): + """ + This query returns the time interval between samples (1 / sample rate). Applies to both voltage and + current measurements. + + :return: float, e.g., 1.3e-06 + """ + return float(self.query('SENSe:SWEep:TINTerval?')) + + # Output system + def get_battery_detect_voltage(self): + """ + This command (battery detect voltage) sets the minimum voltage that must be present before the module + is allowed to go into charge mode. + + :return: float + """ + return float(self.query('BDVoltage?')) + + def set_battery_detect_voltage(self, bd_voltage): + """ + This command (battery detect voltage) sets the minimum voltage that must be present before the module is + allowed to go into charge mode. + + :param bd_voltage: 0 through max voltage + :return: None + """ + return self.cmd('BDVoltage %s' % bd_voltage) + + def get_operational_mode(self): + """ + Get the operation mode and settings. + + Command Parameters ,,,,, where: + NR1: 0 = Off, 1 = standby, 2 = charge, 3 = discharge, 4 = battery + NR1: (bit values add for combinations): 1 = voltage enabled, 2 = current enabled, + 4 = power enabled, 8 = resistance enabled. + NR2: the voltage setpoint (if voltage is enabled with ) + : the current setpoint (if current is enabled with ) + : the power setpoint (if power is enabled with ) + : the resistance setpoint (if resistance is enabled with ) + + :return: dict, e.g., {'mode': 'BATTERY_MODE', + 'enable': ['VOLTAGE_ENABLED', 'CURRENT_ENABLED', 'POWER_ENABLED', 'RESISTANCE_ENABLED'], + 'voltage': 54.0, + 'current': 150.0, + 'power': 8000.0, + 'resistance': 0.0050008} + """ + str_data = self.query('OPERation?') + data = [str(i) for i in str_data.split(',')] + # print(data) + + decimal = int(data[1]) + ena_list = [] + if (decimal & (1 << VOLTAGE_ENABLED)) == (1 << VOLTAGE_ENABLED): + ena_list.append('VOLTAGE_ENABLED') + if (decimal & (1 << CURRENT_ENABLED)) == (1 << CURRENT_ENABLED): + ena_list.append('CURRENT_ENABLED') + if (decimal & (1 << POWER_ENABLED)) == (1 << POWER_ENABLED): + ena_list.append('POWER_ENABLED') + if (decimal & (1 << RESISTANCE_ENABLED)) == (1 << RESISTANCE_ENABLED): + ena_list.append('RESISTANCE_ENABLED') + + data_dict = {'mode': MODES.get(data[0]), + 'enable': ena_list, + 'voltage': float(data[2]), + 'current': float(data[3]), + 'power': float(data[4]), + 'resistance': float(data[5]) + } + + return data_dict + + def set_operational_mode(self, op_mode=None): + """ + Get the operation mode and settings. + + Command Parameters ,,,,, where: + NR1: 0 = Off, 1 = standby, 2 = charge, 3 = discharge, 4 = battery + NR1: (bit values add for combinations): 1 = voltage enabled, 2 = current enabled, + 4 = power enabled, 8 = resistance enabled. + NR2: the voltage setpoint (if voltage is enabled with ) + : the current setpoint (if current is enabled with ) + : the power setpoint (if power is enabled with ) + : the resistance setpoint (if resistance is enabled with ) + + :param op_mode: dict, e.g., {'mode': 'BATTERY_MODE', + 'enable': ['VOLTAGE_ENABLED', 'CURRENT_ENABLED', 'POWER_ENABLED', 'RESISTANCE_ENABLED'], + 'voltage': 54.0, + 'current': 150.0, + 'power': 8000.0, + 'resistance': 0.0050008} + """ + cmd_str = '' + current_params = self.get_operational_mode() # recycle values when not in param dict + if op_mode is None: + op_mode = {} + + if 'mode' in op_mode: + cmd_str += '%s,' % MODES.get(op_mode['mode']) # convert back to int string + else: + cmd_str += '%s,' % MODES.get(current_params['mode']) # convert back to int string + + bitfield_int = 0 + if 'enable' in op_mode: + if 'VOLTAGE_ENABLED' in op_mode['enable']: + bitfield_int += 1 + if 'CURRENT_ENABLED' in op_mode['enable']: + bitfield_int += 2 + if 'POWER_ENABLED' in op_mode['enable']: + bitfield_int += 4 + if 'RESISTANCE_ENABLED' in op_mode['enable']: + bitfield_int += 8 + else: + if 'VOLTAGE_ENABLED' in current_params['enable']: + bitfield_int += 1 + if 'CURRENT_ENABLED' in current_params['enable']: + bitfield_int += 2 + if 'POWER_ENABLED' in current_params['enable']: + bitfield_int += 4 + if 'RESISTANCE_ENABLED' in current_params['enable']: + bitfield_int += 8 + cmd_str += '%s,' % bitfield_int + + keys = ['voltage', 'current', 'power', 'resistance'] + for key in keys: + if key in op_mode: + cmd_str += '%s,' % op_mode[key] + else: + cmd_str += '%s,' % current_params[key] + + command = 'OPERation %s' % cmd_str[:-1] # remove last comma + # print('Sending: %s' % command) + return self.cmd(command) + + def get_watchdog_interval(self): + return self.query('SYSTem:WATChdog:INTerval') + + def get_regulation_gain(self): + """ + This command (regulation gain) sets the percent correction applied during each control loop to regulate the + output. A value of 0 will inhibit the regulation feedback. The larger the value the bigger the adjustment. + The value is a percent entered as a floating point where 5% is 0.05. + + :return: 0 through 0.30 + """ + return float(self.query('OUTPut:RGAin')) + + def irradiance_set(self, irradiance): + """ Not implemented """ + pass + + def get_safety(self): + """ + This command is used to set the maximum allowable time and value, which, if exceeded, will cause the Module + to shut off. Time values of <0 disable that parameter. Max temperature is optional on setting (it is disabled + if not set). Max temperature set to -1 to disable. + + SCPI Query Returns ,,,,,,, + ,,,,, + + :return: dict + """ + str_data = self.query('OUTPut:SAFety?') + data = [float(i) for i in str_data.split(',')] + + data_dict = {'Min V': data[0], + 'Min V Time': data[1], + 'Max V': data[2], + 'Max V Time': data[3], + 'Max Sink A': data[4], + 'Max Sink A Time': data[5], + 'Max Source A': data[6], + 'Max Source A Time': data[7], + 'Max Sink W': data[8], + 'Max Sink W Time': data[9], + 'Max Source W': data[10], + 'Max Source W Time': data[11], + 'Max Temperature': data[12], + } + + return data_dict + + def set_safety(self, safety_dict=None): + """ + This command is used to set the maximum allowable time and value, which, if exceeded, will cause the Module + to shut off. Time values of <0 disable that parameter. Max temperature is optional on setting (it is disabled + if not set). Max temperature set to -1 to disable. + + SCPI Command Params: ,,,,,,, + ,,,,, + + :param safety_dict: dict in the format: + {'Min V': 0.0, + 'Min V Time': -1.0, + 'Max V': 64.0, + 'Max V Time': -1.0, + 'Max Sink A': 150.0, + 'Max Sink A Time': -1.0, + 'Max Source A': 150.0, + 'Max Source A Time': -1.0, + 'Max Sink W': 8000.0, + 'Max Sink W Time': -1.0, + 'Max Source W': 8000.0, + 'Max Source W Time': -1.0, + 'Max Temperature': -1.0} + + Any missing keys will be replaced with the current value + + :return: None + """ + + cmd_str = '' + current_params = self.get_safety() # recycle values when not in param dict + + if safety_dict is None: + safety_dict = {} + + keys = ['Min V', 'Min V Time', 'Max V', 'Max V Time', 'Max Sink A', + 'Max Sink A Time', 'Max Source A','Max Source A Time', 'Max Sink W', + 'Max Sink W Time', 'Max Source W', 'Max Source W Time', 'Max Temperature'] + + for key in keys: + if key in safety_dict: + cmd_str += '%s,' % safety_dict[key] + else: + cmd_str += '%s,' % current_params[key] + + command = 'OUTPut:SAFety %s' % cmd_str[:-1] # remove last comma + # print('Sending: %s' % command) + return self.cmd(command) + + def get_range(self): + """ + Set or get the active range using a zero-based index. Use INSTrument:CAPabilities:RANGe[:MAXimum]? to + determine the maximum number of ranges. + + :return: int + """ + return int(self.query('OUTPut:RANGe')) + + def get_slew_current(self): + return float(self.query('SLEW:CURRent?')) + + def get_slew_voltage(self): + return float(self.query('SLEW:VOLTage?')) + + def get_slew_power(self): + return float(self.query('SLEW:POWer?')) + + def get_slew_resistance(self): + return float(self.query('SLEW:RESistance?')) + + def set_slew_current(self, val): + return self.cmd('SLEW:CURRent %s' % val) + + def set_slew_voltage(self, val): + return self.cmd('SLEW:VOLTage %s' % val) + + def set_slew_power(self, val): + return self.cmd('SLEW:POWer %s' % val) + + def set_slew_resistance(self, val): + return self.cmd('SLEW:RESistance %s' % val) + + def get_output_state(self): + """ + :return: True if output on, False if output off + """ + return self.get_operational_mode()['mode'] == 'BATTERY_MODE' + + def set_output_off(self): + self.set_operational_mode(op_mode={'mode': 'OFF_MODE'}) + + def set_output_on(self): + self.set_operational_mode(op_mode={'mode': 'BATTERY_MODE', 'enable': ['VOLTAGE_ENABLED', 'CURRENT_ENABLED', + 'POWER_ENABLED', 'RESISTANCE_ENABLED']}) + + def get_voltage_protection_level(self): + return self.get_safety()['MAX V'] + + def get_current_protection_level(self): + protection_levels = self.get_safety() + return [protection_levels['MAX SINK A'], protection_levels['MAX SOURCE A']] + + def clear_protection_faults(self): + pass + + def set_overcurrent_protection(self, current=150): + self.set_safety(safety_dict={'Max Sink A': current, 'Max Source A': current, + 'Max Sink A Time': 0.005, 'Max Source A Time': 0.005}) + + def set_overvoltage_protection(self, voltage=54): + self.set_safety(safety_dict={'Max V': voltage, 'Max V Time': 0.005}) + + def set_defaults(self): + + self.set_battery_detect_voltage(bd_voltage=0.0) + + safety_vals = {'Min V': 0.0, 'Min V Time': 0.0, + 'Max V': 64.0, 'Max V Time': 0.005, + 'Max Sink A': 150.0, 'Max Sink A Time': 0.005, + 'Max Source A': 150.0, 'Max Source A Time': 0.005, + 'Max Sink W': 8000.0, 'Max Sink W Time': 0.005, + 'Max Source W': 8000.0, 'Max Source W Time': 0.005, + 'Max Temperature': -1.0} + self.set_safety(safety_dict=safety_vals) + + self.set_output_on() + + op_mode = {'enable': ['VOLTAGE_ENABLED', 'CURRENT_ENABLED', 'POWER_ENABLED', 'RESISTANCE_ENABLED'], + 'voltage': 54.0, + 'current': 150.0, + 'power': 8000.0, + 'resistance': 0.005} + self.set_operational_mode(op_mode=op_mode) + + def blink_led(self, on_off='OFF'): + """ + :param on_off: 0, 1, ON, OFF + :return: SCPI response + """ + return self.cmd('SYSTem:LED %s' % on_off) + +if __name__ == "__main__": + + # rm = pyvisa.ResourceManager() + # pmod = rm.open_resource('TCPIP::10.1.2.180::INSTR') + # print(pmod.query('*IDN?')) + # pmod.close() + + power_module_ips = ['10.1.2.181', '10.1.2.182'] + for ip in power_module_ips: + pmod = NHResearch(ipaddr=ip) + print(pmod.info()) + + pmod.set_defaults() + pmod.set_local() + # pmod.set_output_off() + pmod.close() + + # print(pmod.waverform_capture()) + # print(pmod.measurements_get()) + # print(pmod.status()) + # print(pmod.current_ranges()) + # print(pmod.power_ranges()) + # print(pmod.resistance_ranges()) + # print(pmod.voltage_ranges()) + # print(pmod.gain_ranges()) + # # print(pmod.waverform_capture()) + # print(pmod.measure_all()) + # print(pmod.get_aperture()) + # print(pmod.get_max_samples()) + # print(pmod.get_sample_rate()) + # + # + # print(pmod.get_operational_mode()) + # pmod.set_operational_mode() + # + # safety_dict = {'Min V': 0.0, 'Min V Time': -1.0, + # 'Max V': 64.0, 'Max V Time': -1.0, + # 'Max Sink A': 150.0, 'Max Sink A Time': -1.0, + # 'Max Source A': 150.0, 'Max Source A Time': -1.0, + # 'Max Sink W': 8000.0, 'Max Sink W Time': -1.0, + # 'Max Source W': 8000.0, 'Max Source W Time': -1.0, + # 'Max Temperature': -1.0} + # + # safety_dict2 = {'Min V': 0.0, 'Min V Time': 0.0, + # 'Max V': 64.0, 'Max V Time': 0.0050008, + # 'Max Sink A': 150.0, 'Max Sink A Time': 0.0050016, + # 'Max Source A': 150.0, 'Max Source A Time': 0.0050008, + # 'Max Sink W': 8000.0, 'Max Sink W Time': 0.0050016, + # 'Max Source W': 8000.0, 'Max Source W Time': 0.0050008, + # 'Max Temperature': -1.0} + # + # print(pmod.get_safety()) + + + + + diff --git a/Lib/svpelab/device_chroma_dpm.py b/Lib/svpelab/device_chroma_dpm.py index e651d13..1741518 100644 --- a/Lib/svpelab/device_chroma_dpm.py +++ b/Lib/svpelab/device_chroma_dpm.py @@ -143,13 +143,13 @@ def open(self): """ try: # sys.path.append(os.path.normpath(self.visa_path)) - import visa + import pyvisa as visa self.rm = visa.ResourceManager(self.visa_path) self.conn = self.rm.open_resource(self.visa_device) # set terminator in pyvisa self.conn.write_termination = TERMINATOR - except Exception, e: + except Exception as e: raise DeviceError('Cannot open VISA connection to %s' % (self.visa_device)) def close(self): @@ -163,7 +163,7 @@ def close(self): self.conn.close() self.rm.close() - except Exception, e: + except Exception as e: raise DeviceError(str(e)) def _query(self, cmd_str): @@ -178,7 +178,7 @@ def _query(self, cmd_str): raise DeviceError('Device connection not open') return self.conn.query(cmd_str).strip() - except Exception, e: + except Exception as e: raise DeviceError(str(e)) def _write(self, cmd_str): @@ -191,7 +191,7 @@ def _write(self, cmd_str): raise DeviceError('Device connection not open') return self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise DeviceError(str(e)) @@ -213,5 +213,5 @@ def _write(self, cmd_str): 'points': ('V', 'I', 'P'), 'label': ''}] }) - print dpm.data_points + print(dpm.data_points) diff --git a/Lib/svpelab/device_das7_sandia_ni_pcie.py b/Lib/svpelab/device_das7_sandia_ni_pcie.py new file mode 100644 index 0000000..30fad23 --- /dev/null +++ b/Lib/svpelab/device_das7_sandia_ni_pcie.py @@ -0,0 +1,841 @@ +""" +Communications to NI PCIe Cards + +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import time +import traceback +import glob +from . import waveform +from . import dataset +import sys + +# Wrap driver import statements in try-except clauses to avoid SVP initialization errors +try: + from PyDAQmx import * +except Exception as e: + print('Error: PyDAQmx python package not found!') + +try: + import numpy as np +except Exception as e: + print('Error: numpy python package not found!') + +try: + from . import waveform_analysis +except Exception as e: + print('Error: waveform_analysis file not found!') + +try: + from ctypes import * +except Exception as e: + print('Error: ctypes file not found!') + + +# Data channels for motor control center +dsm_points_mcc = { + 'utility_v_phA': 'Utility_PhA_V', + 'mcc_v_phA': 'MCC_PhA_V', + 'mcc_i_phA': 'MCC_PhA_I', + 'mcc_v_phB': 'MCC_PhB_V', + 'mcc_i_phB': 'MCC_PhB_I', + 'mcc_v_phC': 'MCC_PhC_V', + 'mcc_i_phC': 'MCC_PhC_I', + 'load_v_phA': 'Load_PhA_V', # Same voltage a MCC + 'load_i_phA': 'Load_PhA_I', + 'load_v_phB': 'Load_PhB_V', # Same voltage a MCC + 'load_i_phB': 'Load_PhB_I', + 'load_v_phC': 'Load_PhC_V', # Same voltage a MCC + 'load_i_phC': 'Load_PhC_I', + 'genset_v_phA': 'Diesel_Genset_PhA_V', + 'genset_i_phA': 'Diesel_Genset_PhA_I', + 'genset_v_phB': 'Diesel_Genset_PhB_V', + 'genset_i_phB': 'Diesel_Genset_PhB_I', + 'genset_v_phC': 'Diesel_Genset_PhC_V', + 'genset_i_phC': 'Diesel_Genset_PhC_I', + 'pv_v_phA': 'PV_Inverter_20kW_PhA_V', # Same voltage a MCC + 'pv_i_phA': 'PV_Inverter_20kW_PhA_I', + 'pv_v_phB': 'PV_Inverter_20kW_PhB_V', # Same voltage a MCC + 'pv_i_phB': 'PV_Inverter_20kW_PhB_I', + 'pv_v_phC': 'PV_Inverter_20kW_PhC_V', # Same voltage a MCC + 'pv_i_phC': 'PV_Inverter_20kW_PhC_I', + 'bat_v_phA': 'ESTB_PhA_V', + 'bat_i_phA': 'ESTB_PhA_I', + 'bat_v_phB': 'ESTB_PhB_V', + 'bat_i_phB': 'ESTB_PhB_I', + 'bat_v_phC': 'ESTB_PhC_V', + 'bat_i_phC': 'ESTB_PhC_I'} + +dsm_points_mcc_reversed = { + 'Utility_PhA_V': 'utility_v_phA', # reverse mapping too + 'MCC_PhA_V': 'mcc_v_phA', + 'MCC_PhA_I': 'mcc_i_phA', + 'MCC_PhB_V': 'mcc_v_phB', + 'MCC_PhB_I': 'mcc_i_phB', + 'MCC_PhC_V': 'mcc_v_phC', + 'MCC_PhC_I': 'mcc_i_phC', + 'Load_PhA_V': 'load_v_phA', + 'Load_PhA_I': 'load_i_phA', + 'Load_PhB_V': 'load_v_phB', + 'Load_PhB_I': 'load_i_phB', + 'Load_PhC_V': 'load_v_phC', + 'Load_PhC_I': 'load_i_phC', + 'Diesel_Genset_PhA_V': 'genset_v_phA', + 'Diesel_Genset_PhA_I': 'genset_i_phA', + 'Diesel_Genset_PhB_V': 'genset_v_phB', + 'Diesel_Genset_PhB_I': 'genset_i_phB', + 'Diesel_Genset_PhC_V': 'genset_v_phC', + 'Diesel_Genset_PhC_I': 'genset_i_phC', + 'PV_Inverter_20kW_PhA_V': 'pv_v_phA', + 'PV_Inverter_20kW_PhA_I': 'pv_i_phA', + 'PV_Inverter_20kW_PhB_V': 'pv_v_phB', + 'PV_Inverter_20kW_PhB_I': 'pv_i_phB', + 'PV_Inverter_20kW_PhC_V': 'pv_v_phC', + 'PV_Inverter_20kW_PhC_I': 'pv_i_phC', + 'ESTB_PhA_V': 'bat_v_phA', + 'ESTB_PhA_I': 'bat_i_phA', + 'ESTB_PhB_V': 'bat_v_phB', + 'ESTB_PhB_I': 'bat_i_phB', + 'ESTB_PhC_V': 'bat_v_phC', + 'ESTB_PhC_I': 'bat_i_phC' +} + +wfm_channels = ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] +wfm_dsm_channels = dsm_points_mcc + +DSM_CHANNELS = { +'Utility_PhA_V': {'physChan': 'Dev1/ai0', 'v_max': 1, 'v_min': -1, 'expression': 'x*499.0'}, +'MCC_PhA_V': {'physChan': 'Dev1/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*496'}, +'MCC_PhA_I': {'physChan': 'Dev1/ai2', 'v_max': 1, 'v_min': -1, 'expression': 'x*100'}, +'MCC_PhB_V': {'physChan': 'Dev1/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'MCC_PhB_I': {'physChan': 'Dev1/ai4', 'v_max': 1, 'v_min': -1, 'expression': 'x*100'}, +'MCC_PhC_V': {'physChan': 'Dev1/ai5', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'MCC_PhC_I': {'physChan': 'Dev1/ai6', 'v_max': 1, 'v_min': -1, 'expression': 'x*100'}, +'Load_PhA_V': {'physChan': 'Dev1/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*496'}, +'Load_PhA_I': {'physChan': 'Dev1/ai16', 'v_max': 1, 'v_min': -1, 'expression': '49.039198e3 + 93.861867*x'}, +'Load_PhB_V': {'physChan': 'Dev1/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Load_PhB_I': {'physChan': 'Dev1/ai18', 'v_max': 1, 'v_min': -1, 'expression': '67.837285e3 + 100.687060*x'}, +'Load_PhC_V': {'physChan': 'Dev1/ai5', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Load_PhC_I': {'physChan': 'Dev1/ai20', 'v_max': 1, 'v_min': -1, 'expression': '56.685470e3 + 98.489778*x'}, +'Diesel_Genset_PhA_V': {'physChan': 'Dev1/ai21', 'v_max': 1, 'v_min': -1, 'expression': 'x*497.0'}, +'Diesel_Genset_PhA_I': {'physChan': 'Dev1/ai22', 'v_max': 1, 'v_min': -1, 'expression': '(22.503877e-3) + (100.403661)*x + (-104.893069e-3)*(x**2)'}, +'Diesel_Genset_PhB_V': {'physChan': 'Dev1/ai23', 'v_max': 1, 'v_min': -1, 'expression': 'x*493.5'}, +'Diesel_Genset_PhB_I': {'physChan': 'Dev2/ai0', 'v_max': 1, 'v_min': -1, 'expression': '(10.073010e-3) + (99.752587)*x + (284.699652e-3)*(x**2)'}, +'Diesel_Genset_PhC_V': {'physChan': 'Dev2/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*501.7'}, +'Diesel_Genset_PhC_I': {'physChan': 'Dev2/ai2', 'v_max': 1, 'v_min': -1, 'expression': '(-18.106330e-3) + (101.279462)*x + (14.321437e-3)*(x**2)'}, +'PV_Inverter_20kW_PhA_V': {'physChan': 'Dev1/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*496'}, +'PV_Inverter_20kW_PhA_I': {'physChan': 'Dev2/ai4', 'v_max': 1, 'v_min': -1, 'expression': '(-20.988398e-3) + (20.211266)*x'}, +'PV_Inverter_20kW_PhB_V': {'physChan': 'Dev1/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'PV_Inverter_20kW_PhB_I': {'physChan': 'Dev2/ai6', 'v_max': 1, 'v_min': -1, 'expression': '(-29.163501e-3) + (20.230154)*x'}, +'PV_Inverter_20kW_PhC_V': {'physChan': 'Dev1/ai5', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'PV_Inverter_20kW_PhC_I': {'physChan': 'Dev2/ai16', 'v_max': 1, 'v_min': -1, 'expression': '(28.511307e-3) + (20.130221)*x'}, +'SRP_30kW_Capstone_PhA_V': {'physChan': 'Dev1/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*496'}, +'SRP_30kW_Capstone_PhA_I': {'physChan': 'Dev2/ai18', 'v_max': 1, 'v_min': -1, 'expression': '(-5.657966e-3) + (102.347456)*x'}, +'SRP_30kW_Capstone_PhB_V': {'physChan': 'Dev1/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'SRP_30kW_Capstone_PhB_I': {'physChan': 'Dev2/ai20', 'v_max': 1, 'v_min': -1, 'expression': '(-17.439877e-3) + (100.015337)*x'}, +'SRP_30kW_Capstone_PhC_V': {'physChan': 'Dev2/ai21', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'SRP_30kW_Capstone_PhC_I': {'physChan': 'Dev2/ai22', 'v_max': 1, 'v_min': -1, 'expression': '(2.043021e-3) + (100.963618)*x'}, +'Xantrex_30kW_PhA_V': {'physChan': 'Dev1/ai1', 'v_max': 1, 'v_min': -1, 'expression': 'x*496'}, +'Xantrex_30kW_PhA_I': {'physChan': 'Dev3/ai2', 'v_max': 1, 'v_min': -1, 'expression': '(-115.865216e-3) + (99.772540)*x'}, +'Xantrex_30kW_PhB_V': {'physChan': 'Dev1/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Xantrex_30kW_PhB_I': {'physChan': 'Dev3/ai0', 'v_max': 1, 'v_min': -1, 'expression': '(12.373643e-3) + (99.281793)*x'}, +'Xantrex_30kW_PhC_V': {'physChan': 'Dev3/ai3', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Xantrex_30kW_PhC_I': {'physChan': 'Dev3/ai4', 'v_max': 1, 'v_min': -1, 'expression': '(-38.346419e-3) + (99.823882)*x + (-752.629163e-3)*(x**2)'}, +'Xantrex_30kW_DC1_V': {'physChan': 'Dev3/ai5', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Xantrex_30kW_DC1_I': {'physChan': 'Dev3/ai6', 'v_max': 1, 'v_min': -1, 'expression': 'x*93.5'}, +'Xantrex_30kW_DC2_V': {'physChan': 'Dev3/ai7', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'Xantrex_30kW_DC2_I': {'physChan': 'Dev3/ai16', 'v_max': 1, 'v_min': -1, 'expression': 'x*93.5'}, +'Irradiance': {'physChan': 'Dev3/ai17', 'v_max': 1, 'v_min': -1, 'expression': '812*Irradiance'}, +'Xantrex_30kW_Ambient_Temp': {'physChan': 'Dev3/ai18', 'v_max': 1, 'v_min': -1, 'expression': 'x'}, +'Xantrex_30kW_HS_Temp': {'physChan': 'Dev3/ai19', 'v_max': 1, 'v_min': -1, 'expression': 'x'}, +'Xantrex_30kW_Cap_Temp': {'physChan': 'Dev3/ai20', 'v_max': 1, 'v_min': -1, 'expression': 'x'}, +'ESTB_PhA_V': {'physChan': 'Dev3/ai21', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'ESTB_PhA_I': {'physChan': 'Dev3/ai22', 'v_max': 1, 'v_min': -1, 'expression': '(x)*100'}, +'ESTB_PhB_V': {'physChan': 'Dev3/ai23', 'v_max': 1, 'v_min': -1, 'expression': '(x)*500'}, +'ESTB_PhB_I': {'physChan': 'Dev3/ai18', 'v_max': 1, 'v_min': -1, 'expression': '(x)*100'}, +'ESTB_PhC_V': {'physChan': 'Dev3/ai19', 'v_max': 1, 'v_min': -1, 'expression': 'x*500'}, +'ESTB_PhC_I': {'physChan': 'Dev3/ai20', 'v_max': 1, 'v_min': -1, 'expression': '(x)*100'}} + + +class DeviceError(Exception): + pass + + +class Device(object): + + def __init__(self, params=None, ts=None): + self.ts = ts + self.ts.log_debug(sys.path) + self.device = None + self.sample_rate = params.get('sample_rate') + self.n_cycles = params.get('n_cycles') + self.n_samples = int((self.sample_rate/60.)*self.n_cycles) + self.physical_channels = '' # string of physical channels + self.dev_numbers = [] # list of device numbers + self.duplicate_channels = {} # dict with {recorded channel: [matching channels list]} + + # Get analog channels to acquire + self.points_map = dsm_points_mcc + + # Create list of analog channels to capture + self.analog_channels = [] + for key, value in self.points_map.items(): + self.analog_channels.append(value) + self.analog_channels = sorted(self.analog_channels) # alphabetize + # self.ts.log_debug('analog_channels = %s' % self.analog_channels) + + self.time_vector = np.linspace(0., self.n_samples/self.sample_rate, self.n_samples) + self.n_channels = len(self.analog_channels) + + for k in range(len(self.analog_channels)): + chan = DSM_CHANNELS[self.analog_channels[k]]['physChan'] + self.physical_channels += chan + self.dev_numbers.append(chan[3]) + if k != len(self.analog_channels)-1: + self.physical_channels += ',' + self.ts.log_debug('The following channels will be captured: %s, on physical channels: %s.' % + (self.analog_channels, self.physical_channels)) + + # find the unique NI devices + self.sorted_unique, self.unique_counts = np.unique(self.dev_numbers, return_index=False, return_counts=True) + + self.read = int32() + self.analog_input = [] + self.physical_channel_str = [] # list of strings of physical channels, sorted by Device + self.physical_channel_list = [] # list of lists of physical channels, sorted by Device + self.chan_decoder = [] # list of lists of channel names, aligned to the physical_channel_str + for k in range(len(self.unique_counts)): + self.analog_input.append(Task()) + self.physical_channel_str.append('') + self.physical_channel_list.append([]) + self.chan_decoder.append([]) + + self.raw_data = [] + self.n_channels = [] + for k in self.unique_counts: + self.n_channels.append(k) + self.raw_data.append(np.zeros((self.n_samples*k,), dtype=np.float64)) + + unique_dev_num = -1 # count for the unique devs + for dev in self.sorted_unique: + unique_dev_num += 1 + for k in range(len(self.analog_channels)): # for each channel + current_chan_name = self.analog_channels[k] + chan = DSM_CHANNELS[current_chan_name]['physChan'] + if dev == chan[3]: # if this device matches, put it in this task + # self.ts.log_debug('Current Channel: %s' % current_chan_name) + if chan not in self.physical_channel_list[unique_dev_num]: # do not duplicate physical channels + self.physical_channel_str[unique_dev_num] += chan + ',' + self.physical_channel_list[unique_dev_num].append(chan) + self.chan_decoder[unique_dev_num].append(current_chan_name) + else: # create dictionary that maps recorded channels to other channels using the same phys channel + for prior_chan in self.physical_channel_list[unique_dev_num]: + if prior_chan == chan: # if the channel matches one of the previous, get the index + chan_idx = self.physical_channel_list[unique_dev_num].index(prior_chan) + prior_channel_name = self.chan_decoder[unique_dev_num][chan_idx] + # self.ts.log_debug('Prior Channel: %s, Prior Channel Name: %s, ' + # 'Current Channel: %s, Current Channel Name: %s' % + # (prior_chan, prior_channel_name, chan, current_chan_name)) + try: + self.duplicate_channels[prior_channel_name].append(current_chan_name) + except KeyError: + self.duplicate_channels[prior_channel_name] = [current_chan_name] + + self.ts.log_debug('Duplicate Channel dict: %s' % self.duplicate_channels) + + for dev in range(len(self.sorted_unique)): # clean up last comma + self.physical_channel_str[dev] = self.physical_channel_str[dev][:-1] # Remove the last comma. + self.ts.log_debug('Sampling the following analog channels: %s' % self.physical_channel_str) + + # Create empty container for data capture + self.ac_voltage_vector = None + self.ac_current_vector = None + self.ametek_trigger = None + + # waveform settings + self.wfm_sample_rate = None + self.wfm_pre_trigger = None + self.wfm_post_trigger = None + self.wfm_trigger_level = None + self.wfm_trigger_cond = None + self.wfm_trigger_channel = None + self.wfm_timeout = None + self.wfm_channels = None + self.wfm_capture_name = None + + def info(self): + return 'DAS Hardware: Sandia DAQ7 NI PCIe Cards' + + def open(self): + pass + + def close(self): + pass + + def data_capture(self, enable=True): # Enable/disable RMS data capture + pass + + def data_read(self): + # Virtual channels are created. Each one of the virtual channels in question here is used to acquire + # from an analog voltage signal(s). + for k in range(len(self.sorted_unique)): + self.analog_input[k].CreateAIVoltageChan(self.physical_channel_str[k], # The physical name of the channel + "", # The name to associate with this channel + DAQmx_Val_Cfg_Default, # Differential wiring + -10.0, # Min voltage + 10.0, # Max voltage + DAQmx_Val_Volts, # Units + None) # reserved + + try: + status = DAQmxConnectTerms('/Dev%s/20MHzTimebase' % self.dev_numbers[0], + '/Dev%s/RTSI7' % self.dev_numbers[len(self.sorted_unique)-1], + DAQmx_Val_DoNotInvertPolarity) + except Exception as e: + print(('Error: Task does not support DAQmxConnectTerms: %s' % e)) + + for k in range(len(self.sorted_unique)-1, -1, -1): + # Start Master last so slave(s) will wait for trigger from master over RSTI bus + print(('Starting Task: %s.' % k)) + self.analog_input[k].StartTask() + + # DAQmx Read Code + # fillMode options + # 1. DAQmx_Val_GroupByChannel Group by channel (non-interleaved) + # 2. DAQmx_Val_GroupByScanNumber Group by scan number (interleaved) + for k in range(len(self.sorted_unique)): + # DAQmxReadAnalogF64(task2,sampsPerChanRead1,timeout,DAQmx_Val_GroupByScanNumber, + # buffer2,bufferSize,&sampsPerChanRead2,NULL); + self.analog_input[k].ReadAnalogF64(self.n_samples, # int32 numSampsPerChan, + 5.0, # float64 timeout, + DAQmx_Val_GroupByChannel, # bool32 fillMode, + self.raw_data[k], # float64 readArray[], + self.n_samples*self.n_channels[k], # uInt32 arraySizeInSamps, + byref(self.read), # int32 *sampsPerChanRead, + None) # bool32 *reserved); + + self.ts.log_debug('Acquired %d points' % self.read.value) + self.ts.log_debug('raw_data length: %s' % len(self.raw_data[k])) + + # create null data set with timestamp + datarec = {'TIME': time.time(), + 'utility_v_phA': None, + 'mcc_v_phA': None, + 'mcc_i_phA': None, + 'mcc_v_phB': None, + 'mcc_i_phB': None, + 'mcc_v_phC': None, + 'mcc_i_phC': None, + 'load_v_phA': None, + 'load_i_phA': None, + 'load_v_phB': None, + 'load_i_phB': None, + 'load_v_phC': None, + 'load_i_phC': None, + 'genset_v_phA': None, + 'genset_i_phA': None, + 'genset_v_phB': None, + 'genset_i_phB': None, + 'genset_v_phC': None, + 'genset_i_phC': None, + 'pv_v_phA': None, + 'pv_i_phA': None, + 'pv_v_phB': None, + 'pv_i_phB': None, + 'pv_v_phC': None, + 'pv_i_phC': None, + 'bat_v_phA': None, + 'bat_i_phA': None, + 'bat_v_phB': None, + 'bat_i_phB': None, + 'bat_v_phC': None, + 'bat_i_phC': None, + 'mcc_freq': None, + 'mcc_p': None, + 'mcc_s': None, + 'mcc_q': None, + 'mcc_pf': None, + 'load_freq': None, + 'load_p': None, + 'load_s': None, + 'load_q': None, + 'load_pf': None, + 'genset_freq': None, + 'genset_p': None, + 'genset_s': None, + 'genset_q': None, + 'genset_pf': None, + 'pv_freq': None, + 'pv_p': None, + 'pv_s': None, + 'pv_q': None, + 'pv_pf': None, + 'bat_freq': None, + 'bat_p': None, + 'bat_s': None, + 'bat_q': None, + 'bat_pf': None + } + + try: + for k in range(len(self.sorted_unique)-1, -1, -1): + self.analog_input[k].StopTask() + self.analog_input[k].TaskControl(DAQmx_Val_Task_Unreserve) + except Exception as e: + self.ts.log_error('Error with DAQmx in StopTask. Returning nones... %s' % e) + return datarec + + # Scale and save the waveform data + dev_idx = -1 + data = {} + rms_data = {} + chan_idx = None + for k in range(len(self.analog_channels)): + # self.ts.log_debug('Getting data for %s' % self.analog_channels[k]) + for j in range(len(self.chan_decoder)): + # self.ts.log_debug('Looking in data set: %s' % self.chan_decoder[j]) + if any(self.analog_channels[k] in s for s in self.chan_decoder[j]): + dev_idx = j + chan_idx = self.chan_decoder[j].index(self.analog_channels[k]) + break + if chan_idx is not None: + # self.ts.log_debug('Converting raw data to scaled values for %s' % self.analog_channels[k]) + scaled_data = dsm_expression(channel_name=self.analog_channels[k], + dsm_value=self.raw_data[dev_idx][chan_idx*self.n_samples:(chan_idx+1)*self.n_samples]) + data[self.analog_channels[k]] = scaled_data + + # Calculate all the RMS current and voltage values + rms_data[self.analog_channels[k]] = waveform_analysis.calculateRMS(scaled_data) # index RMS value in dict under the channel name + svp_name = dsm_points_mcc_reversed.get(self.analog_channels[k]) # get the name that will appear in the SVP + # self.ts.log_debug('Storing data for %s (%s)' % (self.analog_channels[k], svp_name)) + datarec[svp_name] = rms_data[self.analog_channels[k]] # add RMS data to recorded data under SVP name + + # check to see if this data also belongs to other channels + for key, value in self.duplicate_channels.items(): + if key == self.analog_channels[k]: + for j in value: + svp_name = dsm_points_mcc_reversed.get(j) + # self.ts.log_debug('Duplicating data for %s (%s) from %s' % + # (j, svp_name, self.analog_channels[k])) + datarec[svp_name] = rms_data[self.analog_channels[k]] + + self.ts.log_debug(datarec) + + # Calculate AC information for each device/metered point + sets = ['mcc', 'load', 'genset', 'pv', 'bat'] + for s in sets: + ac_voltage_a = None + ac_voltage_b = None + ac_voltage_c = None + ac_current_a = None + ac_current_b = None + ac_current_c = None + for analog_chan_name, dsm_name in dsm_points_mcc.items(): + # self.ts.log_debug('Checking to see if %s is in %s' % (s, k)) + if analog_chan_name.find(s) != -1: + self.ts.log_debug('Found Channel %s' % analog_chan_name) + if analog_chan_name[-5:] == 'v_phA': + ac_voltage_a = data[dsm_name] + svp_name = s + '_freq' + datarec[svp_name], _ = waveform_analysis.freq_from_crossings(self.time_vector, ac_voltage_a, + self.sample_rate) + elif analog_chan_name[-5:] == 'v_phB': + ac_voltage_b = data[dsm_name] + elif analog_chan_name[-5:] == 'v_phC': + ac_voltage_c = data[dsm_name] + elif analog_chan_name[-5:] == 'i_phA': + ac_current_a = data[dsm_name] + elif analog_chan_name[-5:] == 'i_phB': + ac_current_b = data[dsm_name] + elif analog_chan_name[-5:] == 'i_phC': + ac_current_c = data[dsm_name] + else: + self.ts.log_warning('Unexpected data set: %s' % analog_chan_name) + + self.ts.log_debug(datarec) + + avg_P_a = None + avg_P_b = None + avg_P_c = None + S_a = None + S_b = None + S_c = None + Q1_a = None + Q1_b = None + Q1_c = None + PF1_a = None + + if ac_voltage_a is not None and ac_current_a is not None: + avg_P_a, S_a, Q1_a, N_a, PF1_a = waveform_analysis.harmonic_analysis(self.time_vector, + ac_voltage_a, ac_current_a, + self.sample_rate, self.ts) + else: + self.ts.log_debug('Missing phase A current or voltage datasets for %s' % s) + + if ac_voltage_b is not None and ac_current_b is not None: + avg_P_b, S_b, Q1_b, N_b, PF1_b = waveform_analysis.harmonic_analysis(self.time_vector, + ac_voltage_b, ac_current_b, + self.sample_rate, self.ts) + else: + self.ts.log_debug('Missing phase B current or voltage datasets for %s' % s) + + if ac_voltage_c is not None and ac_current_c is not None: + avg_P_c, S_c, Q1_c, N_c, PF1_c = waveform_analysis.harmonic_analysis(self.time_vector, + ac_voltage_c, ac_current_c, + self.sample_rate, self.ts) + else: + self.ts.log_debug('Missing phase C current or voltage datasets for %s' % s) + + datarec[s + '_p'] = avg_P_a + avg_P_b + avg_P_c + datarec[s + '_s'] = S_a + S_b + S_c + datarec[s + '_q'] = Q1_a + Q1_b + Q1_c + datarec[s + '_pf'] = PF1_a + + return datarec + + + def waveform_config(self, params): + """ + Configure waveform capture. + + params: Dictionary with following entries: + 'sample_rate' - Sample rate (samples/sec) + 'pre_trigger' - Pre-trigger time (sec) + 'post_trigger' - Post-trigger time (sec) + 'trigger_level' - Trigger level + 'trigger_cond' - Trigger condition - ['Rising_Edge', 'Falling_Edge'] + 'trigger_channel' - Trigger channel - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + 'timeout' - Timeout (sec) + 'channels' - Channels to capture - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + """ + self.wfm_sample_rate = params.get('sample_rate') + self.wfm_pre_trigger = params.get('pre_trigger') + self.wfm_post_trigger = params.get('post_trigger') + self.wfm_trigger_level = params.get('trigger_level') + self.wfm_trigger_cond = params.get('trigger_cond') + self.wfm_trigger_channel = params.get('trigger_channel') + self.wfm_timeout = params.get('timeout') + self.wfm_channels = params.get('channels') + + for c in self.wfm_channels: + dsm_chan = wfm_dsm_channels[c] + if dsm_chan is not None: + self.wfm_dsm_channels.append('%s_%s' % (dsm_chan, self.dsm_id)) + self.ts.log_debug('Channels to record: %s' % str(self.wfm_channels)) + + def waveform_capture(self, enable=True, sleep=None): + """ + Enable/disable waveform capture. + """ + if enable: + for k in range(len(self.sorted_unique)): + self.analog_input[k].CreateAIVoltageChan(self.physical_channel_str[k], # The physical name of the channel + "", # The name to associate with this channel + DAQmx_Val_Cfg_Default, # Differential wiring + -10.0, # Min voltage + 10.0, # Max voltage + DAQmx_Val_Volts, # Units + None) # reserved + + try: + status = DAQmxConnectTerms('/Dev%s/20MHzTimebase' % self.dev_numbers[0], + '/Dev%s/RTSI7' % self.dev_numbers[len(self.sorted_unique)-1], + DAQmx_Val_DoNotInvertPolarity) + except Exception as e: + print(('Error: Task does not support DAQmxConnectTerms: %s' % e)) + + for k in range(len(self.sorted_unique)-1, -1, -1): + # Start Master last so slave(s) will wait for trigger from master over RSTI bus + print(('Starting Task: %s.' % k)) + self.analog_input[k].StartTask() + + for k in range(len(self.sorted_unique)): + self.analog_input[k].ReadAnalogF64(self.n_samples, # int32 numSampsPerChan, + 5.0, # float64 timeout, + DAQmx_Val_GroupByChannel, # bool32 fillMode, + self.raw_data[k], # float64 readArray[], + self.n_samples*self.n_channels[k], # uInt32 arraySizeInSamps, + byref(self.read), # int32 *sampsPerChanRead, + None) # bool32 *reserved); + + def waveform_status(self): + # return INACTIVE, ACTIVE, COMPLETE + + trig_type = self.analog_input[k].GetStartTrigType() + + if int(trig_type) == 10099 and self.raw_data is None: + # DAQmx_Val_AnlgEdge 10099 Trigger when an analog signal signal crosses a threshold. + # DAQmx_Val_DigEdge 10150 Trigger on the rising or falling edge of a digital signal. + # DAQmx_Val_DigPattern 10398 Trigger when digital physical channels match a digital pattern. + # DAQmx_Val_AnlgWin 10103 Trigger when an analog signal enters or leaves a range of values. + # DAQmx_Val_None 10230 Disable triggering for the task. + stat = 'ACTIVE' + + elif self.raw_data is not None: + stat = 'COMPLETE' + + # once complete, close the Task + try: + for k in range(len(self.sorted_unique)-1, -1, -1): + self.analog_input[k].StopTask() + self.analog_input[k].TaskControl(DAQmx_Val_Task_Unreserve) + except Exception as e: + self.ts.log_error('Error with DAQmx in StopTask. Returning nones... %s' % e) + + else: + stat = 'INACTIVE' + + return stat + + def waveform_force_trigger(self): + """ + Create trigger event with provided value. + """ + trig_condition = self.wfm_trigger_cond + self.wfm_trigger_cond = None + self.waveform_capture() + self.wfm_trigger_cond = trig_condition + + def waveform_capture_dataset(self): + ds = dataset.Dataset() + ds.points.append('TIME') + ds.data.append(self.time_vector) + + dev_idx = -1 + data = {} + chan_idx = None + for k in range(len(self.analog_channels)): + # print('Getting data for %s' % analog_channels[k]) + for j in range(len(self.chan_decoder)): + # print('Looking in data set: %s' % chan_decoder[j]) + if any(self.analog_channels[k] in s for s in self.chan_decoder[j]): + dev_idx = j + chan_idx = self.chan_decoder[j].index(self.analog_channels[k]) + break + if chan_idx is not None: + scaled_data = dsm_expression(channel_name=self.analog_channels[k], + dsm_value=self.raw_data[dev_idx][chan_idx*self.n_samples:(chan_idx+1)*self.n_samples]) + data[self.analog_channels[k]] = scaled_data + else: + print('No channel index') + ds.points.append(dsm_points_mcc.get(self.analog_channels[k])) + ds.data.append(data[self.analog_channels[k]]) # first row for first signal and so on + + return ds + + +def dsm_expression(channel_name, dsm_value): + x = dsm_value # this is required for the expression calculation + # print(DSM_CHANNELS[channel_name]['expression']) + return eval(DSM_CHANNELS[channel_name]['expression']) + + +def c7_relay(new_state='close', device=(3, 0, 17)): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + # print('Opening C7 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + # print('Closing C7 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + dev = "Dev%d/port%d/line%d" % (device[0], device[1], device[2]) + task.CreateDOChan(dev, "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + + +if __name__ == "__main__": + + # for i in [3]: + # for j in range(3): + # for k in range(24): + # print('Switching Dev%s, port%s, line%s' % (i, j, k)) + # try: + # c7_relay(new_state='close', device=[i, j, k]) + # time.sleep(1) + # c7_relay(new_state='open', device=[i, j, k]) + # time.sleep(1) + # except Exception, e: + # print('Dev%s, port%s, line%s does not exist' % (i, j, k)) + + analog_channels = ['MCC_PhA_V', 'MCC_PhA_I', 'Diesel_Genset_PhC_V', 'ESTB_PhA_V'] + read = int32() + n_points = 1000 # number of samples per channel + sample_rate = 10000. + + dev_numbers = [] + for i in range(len(analog_channels)): + chan = DSM_CHANNELS[analog_channels[i]]['physChan'] + dev_numbers.append(chan[3]) + + sorted_unique, unique_counts = np.unique(dev_numbers, return_index=False, return_counts=True) # find the unique NI devices + analog_input = [] + physical_channels = [] + chan_decoder = [] + for i in range(len(unique_counts)): + analog_input.append(Task()) + physical_channels.append('') + chan_decoder.append([]) + + raw_data = [] + n_channels = [] + for i in unique_counts: + n_channels.append(i) + raw_data.append(np.zeros((n_points*i,), dtype=np.float64)) + print(n_channels) + print(('raw_data length: %s' % len(raw_data[0]))) + + # print('sorted_unique: %s' % sorted_unique) + + unique_dev_num = -1 # count for the unique devs + for dev in sorted_unique: + unique_dev_num += 1 + for i in range(len(analog_channels)): # for each channel + chan = DSM_CHANNELS[analog_channels[int(i)]]['physChan'] + if dev == chan[3]: # if this device matches, put it in this task + physical_channels[unique_dev_num] += chan + ',' + # print(analog_channels[i]) + # print(unique_dev_num) + chan_decoder[unique_dev_num].append(analog_channels[i]) + # print(chan_decoder) + for dev in range(len(sorted_unique)): # clean up last comma + physical_channels[dev] = physical_channels[dev][:-1] # Remove the last comma. + + print(('Capturing Waveforms on Channels: %s' % physical_channels)) + print(('Waveforms Channels are: %s' % chan_decoder)) + + # Step 1, virtual channels are created. Each one of the virtual channels in question here is used to acquire + # from an analog voltage signal(s). + for i in range(len(sorted_unique)): + analog_input[i].CreateAIVoltageChan(physical_channels[i], # The physical name of the channel + "", # The name to associate with this channel + DAQmx_Val_Cfg_Default, # Differential wiring + -10.0, # Min voltage + 10.0, # Max voltage + DAQmx_Val_Volts, # Units + None) # reserved + + for i in range(len(sorted_unique)-1, -1, -1): + # Start Master last so slave(s) will wait for trigger from master over RSTI bus + print(('Starting Task: %s.' % i)) + analog_input[i].StartTask() + + # DAQmx Read Code + # fillMode options + # 1. DAQmx_Val_GroupByChannel Group by channel (non-interleaved) + # 2. DAQmx_Val_GroupByScanNumber Group by scan number (interleaved) + for i in range(len(sorted_unique)): + # DAQmxReadAnalogF64(task2,sampsPerChanRead1,timeout,DAQmx_Val_GroupByScanNumber, + # buffer2,bufferSize,&sampsPerChanRead2,NULL); + analog_input[i].ReadAnalogF64(n_points, # int32 numSampsPerChan, + 5.0, # float64 timeout, + DAQmx_Val_GroupByChannel, # bool32 fillMode, + raw_data[i], # float64 readArray[], + n_points*n_channels[i], # uInt32 arraySizeInSamps, + byref(read), # int32 *sampsPerChanRead, + None) # bool32 *reserved); + + print("Acquired %d points" % read.value) + print(('raw_data length: %s' % len(raw_data[i]))) + + for i in range(len(sorted_unique)-1, -1, -1): + analog_input[i].StopTask() + analog_input[i].TaskControl(DAQmx_Val_Task_Unreserve) + + dev_idx = -1 + data = {} + chan_idx = None + for i in range(len(analog_channels)): + # print('Getting data for %s' % analog_channels[i]) + for j in range(len(chan_decoder)): + # print('Looking in data set: %s' % chan_decoder[j]) + if any(analog_channels[i] in s for s in chan_decoder[j]): + device_number = dev_numbers[i] + dev_idx = j + chan_idx = chan_decoder[j].index(analog_channels[i]) + break + # print('Channel: %s, Device number: %s, Device Index: %s, Channel Index: %s' + # % (i, device_number, dev_idx, chan_idx)) + # print(raw_data[dev_idx][chan_idx*n_points:(chan_idx+1)*n_points]) + if chan_idx is not None: + scaled_data = dsm_expression(channel_name=analog_channels[i], + dsm_value=raw_data[dev_idx][chan_idx*n_points:(chan_idx+1)*n_points]) + data[analog_channels[i]] = scaled_data + else: + print('No Channel Index Found') + + time_vector = np.linspace(0., n_points/sample_rate, n_points) + # print('time length: %s' % (len(time_vector))) + # for i in range(len(analog_channels)): + # print('data length: %s' % (len(data[analog_channels[i]]))) + + import matplotlib.pyplot as plt + # plt.plot(time, ac_voltage_10, 'r', time, ac_current_10, 'b') + # plt.show() + + fig, ax1 = plt.subplots() + ax1.plot(time_vector, data[analog_channels[0]], 'b-') + ax1.plot(time_vector, data[analog_channels[1]], 'k-') + ax1.plot(time_vector, data[analog_channels[2]], 'c-') + ax1.plot(time_vector, data[analog_channels[3]], 'm-') + ax1.set_xlabel('time (s)') + # Make the y-axis label and tick labels match the line color. + ax1.set_ylabel('AC Voltage', color='b') + for tl in ax1.get_yticklabels(): + tl.set_color('b') + + plt.show() + + avg_P, S, Q1, N, PF1 = waveform_analysis.harmonic_analysis(time_vector, data[analog_channels[0]], + data[analog_channels[1]], + sample_rate, None) + + print(('Power = %s, Q = %s' % (avg_P, Q1))) + + f = open('C:\\SVP\\MCC_waveforms-P=%s, Q=%s.csv' % (avg_P, Q1), 'w') + f.write('Python Time (s), AC Voltage (V), AC Current (A)\n') + for t in range(len(time_vector)): + f.write('%0.6f, %0.6f, %0.6f\n' % (time_vector[t], data[analog_channels[0]][t], data[analog_channels[1]][t])) + f.close() + diff --git a/Lib/svpelab/device_das_dewetron.py b/Lib/svpelab/device_das_dewetron.py new file mode 100644 index 0000000..9677278 --- /dev/null +++ b/Lib/svpelab/device_das_dewetron.py @@ -0,0 +1,408 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import time +from collections import OrderedDict +import datetime + +import numpy as np + +try: + from .dewenetcontroller import dewenetcontroller as dewe +except Exception as e: + print(('Missing dewecontroller. %s' % e)) + +""" +todo: thread needs to be joined and stopped! + +""" + +""" +This is a really bad hack! +""" +data_points = [ # 3 phase + 'TIME', + 'DC_V', + 'DC_I', + 'AC_VRMS_1', + 'AC_VRMS_2', + 'AC_VRMS_3', + 'AC_IRMS_1', + 'AC_IRMS_2', + 'AC_IRMS_3', + 'DC_P', + 'AC_S_1', + 'AC_S_2', + 'AC_S_3', + 'AC_P_1', + 'AC_P_2', + 'AC_P_3', + 'AC_Q_1', + 'AC_Q_2', + 'AC_Q_3', + 'AC_FREQ_1', + 'AC_FREQ_2', + 'AC_FREQ_3', + 'AC_PF_1', + 'AC_PF_2', + 'AC_PF_3', + 'TRIG', + 'TRIG_GRID' + ] + + + +dewe_channelmap = OrderedDict([ + ('TIME', None), + ('AC_VRMS_1', 'Va'), + ('AC_IRMS_1', 'Ia'), + ('AC_P_1', 'Pa'), + ('AC_S_1', 'Sa'), + ('AC_Q_1', 'Qa'), + ('AC_PF_1', 'PFa'), + ('AC_FREQ_1', 'F'), + ('AC_VRMS_2', 'Vb'), + ('AC_IRMS_2', 'Ib'), + ('AC_P_2', 'Pb'), + ('AC_S_2', 'Sb'), + ('AC_Q_2', 'Qb'), + ('AC_PF_2', 'PFb'), + ('AC_FREQ_2', 'F'), + ('AC_VRMS_3', 'Vc'), + ('AC_IRMS_3', 'Ic'), + ('AC_P_3', 'Pc'), + ('AC_S_3', 'Sc'), + ('AC_Q_3', 'Qc'), + ('AC_PF_3', 'PFc'), + ('AC_FREQ_3', 'F'), + ('DC_V', 'Vdc'), + ('DC_I', 'Idc'), + ('DC_P', 'Pdc'), + ('TRIG', None), + ('TRIG_GRID', None) +]) + + +deweResults = OrderedDict() + + +def update_value(channel_name, timestamp, value): + ts_m = (np.float64(timestamp.strftime('%M.0'))*60)*1000 + ts_us = np.longlong(ts_m+np.float64(timestamp.strftime('%S.%f'))*1000) + + for k in list(dewe_channelmap.keys()): + if dewe_channelmap[k]: + if dewe_channelmap[k] in channel_name: + try: + deweResults[k] + except: + deweResults[k] = [] + deweResults[k].append( (ts_us, value) ) + +class Device(object): + + def __logevent__(self, msg): + if self.ts: + self.ts.log(msg) + else: + print('%s' % msg) + + + def __init__(self, params=None): + if not params: + raise ValueError('Params can not be None for this module!') + + + self.deweDevice = None + + self.params = params + + + try: + self.dewehost = self.params['ip_addr'] + self.deweport = self.params['ipport'] + self.sample_interval = self.params['sample_interval'] + except: + raise ValueError('Minimum required paramters were not supplied!') + + + try: + global dewe_channelmap + dewe_channelmap['AC_VRMS_1'] = self.params['AC_VRMS_1'] + dewe_channelmap['AC_VRMS_2'] = self.params['AC_VRMS_2'] + dewe_channelmap['AC_VRMS_3'] = self.params['AC_VRMS_3'] + dewe_channelmap['AC_IRMS_1'] = self.params['AC_IRMS_1'] + dewe_channelmap['AC_IRMS_2'] = self.params['AC_IRMS_2'] + dewe_channelmap['AC_IRMS_3'] = self.params['AC_IRMS_3'] + dewe_channelmap['AC_FREQ_1'] = self.params['AC_FREQ_1'] + dewe_channelmap['AC_FREQ_2'] = self.params['AC_FREQ_2'] + dewe_channelmap['AC_FREQ_3'] = self.params['AC_FREQ_3'] + dewe_channelmap['AC_P_1'] = self.params['AC_P_1'] + dewe_channelmap['AC_P_2'] = self.params['AC_P_2'] + dewe_channelmap['AC_P_3'] = self.params['AC_P_3'] + dewe_channelmap['AC_S_1'] = self.params['AC_S_1'] + dewe_channelmap['AC_S_2'] = self.params['AC_S_2'] + dewe_channelmap['AC_S_3'] = self.params['AC_S_3'] + dewe_channelmap['AC_Q_1'] = self.params['AC_Q_1'] + dewe_channelmap['AC_Q_2'] = self.params['AC_Q_2'] + dewe_channelmap['AC_Q_3'] = self.params['AC_Q_3'] + dewe_channelmap['DC_V'] = self.params['DC_V'] + dewe_channelmap['DC_I'] = self.params['DC_I'] + dewe_channelmap['DC_P'] = self.params['DC_P'] + dewe_channelmap['AC_PF_1'] = self.params['AC_PF_1'] + dewe_channelmap['AC_PF_2'] = self.params['AC_PF_2'] + dewe_channelmap['AC_PF_3'] = self.params['AC_PF_3'] + dewe_channelmap['TIME'] = None + dewe_channelmap['TRIG'] = None + dewe_channelmap['TRIG_GRID'] = None + + self.deweproxyhost = self.params['deweproxy_ip_addr'] + self.deweproxyport = self.params['deweproxy_ip_port'] + + except Exception as e: + self.deweproxyhost = '127.0.0.1' + self.deweproxyport = 9000 + print('Using default map') + + + try: + self.sampling_interval_dewe = self.params['sample_interval_dewe'] + except: + self.sampling_interval_dewe = None + + + try: + self.ts = self.params['ts'] + except: + self.ts = None + + self.__logevent__('DEWESoft NET Plugin Initialized!.') + + try: + self.__logger__ = self.params['logger'] + except: + self.__logger__ = None + + self.channellist = [] + + for k in list(dewe_channelmap.keys()): + if dewe_channelmap[k] is not None: + if dewe_channelmap[k] not in self.channellist: + self.channellist.append(dewe_channelmap[k]) + + + self.data_points = list(data_points) + + self.points = None + self.point_indexes = [] + + # waveform settings + self.wfm_sample_rate = None + self.wfm_pre_trigger = None + self.wfm_post_trigger = None + self.wfm_trigger_level = None + self.wfm_trigger_cond = None + self.wfm_trigger_channel = None + self.wfm_timeout = None + self.wfm_channels = None + self.wfm_capture_name = None + + self.numberOfSamples = None + self.triggerOffset = None + self.decimation = 1 + self.captureSettings = None + self.triggerSettings = None + self.channelSettings = None + + # regular python list is used for data buffer + self.capturedDataBuffer = [] + self.time_vector = None + self.wfm_data = None + self.signalsNames = None + self.analog_channels = [] + self.digital_channels = [] + self.subsampling_rate = None + + + + """ + Why is .open() not handled at toplevel? + """ + self.open() + + def info(self): + if self.deweDevice: + return self.deweDevice.get_dewe_information() + else: + raise ValueError("Not connected to DAS - open() was not called prior") + + def open(self): + if not self.deweDevice: + self.__logevent__('Starting connection to local DEWESoft NET Instance.') + try: + self.deweDevice = dewe.DeweNetController(logger=self.__logger__) + self.deweDevice.connect_to_dewe(dewe_ip=self.dewehost, dewe_port=int(self.deweport), + client_server_ip=self.deweproxyhost, client_server_port=int(self.deweproxyport), + list_of_channels=self.channellist, samplerate=self.sampling_interval_dewe) + + self.deweDevice.add_update_value_handler(update_value, channels=self.channellist) + + self.deweDevice.start_dewe_measurement() + + except Exception as e: + self.__logevent__('Error on establishing connection to dewe! [%s]' % e) + raise + + return self.deweDevice + + + def close(self): + if self.deweDevice: + self.deweDevice.stop_dewe_measurement() + self.deweDevice.disconnect_from_dewe() + self.deweDevice = None + + return self.deweDevice + + + def data_capture(self, enable=True): + """todo: """ + pass + + def data_read(self): + if self.deweDevice: + """For Later dict support: + retry = 0 + e = datetime.datetime.now() + while True: + data = {} + try: + data['TIME'] = time.time() + data['TRIG'] = 0 + data['TRIG_GRID'] = 0 + for i in deweResults.keys(): + data[i] = deweResults[i][-1][1] + + self.__logevent__(data) + return data + except Exception, e: + self.__logevent__(e) + """ + while True: + data = [] + try: + data.append(time.time()) #Channle TIME + for i in data_points: + + if dewe_channelmap[i]: + data.append(deweResults[i][-1][1]) + data.append(0) #channel TRIG + data.append(0) #channel TRIG_GRID + return data + except Exception as e: + pass + else: + raise ValueError("Not connected to DAS - open() was not called prior") + + + + + + + + def waveform_config(self, params): + pass + + def waveform_capture(self, enable=True, sleep=None): + pass + + def waveform_status(self): + pass + + def waveform_force_trigger(self): + """ + Create trigger event with provided value. + """ + pass + + def waveform_capture_dataset(self): + pass + + +if __name__ == "__main__": + + + params = {} + params['ip_addr'] = '127.0.0.1' + params['ipport'] = 8999 + params['sample_interval'] = 1000 + params['sample_interval_dewe'] = 10000 + + params['AC_VRMS_1'] = "EUT/U_rms_L1" + params['AC_VRMS_2'] = "EUT/U_rms_L2" + params['AC_VRMS_3'] = "EUT/U_rms_L3" + params['AC_IRMS_1'] = "EUT/I_rms_L1" + params['AC_IRMS_2'] = "EUT/I_rms_L2" + params['AC_IRMS_3'] = "EUT/I_rms_L3" + params['AC_FREQ_1'] = "EUT/Frequency" + params['AC_FREQ_2'] = "EUT/Frequency" + params['AC_FREQ_3'] = "EUT/Frequency" + params['AC_P_1'] = "EUT/P_L1" + params['AC_P_2'] = "EUT/P_L2" + params['AC_P_3'] = "EUT/P_L1" + params['AC_S_1'] = "EUT/S_L1" + params['AC_S_2'] = "EUT/S_L2" + params['AC_S_3'] = "EUT/S_L3" + params['AC_Q_1'] = "EUT/Q_L1" + params['AC_Q_2'] = "EUT/Q_L2" + params['AC_Q_3'] = "EUT/Q_L3" + params['AC_PF_1'] = "EUT/PF_L1" + params['AC_PF_2'] = "EUT/PF_L2" + params['AC_PF_3'] = "EUT/PF_L3" + params['DC_V'] = "PV/U_rms_L1" + params['DC_I'] = "PV/I_rms_L1" + params['DC_P'] = "PV/P_L1" + + params['deweproxy_ip_addr'] = "0.0.0.0" + params['deweproxy_ip_port'] = 9999 + + d = Device(params=params) + + d.open() + count = 0 + while True: + count +=1 + time.sleep(0.25) + print("[%s] -> %s" % (count, d.data_read())) + if count > 200: break + d.close() + diff --git a/Lib/svpelab/device_das_manual.py b/Lib/svpelab/device_das_manual.py index 3692379..fe0efd7 100644 --- a/Lib/svpelab/device_das_manual.py +++ b/Lib/svpelab/device_das_manual.py @@ -29,11 +29,82 @@ Questions can be directed to support@sunspec.org """ +import time +import random +import numpy as np +import datetime +query_points = { + 'AC_VRMS': 'UTRMS', + 'AC_IRMS': 'ITRMS', + 'AC_P': 'P', + 'AC_S': 'S', + 'AC_Q': 'Q', + 'AC_PF': 'PF', + 'AC_FREQ': 'FCYC', + 'AC_INC': 'INCA', + 'DC_V': 'UDC', + 'DC_I': 'IDC', + 'DC_P': 'P' +} + +initiale_average_values = { + 'U': 120.00, + 'I': 12.00, + 'PF': 0.12, + 'FCYC': 67.00, + 'P': 12345.00, + 'Q': 11111.00, + 'S': 16609.00, + 'INCA': 1.00, + 'Unset': 9991.00 +} + + +class DeviceError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass class Device(object): def __init__(self, params=None): - self.data_points = [] + + self.params = params + self.sample_interval = params.get('sample_interval') + self.channels = params.get('channels') + self.data_points = ['TIME'] + self.rm = None + self.average = initiale_average_values + # Connection object + self.conn = None + self.start_time = None + self.current_time = None + self.query_chan_str = "" + item = 0 + + for i in range(1, 4): + chan = self.channels[i] + if chan is not None: + chan_type = chan.get('type') + points = chan.get('points') + if points is not None: + chan_label = chan.get('label') + if chan_type is None: + raise DeviceError('No channel type specified') + if points is None: + raise DeviceError('No points specified') + for p in points: + item += 1 + point_str = '%s_%s' % (chan_type, p) + chan_str = query_points.get(point_str) + self.query_chan_str += '%s%d?; ' % (chan_str, i) + if chan_label: + point_str = '%s_%s' % (point_str, chan_label) + self.data_points.append(point_str) + + + # Config the rms values def info(self): return 'DAS Manual - 1.0' @@ -45,7 +116,56 @@ def close(self): pass def data_capture(self, enable=True): - pass + self.start_time = None def data_read(self): - return [] + + if self.start_time is None: + self.start_time = np.datetime64(datetime.datetime.utcnow(), 'us') + else : + self.current_time = np.datetime64(datetime.datetime.utcnow(), 'us') + data = [] + points = self.query_chan_str.split(";")[:-1] + for point in points: + if 'U' in point: + data.append(self._gen_data('U')) + elif 'I' in point and 'INCA' not in point: + data.append(self._gen_data('I')) + elif 'PF' in point: + data.append(self._gen_data('PF')) + elif 'FCYC' in point: + data.append(self._gen_data('FCYC')) + elif 'P' in point and 'PF' not in point: + data.append(self._gen_data('P')) + elif 'Q' in point: + data.append(self._gen_data('Q')) + elif 'S' in point and 'RMS' not in point: + data.append(self._gen_data('S')) + elif 'INCA' in point: + data.append(self._gen_data('INCA')) + else: + data.append(self._gen_data('Unset')) + data.insert(0, time.clock()) + return data + + def _gen_data(self, key): + delta = random.uniform(-0.5, 0.5) + r = random.random() + + if key == 'INCA': + if r > 0.9: + self.average[key] = -1 + elif r > 0.8: + self.average[key] = 0 + else: + self.average[key] = 1 + else: + if r > 0.9: + self.average[key] += delta * 0.33*self.average[key] + elif r > 0.8: + # attraction to the initial value + delta += (0.5 if initiale_average_values[key] > self.average[key] else -0.5) + self.average[key] += delta*0.01*self.average[key] + else: + self.average[key] += delta*0.01*self.average[key] + return self.average[key] diff --git a/Lib/svpelab/device_das_opal.py b/Lib/svpelab/device_das_opal.py new file mode 100644 index 0000000..a87f07e --- /dev/null +++ b/Lib/svpelab/device_das_opal.py @@ -0,0 +1,729 @@ +""" +Copyright (c) 2020 +All rights reserved. + +Questions can be directed to support@sunspec.org +""" + +import time +import traceback +import glob +from . import waveform +from . import dataset +import sys +import glob, os +from collections import OrderedDict + +# data_points = [ # 3 phase +# 'TIME', +# 'DC_V', +# 'DC_I', +# 'AC_VRMS_1', +# 'AC_VRMS_2', +# 'AC_VRMS_3', +# 'AC_IRMS_1', +# 'AC_IRMS_2', +# 'AC_IRMS_3', +# 'DC_P', +# 'AC_S_1', +# 'AC_S_2', +# 'AC_S_3', +# 'AC_P_1', +# 'AC_P_2', +# 'AC_P_3', +# 'AC_Q_1', +# 'AC_Q_2', +# 'AC_Q_3', +# 'AC_FREQ_1', +# 'AC_FREQ_2', +# 'AC_FREQ_3', +# 'AC_PF_1', +# 'AC_PF_2', +# 'AC_PF_3', +# 'TRIG', +# 'TRIG_GRID' +# ] + +# Channels to be captured during the waveform capture +# Todo : This should be provided by the IEEE 1547 library + +WFM_CHANNELS = {'Generic': ['TIME', 'AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'], + 'PhaseJump': ['TIME', 'AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'Trigger', + 'Total_RMS_Current', 'Time_Below_80pct_Current', 'Time_Phase_Misalignment', + 'Ph_Del_A', 'Ph_Del_B', 'Ph_Del_C'], + 'PhaseJumpOld': ['TIME', 'AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'Trigger', + 'Total_RMS_Current', 'Time_Below_80pct_Current', 'Time_Phase_Misalignment'], + 'VRT': ['TIME', 'AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', + 'AC_V_1_TARGET', 'AC_V_2_TARGET', 'AC_V_3_TARGET'], + 'FRT': ['TIME', 'AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', + 'AC_V_1_TARGET', 'AC_V_2_TARGET', 'AC_V_3_TARGET'], + 'VRT_RMS': ['TIME', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'AC_P_1', 'AC_P_2', 'AC_P_3', + 'AC_Q_1', 'AC_Q_2', 'AC_Q_3', 'Trigger'], + 'Opal_UI_1547': ['TIME', 'TRIGGER', 'AC_I_1', 'AC_I_2', 'AC_I_3', + 'AC_V_1', 'AC_V_2', 'AC_V_3'], + 'IEEE1547_VRT': ['TIME', + 'AC_V_1', 'AC_V_2', 'AC_V_3', + 'AC_I_1', 'AC_I_2', 'AC_I_3', + 'AC_P_1', 'AC_P_2', 'AC_P_3', + 'AC_Q_1', 'AC_Q_2', 'AC_Q_3', + 'AC_V_CMD_1', 'AC_V_CMD_2', 'AC_V_CMD_3', + "TRIGGER"] + } + + +class MatlabException(Exception): + pass + + +class Device(object): + + def __init__(self, params=None): + self.params = params + self.ts = self.params['ts'] + + # import based on the version number + rt_version = self.params['rt_lab_version'] + rt_import_path = "C://OPAL-RT//RT-LAB//%s//common//python" % rt_version + try: + sys.path.insert(0, rt_import_path) + import RtlabApi + import OpalApiPy + self.RtlabApi = RtlabApi + self.OpalApiPy = OpalApiPy + except ImportError as e: + ts.log('RtlabApi Import Error. Check the version number. Using path = %s. %s' % (import_path, e)) + print('RtlabApi Import Error. Check the version number. Using path = %s' % import_path) + print(e) + + self.points = None + self.point_indexes = [] + + self.map = self.params['map'] + self.sc_capture = self.params['sc_capture'] + self.sample_interval = self.params['sample_interval'] + self.wfm_dir = self.params['wfm_dir'] + self.data_name = self.params['data_name'] + self.dc_measurement_device = None + # _, self.model_name = self.RtlabApi.GetCurrentModel() + + self.mat_location = '' + self.csv_location = '' + + # optional parameters for interfacing with other SVP devices + self.hil = self.params['hil'] + self.model_name = self.hil.rt_lab_model + self.target_name = self.hil.target_name + + self.gridsim = self.params['gridsim'] + self.dc_measurement_device = self.params['dc_measurement_device'] + + self.ts.log_debug('DAS connected to with HIL: %s, DC meas: %s, and gridsim: %s' % + (self.hil, self.dc_measurement_device, self.gridsim)) + + # TODO: All this could be replaced with Alias manipulations. + if self.sc_capture == 'No': + # Mapping from the channels to be captured and the names that are used in the Opal environment + self.opal_map_phase_jump = OrderedDict({ # data point : analog channel name + 'TIME': self.model_name + '/SM_Source/Clock1/port1', + 'AC_VRMS_1': self.model_name + '/SM_Source/AC_VRMS_1/Switch/port1', + 'AC_VRMS_2': self.model_name + '/SM_Source/AC_VRMS_2/Switch/port1', + 'AC_VRMS_3': self.model_name + '/SM_Source/AC_VRMS_3/Switch/port1', + 'AC_IRMS_1': self.model_name + '/SM_Source/AC_IRMS_1/Switch/port1', + 'AC_IRMS_2': self.model_name + '/SM_Source/AC_IRMS_2/Switch/port1', + 'AC_IRMS_3': self.model_name + '/SM_Source/AC_IRMS_3/Switch/port1', + 'AC_P_1': self.model_name + '/SM_Source/AC_P_1/port1(2)', + 'AC_P_2': self.model_name + '/SM_Source/AC_P_2/port1(2)', + 'AC_P_3': self.model_name + '/SM_Source/AC_P_3/port1(2)', + 'AC_Q_1': self.model_name + '/SM_Source/AC_Q_1/port1(2)', + 'AC_Q_2': self.model_name + '/SM_Source/AC_Q_2/port1(2)', + 'AC_Q_3': self.model_name + '/SM_Source/AC_Q_3/port1(2)', + 'AC_S_1': self.model_name + '/SM_Source/AC_S_1/port1(2)', + 'AC_S_2': self.model_name + '/SM_Source/AC_S_2/port1(2)', + 'AC_S_3': self.model_name + '/SM_Source/AC_S_3/port1(2)', + 'AC_PF_1': self.model_name + '/SM_Source/AC_PF_3/port1(2)', + 'AC_PF_2': self.model_name + '/SM_Source/AC_PF_2/port1(2)', + 'AC_PF_3': self.model_name + '/SM_Source/AC_PF_3/port1(2)', + 'AC_FREQ_1': self.model_name + '/SM_Source/AC_FREQ_1/port1', + 'AC_FREQ_2': self.model_name + '/SM_Source/AC_FREQ_2/port1', + 'AC_FREQ_3': self.model_name + '/SM_Source/AC_FREQ_3/port1', + 'DC_V': None, + 'DC_I': None, + 'DC_P': None, + 'TRIG': self.model_name + '/SM_Source/Switch5/port1', + 'TRIG_GRID': self.model_name + '/SM_Source/Switch5/port1'}) + + self.opal_map_phase_jump_w_phase_realign = OrderedDict({ # data point : analog channel name + 'TIME': self.model_name + '/SM_Source/Clock1/port1', + 'AC_VRMS_1': self.model_name + '/SM_Source/AC_VRMS_1/Switch/port1', + 'AC_VRMS_2': self.model_name + '/SM_Source/AC_VRMS_2/Switch/port1', + 'AC_VRMS_3': self.model_name + '/SM_Source/AC_VRMS_3/Switch/port1', + 'AC_IRMS_1': self.model_name + '/SM_Source/AC_IRMS_1/Switch/port1', + 'AC_IRMS_2': self.model_name + '/SM_Source/AC_IRMS_2/Switch/port1', + 'AC_IRMS_3': self.model_name + '/SM_Source/AC_IRMS_3/Switch/port1', + 'AC_P_1': self.model_name + '/SM_Source/AC_P_1/port1(2)', + 'AC_P_2': self.model_name + '/SM_Source/AC_P_2/port1(2)', + 'AC_P_3': self.model_name + '/SM_Source/AC_P_3/port1(2)', + 'AC_Q_1': self.model_name + '/SM_Source/AC_Q_1/port1(2)', + 'AC_Q_2': self.model_name + '/SM_Source/AC_Q_2/port1(2)', + 'AC_Q_3': self.model_name + '/SM_Source/AC_Q_3/port1(2)', + 'AC_S_1': self.model_name + '/SM_Source/AC_S_1/port1(2)', + 'AC_S_2': self.model_name + '/SM_Source/AC_S_2/port1(2)', + 'AC_S_3': self.model_name + '/SM_Source/AC_S_3/port1(2)', + 'AC_PF_1': self.model_name + '/SM_Source/AC_PF_3/port1(2)', + 'AC_PF_2': self.model_name + '/SM_Source/AC_PF_2/port1(2)', + 'AC_PF_3': self.model_name + '/SM_Source/AC_PF_3/port1(2)', + 'AC_FREQ_1': self.model_name + '/SM_Source/AC_FREQ_1/port1', + 'AC_FREQ_2': self.model_name + '/SM_Source/AC_FREQ_2/port1', + 'AC_FREQ_3': self.model_name + '/SM_Source/AC_FREQ_3/port1', + 'DC_V': None, + 'DC_I': None, + 'DC_P': None, + 'TRIG': self.model_name + '/SM_Source/Switch5/port1', + 'TRIG_GRID': self.model_name + '/SM_Source/Switch5/port1', + 'T_Phase_Realign': self.model_name + '/SM_Source/T_Phase_Realign/port1', + 'T_Curr_80': self.model_name + '/SM_Source/T_Curr_80/port1'}) + + self.opal_map_ekhi = OrderedDict({ # data point : analog channel name + 'TIME': self.model_name + '/SM_LOHO13/Dynamic Load Landfill/Clock1/port1', + 'IED2_V_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(1)', + 'IED2_V_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(3)', + 'IED2_V_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(5)', + 'IED2_I_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(7)', + 'IED2_I_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(9)', + 'IED2_I_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(11)', + 'IED2_Frequency': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(13)', + 'IED5_V_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(15)', + 'IED5_V_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(17)', + 'IED5_V_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(19)', + 'IED5_I_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(21)', + 'IED5_I_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(23)', + 'IED5_I_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(25)', + 'IED5_Frequency': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(27)', + 'IED9_V_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(29)', + 'IED9_V_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(31)', + 'IED9_V_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(33)', + 'IED9_I_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(35)', + 'IED9_I_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(37)', + 'IED9_I_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(39)', + 'IED9_Frequency': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(41)', + 'IED13_V_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(43)', + 'IED13_V_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(45)', + 'IED13_V_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(47)', + 'IED13_I_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(49)', + 'IED13_I_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(51)', + 'IED13_I_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(53)', + 'IED13_Frequency': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(55)', + 'IED17_V_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(57)', + 'IED17_V_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(59)', + 'IED17_V_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(61)', + 'IED17_I_1': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(63)', + 'IED17_I_2': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(65)', + 'IED17_I_3': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(67)', + 'IED17_Frequency': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(69)', + 'GPS_YEAR': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(71)', + 'GPS_DAY': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(72)', + 'GPS_HOUR': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(73)', + 'GPS_MIN': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(74)', + 'GPS_SEC': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(75)', + # 'GPS_NANOSEC': self.model_name + '/SM_LOHO13/SS_PMU/SVPOUT/port1(76)', + 'DC_V': None, + 'DC_I': None, + 'DC_P': None}) + + self.opal_fast_1547 = OrderedDict({ # data point : analog channel name + 'TIME': self.model_name + "/SM_Source/IEEE_1547_TESTING/Clock/port1", + # Voltage + 'AC_VRMS_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_VRMS_1/Switch/port1', + 'AC_VRMS_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_VRMS_2/Switch/port1', + 'AC_VRMS_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_VRMS_3/Switch/port1', + # Current + 'AC_IRMS_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_IRMS_1/Switch/port1', + 'AC_IRMS_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_IRMS_2/Switch/port1', + 'AC_IRMS_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_IRMS_3/Switch/port1', + # Frequency + 'AC_FREQ_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_FREQ_1/port1', + 'AC_FREQ_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_FREQ_2/port1', + 'AC_FREQ_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_FREQ_3/port1', + # Active Power + 'AC_P_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_P_1/port1', + 'AC_P_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_P_2/port1', + 'AC_P_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_P_3/port1', + # Reactive Power + 'AC_Q_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_Q_1/port1', + 'AC_Q_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_Q_2/port1', + 'AC_Q_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_Q_3/port1', + # Apparent Power + 'AC_S_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_S_1/port1', + 'AC_S_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_S_2/port1', + 'AC_S_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_S_3/port1', + # Power Factor + 'AC_PF_1': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_PF_1/port1', + 'AC_PF_2': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_PF_2/port1', + 'AC_PF_3': self.model_name + '/SM_Source/IEEE_1547_TESTING/SignalConditionning/AC_PF_3/port1', + + # TODO : As some point this will be read it from HIL + 'DC_V': None, + 'DC_I': None, + 'DC_P': None}) + + # Mapping from the channels to be captured and the names that are used in the Opal environment + opal_points_map = { + 'Opal_Phase_Jump': self.opal_map_phase_jump, # For use with the IEEE 1547.1 Phase Jump Tests + 'Opal_Phase_Jump_Realign': self.opal_map_phase_jump_w_phase_realign, # Phase Jump Tests with Realignment + 'Ekhi': self.opal_map_ekhi, # For use with Ekhi + 'Opal_Fast_1547': self.opal_fast_1547, # PCRT, VRT and FRT + } + self.data_point_map = opal_points_map[self.map] # dict with the {data_point: opal signal} map + + # self.data_points will be appended with the soft channels, so keep a local version for getting device data + self.data_points = list(opal_points_map[self.map].keys()) + self.data_points_device = list(opal_points_map[self.map].keys()) + + else: # self.sc_capture == 'Yes' + """ + For data capture from data acquisition signals in the SC_ console + """ + self.opal_map_ui = OrderedDict() + if self.hil is not None: # Populate dictionary with Opal-RT names and labels + acq_sigs = self.hil.get_acq_signals(verbose=False) # get tuple of acq channels (signalId, label, value) + # self.ts.log_debug('acq_sigs: %s' % str(acq_sigs)) + for i in range(len(list(acq_sigs))): + label = acq_sigs[i][1].rsplit('.', 1)[-1] + self.opal_map_ui[label] = acq_sigs[i][1] + + # Replace Opal-RT keys in the dataset with SVP keys + self.data_points = list(self.opal_map_ui.keys()) + # self.ts.log_debug('List: %s' % self.data_points) + self.data_points[self.data_points.index('Utility Vph(1)')] = 'AC_VRMS_SOURCE_1' # Utility measurements + self.data_points[self.data_points.index('Utility Vph(2)')] = 'AC_VRMS_SOURCE_2' + self.data_points[self.data_points.index('Utility Vph(3)')] = 'AC_VRMS_SOURCE_3' + self.data_points[self.data_points.index('Utility Vph pu(1)')] = 'AC_VRMS_SOURCE_1_PU' # Utility measurements + self.data_points[self.data_points.index('Utility Vph pu(2)')] = 'AC_VRMS_SOURCE_2_PU' + self.data_points[self.data_points.index('Utility Vph pu(3)')] = 'AC_VRMS_SOURCE_3_PU' + self.data_points[self.data_points.index('Utility I(1)')] = 'AC_IRMS_SOURCE_1' + self.data_points[self.data_points.index('Utility I(2)')] = 'AC_IRMS_SOURCE_2' + self.data_points[self.data_points.index('Utility I(3)')] = 'AC_IRMS_SOURCE_3' + self.data_points[self.data_points.index('Puti_Watts')] = 'AC_SOURCE_P' + self.data_points[self.data_points.index('Quti_Vars')] = 'AC_SOURCE_Q' + self.data_points[self.data_points.index('Utility Ppu')] = 'AC_SOURCE_P_PU' + self.data_points[self.data_points.index('Utility Qpu')] = 'AC_SOURCE_Q_PU' + self.data_points[self.data_points.index('Inv Vph(1)')] = 'AC_VRMS_1' # Inverter measurements + self.data_points[self.data_points.index('Inv Vph(2)')] = 'AC_VRMS_2' + self.data_points[self.data_points.index('Inv Vph(3)')] = 'AC_VRMS_3' + self.data_points[self.data_points.index('Inv I(1)')] = 'AC_IRMS_1' + self.data_points[self.data_points.index('Inv I(2)')] = 'AC_IRMS_2' + self.data_points[self.data_points.index('Inv I(3)')] = 'AC_IRMS_3' + self.data_points[self.data_points.index('Inv Ptot')] = 'AC_P' + self.data_points[self.data_points.index('Inv Qtot')] = 'AC_Q' + self.data_points[self.data_points.index('Inv Freq')] = 'AC_FREQ_1' + self.data_points[self.data_points.index('Load Vph(1)')] = 'AC_VRMS_LOAD_1' # Load measurements + self.data_points[self.data_points.index('Load Vph(2)')] = 'AC_VRMS_LOAD_2' + self.data_points[self.data_points.index('Load Vph(3)')] = 'AC_VRMS_LOAD_3' + self.data_points[self.data_points.index('Load I(1)')] = 'AC_IRMS_LOAD_1' + self.data_points[self.data_points.index('Load I(2)')] = 'AC_IRMS_LOAD_2' + self.data_points[self.data_points.index('Load I(3)')] = 'AC_IRMS_LOAD_3' + self.data_points[self.data_points.index('PLoad Watts')] = 'AC_P_LOAD' + self.data_points[self.data_points.index('QLoad Vars')] = 'AC_Q_LOAD' + self.data_points[self.data_points.index('Load Ppu')] = 'AC_P_LOAD_PU' + self.data_points[self.data_points.index('Load Qpu')] = 'AC_Q_LOAD_PU' + + self.data_points[self.data_points.index('Inv QFactor')] = 'QUALITY_FACTOR' + + # R + self.data_points[self.data_points.index('IR(1)')] = 'AC_IRMS_LOAD_R_1' + self.data_points[self.data_points.index('IR(2)')] = 'AC_IRMS_LOAD_R_2' + self.data_points[self.data_points.index('IR(3)')] = 'AC_IRMS_LOAD_R_3' + self.data_points[self.data_points.index('R1_P')] = 'AC_P_LOAD_R_1' # pu + self.data_points[self.data_points.index('R2_P')] = 'AC_P_LOAD_R_2' + self.data_points[self.data_points.index('R3_P')] = 'AC_P_LOAD_R_3' + self.data_points[self.data_points.index('R1_Q')] = 'AC_Q_LOAD_R_1' + self.data_points[self.data_points.index('R2_Q')] = 'AC_Q_LOAD_R_2' + self.data_points[self.data_points.index('R3_Q')] = 'AC_Q_LOAD_R_3' + + # L + self.data_points[self.data_points.index('IL(1)')] = 'AC_IRMS_LOAD_L_1' + self.data_points[self.data_points.index('IL(2)')] = 'AC_IRMS_LOAD_L_2' + self.data_points[self.data_points.index('IL(3)')] = 'AC_IRMS_LOAD_L_3' + self.data_points[self.data_points.index('L1_P')] = 'AC_P_LOAD_L_1' # pu + self.data_points[self.data_points.index('L2_P')] = 'AC_P_LOAD_L_2' + self.data_points[self.data_points.index('L3_P')] = 'AC_P_LOAD_L_3' + self.data_points[self.data_points.index('L1_Q')] = 'AC_Q_LOAD_L_1' + self.data_points[self.data_points.index('L2_Q')] = 'AC_Q_LOAD_L_2' + self.data_points[self.data_points.index('L3_Q')] = 'AC_Q_LOAD_L_3' + self.data_points[self.data_points.index('QL pu')] = 'QL' + + # C + self.data_points[self.data_points.index('IC(1)')] = 'AC_IRMS_LOAD_C_1' + self.data_points[self.data_points.index('IC(2)')] = 'AC_IRMS_LOAD_C_2' + self.data_points[self.data_points.index('IC(3)')] = 'AC_IRMS_LOAD_C_3' + self.data_points[self.data_points.index('C1_P')] = 'AC_P_LOAD_C_1' # pu + self.data_points[self.data_points.index('C2_P')] = 'AC_P_LOAD_C_2' + self.data_points[self.data_points.index('C3_P')] = 'AC_P_LOAD_C_3' + self.data_points[self.data_points.index('C1_Q')] = 'AC_Q_LOAD_C_1' + self.data_points[self.data_points.index('C2_Q')] = 'AC_Q_LOAD_C_2' + self.data_points[self.data_points.index('C3_Q')] = 'AC_Q_LOAD_C_3' + self.data_points[self.data_points.index('QC pu')] = 'QC' + + # Switch P/Q in pu + self.data_points[self.data_points.index('S1_P_Pu')] = 'AC_P_S1_PU' + self.data_points[self.data_points.index('S2_P_Pu')] = 'AC_P_S2_PU' + self.data_points[self.data_points.index('S3_P_Pu')] = 'AC_P_S3_PU' + self.data_points[self.data_points.index('S1_Q_Pu')] = 'AC_Q_S1_PU' + self.data_points[self.data_points.index('S2_Q_Pu')] = 'AC_Q_S2_PU' + self.data_points[self.data_points.index('S3_Q_Pu')] = 'AC_Q_S3_PU' + + # Switch P/Q in watts/vars + self.data_points[self.data_points.index('S1_P_Watts')] = 'AC_P_S1' + self.data_points[self.data_points.index('S2_P_Watts')] = 'AC_P_S2' + self.data_points[self.data_points.index('S3_P_Watts')] = 'AC_P_S3' + self.data_points[self.data_points.index('S1_Q_Vars')] = 'AC_Q_S1' + self.data_points[self.data_points.index('S2_Q_Vars')] = 'AC_Q_S2' + self.data_points[self.data_points.index('S3_Q_Vars')] = 'AC_Q_S3' + + self.data_points[self.data_points.index('Resistor (ohms)')] = 'R' # RLC measurements + self.data_points[self.data_points.index('Rint (ohms)')] = 'R_INT' + self.data_points[self.data_points.index('Inductor (mH)')] = 'L' + self.data_points[self.data_points.index('Capacitor (uF)')] = 'C' + self.data_points[self.data_points.index('Freq PCC')] = 'AC_FREQ_PCC' + self.data_points[self.data_points.index('pf_inv')] = 'AC_PF_1' + + self.data_points[self.data_points.index('Trip Time(1)')] = 'TRIP_TIME' + self.data_points[self.data_points.index('Island Freq(1)')] = 'ISLAND_FREQ' + self.data_points[self.data_points.index('Island Vrms(1)')] = 'ISLAND_VRMS' + + # self.ts.log_debug('data_points %s' % self.data_points) + + # After the simulation the data is stored in a .mat file. Matlab is used to convert this to a .csv file. + # Get the svpelab directory and then add the \OpalRT\... + self.driver_path = os.path.dirname(os.path.realpath(__file__)) + + # location where opal saves the waveform data (.mat) + # self.mat_location = self.wfm_dir + self.data_name + # location where matlab saves the waveform data (.csv) + # self.csv_location = self.wfm_dir + f'\{self.data_name.split(".mat")[0]}_temp.csv' + self.wfm_channels = WFM_CHANNELS.get(self.params['wfm_chan_list']) + self.waveform_config({"mat_file_name": self.data_name}) + + # delete the old data file + try: + os.remove(self.csv_location) + except Exception as e: + # self.ts.log_warning('Could not delete old data file at %s: %s' % (self.csv_location, e)) + pass + + def info(self): + """ + Return system information + + :return: Opal Information + """ + system_info = self.RtlabApi.GetTargetNodeSystemInfo(self.target_name) + opal_rt_info = "OPAL-RT - Platform version {0} (IP address : {1})".format(system_info[1], system_info[6]) + return opal_rt_info + + def open(self): + pass + + def close(self): + pass + + def data_capture(self, enable=True): + pass + + def data_read(self): + """ + Collect the data for each of the signals representing the data set + + :return: list with data aligned with the data_points order + """ + + dc_meas = None + if self.dc_measurement_device is not None: + try: + dc_meas = self.dc_measurement_device.measurements_get() + # if self.ts is not None: + # self.ts.log_debug('The DC measurements are %s' % dc_meas) + # else: + # print('The DC measurements are %s' % dc_meas) + except Exception as e: + self.ts.log_debug('Could not get data from DC Measurement Object. %s' % e) + + data = [] + try: + if self.sc_capture == 'Yes': + # self.ts.log_debug('Collecting data from the console\'s acquisition signals.') + try: + data = self.hil.get_acq_signals_raw(verbose=False) + except Exception as e: + self.ts.log_debug('Could not get data using get_acq_signals_raw. Error: %s' % e) + else: + for chan in self.data_points_device: + # self.ts.log_debug('self.data_points = %s' % self.data_points) + signal = self.data_point_map[chan] # get signal name associated with data name + # self.ts.log_debug('Chan = %s, signal = %s' % (chan, signal)) + if signal is None: # skip the signals that have no mapping to the simulink model + + # search the dc measurement object for the data that isn't in the opal_points_map + if self.dc_measurement_device is not None: + dc_value = dc_meas.get(chan) # signal = 'DC_V', 'DC_I', or 'DC_P' + # if self.ts is not None: + # self.ts.log_debug('Setting Chan = %s to dc_value = %s' % (chan, dc_value)) + # else: + # print('Setting Chan = %s to dc_value = %s' % (chan, dc_value)) + if dc_value is not None: + data.append(dc_value) + else: # Channel data missing + # self.ts.log_debug('Appending None for data point: %s' % chan) + data.append(None) + else: # DC Measurement Object missing + + # self.ts.log_debug('Appending None for data point: %s' % chan) + data.append(None) + continue + + # verify the model is running before getting the signal data. + status, _ = self.RtlabApi.GetModelState() + if status == self.RtlabApi.MODEL_RUNNING: + signal_value = self.RtlabApi.GetSignalsByName(signal) + # self.ts.log_warning('signal_value is %s from Opal' % signal_value) + else: + signal_value = None + # self.ts.log_warning('Signal_value set to None because Opal isn\'t running') + + # self.ts.log_debug('Signal %s = %s' % (signal, signal_value)) + # self.ts.log_warning('type(sig) %s' % type(signal_value)) + if signal_value is not None and signal_value is not 'None': + data.append(signal_value) + else: + data.append(None) + + except Exception as e: + self.ts.log_debug('Could not get data. Simulation likely completed. Error: %s' % e) + # self.ts.log_warning('self.data_points = %s. Writing all Nones.' % self.data_points) + # self.ts.log_warning('self.data_points = %s. Writing all Nones.' % self.data_points_device) + data = [None]*len(self.data_points_device) # Return list of Nones when simulations stops. + # todo: this should be fixed in das.py sometime where a None can be returned and not added to the database + + return data + + def get_acq_signals(self): + """ + Get the data acquisition signals from the model + + :return: dict with "label" keys and "value" values + + """ + signals = self.RtlabApi.GetSignalsDescription() + # array of tuples: (signalType, signalId, path, label, reserved, readonly, value) + # 0 signalType: Signal type. See OP_SIGNAL_TYPE. + # 1 signalId: Id of the signal. + # 2 path: Path of the signal. + # 3 label: Label or name of the signal. + # 4 reserved: unused? + # 5 readonly: True when the signal is read-only. + # 6 value: Current value of the signal. + + # ts.log_debug('Signals: %s' % signals) + acq_signals = {} + for sig in range(len(signals)): + if str(signals[sig][0]) == 'OP_ACQUISITION_SIGNAL(0)': + acq_signals[signals[sig][1]] = signals[sig][6] + + return acq_signals + + def waveform_config(self, params): + """ + Configure waveform capture. + + params: Dictionary with following entries: + 'sample_rate' - Sample rate (samples/sec) + 'pre_trigger' - Pre-trigger time (sec) + 'post_trigger' - Post-trigger time (sec) + 'trigger_level' - Trigger level + 'trigger_cond' - Trigger condition - ['Rising_Edge', 'Falling_Edge'] + 'trigger_channel' - Trigger channel - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + 'timeout' - Timeout (sec) + 'channels' - Channels to capture - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + """ + + # start_time_value = params["start_time_value"] + # end_time_value = params["end_time_value"] + # start_time_variable = params["start_time_variable"] + # end_time_variable = params["end_time_variable"] + # variables = [] + # variables.append((start_time_variable, start_time_value)) + # variables.append((end_time_variable, end_time_value)) + # self.hil.set_variables(variables) + + # self.ts.log_debug(params) + mat_file_name = params.get("mat_file_name") + self.data_name = mat_file_name + self.mat_location = self.wfm_dir + mat_file_name + self.csv_location = self.wfm_dir + f'\{mat_file_name.split(".mat")[0]}_temp.csv' + + def waveform_capture(self, enable=True, sleep=None): + """ + Enable/disable waveform capture. + """ + if enable: + pass + + def waveform_status(self): + """ + + :return: str 'INACTIVE', 'ACTIVE', or 'COMPLETE' + """ + pass + + def waveform_force_trigger(self): + """ + Create trigger event with provided value. + """ + pass + + def waveform_capture_dataset(self): + """ + Convert saved waveform data into a list of datasets + + Steps: + 1. Use matlab to read in the .mat file that is saved with an OpWriteFile block in RT-Lab + 2. Use matlab to write a .csv file in the same directory with the data header and the simulation data + 3. Use python to read the .csv file and save the data as a database object + + :return: dataset + """ + + # in case multiple waveform captures are required for the test, create list of datasets + datasets = [] + os.chdir(self.wfm_dir) + self.ts.log_debug(f'.mat in {self.wfm_dir,self.data_name }') + + for entry in glob.glob("*.mat"): + if self.data_name in entry: + self.mat_location = f'{self.wfm_dir}\{entry}' + self.ts.log_debug(f'The model state is {self.hil.model_state()}') + + while self.hil.model_state() == "Model Resetting": + self.ts.log_debug('The model is still resetting. Waiting 10 sec') + self.ts.sleep(10) + + # Pull in saved data from the .mat files + self.ts.log('Loading %s file in matlab...' % self.mat_location) + m_cmd = "load('" + self.mat_location + "')" + # self.ts.log_debug('Running matlab command: %s' % m_cmd) + if isinstance(self.matlab_cmd(m_cmd), MatlabException): + self.ts.log_warning('Matlab command failed. Waiting 10 sec and retrying...') + self.ts.sleep(10) + self.matlab_cmd(m_cmd) + + # Add the header to the data in Matlab + self.ts.log('Adding Data Header from self.wfm_channels = %s' % self.wfm_channels) + m_cmd = "header = {" + str(self.wfm_channels)[1:-1] + "};" + if isinstance(self.matlab_cmd(m_cmd), MatlabException): + self.ts.log_warning('Matlab command failed. Waiting 10 sec and retrying...') + self.ts.sleep(10) + self.matlab_cmd(m_cmd) + self.matlab_cmd("[x, y] = size(Data);") + self.matlab_cmd("data_w_header = cell(y+1,x);") + self.matlab_cmd("data_w_header(1,:) = header;") + self.matlab_cmd("data_w_header(2:y+1,:) = num2cell(Data');") + + # save as xlsx + # m_cmd = "xlswrite(('" + self.csv_location + "'), data_w_header)" + # self.ts.log_debug('Running matlab command: %s' % m_cmd) + # self.ts.log_debug('Matlab: ' + self.matlab_cmd(m_cmd)) + + # save the data as a csv file so it is easier to read in python + self.ts.log('Saving the waveform data as .csv file in %s' % self.csv_location) + m_cmd = "fid = fopen('" + self.csv_location + "', 'wt');" + m_cmd += "if fid > 0\n" + m_cmd += "fprintf(fid, '" + "%s,"*(len(self.wfm_channels)-1) + "%s\\n', data_w_header{1,:});\n" + m_cmd += "for k=2:size(data_w_header, 1)\n" + m_cmd += "fprintf(fid, '" + "%f,"*(len(self.wfm_channels)-1) + "%f\\n', data_w_header{k,:});\n" + m_cmd += "end\n" + m_cmd += "fclose(fid);\n" + m_cmd += "end\n" + + # self.ts.log_debug('Matlab: ' + m_cmd) + if self.matlab_cmd(m_cmd) == '': + self.ts.log_warning('Matlab command failed. Waiting 10 sec and retrying...') + self.ts.sleep(10) + self.matlab_cmd(m_cmd) + + # read csv file and convert to ds + ds = dataset.Dataset() + ds.from_csv(filename=self.csv_location) + + datasets.append(ds) + + return datasets + + def get_signals(self): + """ + Get the signals from the model + + :return: list of parameter tuples with (signalID, path, label, value) + """ + # (signalType, signalId, path, label, reserved, readonly, value) = self.RtlabApi.GetSignalsDescription() + signal_parameters = self.RtlabApi.GetSignalsDescription() + signal_params = [] + for sig in range(len(signal_parameters)): + signal_params.append((signal_parameters[sig][1], + signal_parameters[sig][2], + signal_parameters[sig][3], + signal_parameters[sig][6])) + self.ts.log_debug('Signal #%s: %s [%s] = %s' % (signal_parameters[sig][1], + signal_parameters[sig][2], + signal_parameters[sig][3], + signal_parameters[sig][6])) + return signal_params + + def matlab_cmd(self, cmd): + try: + result = self.RtlabApi.ExecuteMatlabCmd(cmd) + return result + except Exception as e: + self.ts.log_warning('Cannot execute Matlab command: %s' % e) + return MatlabException(e) + + def set_dc_measurement(self, obj=None): + """ + DEPRECATED + + In the event that DC measurements are taken from another device (e.g., a PV simulator) please add this + device to the das object + :param obj: The object (e.g., pvsim) that will gather the dc measurements + :return: None + """ + + if obj is not None: + self.ts.log('DAS DC Measurement Device configured to be %s' % (obj.info())) + self.dc_measurement_device = obj + + +if __name__ == "__main__": + + import_path = "C://OPAL-RT//RT-LAB//2020.4//common//python" % rt_version + try: + sys.path.insert(0, import_path) + import RtlabApi + import OpalApiPy + except ImportError as e: + print('RtlabApi Import Error. Check the version number. Using path = %s' % import_path) + print(e) + + system_info = RtlabApi.GetTargetNodeSystemInfo("RTServer") + + projectName = os.path.abspath("C:\\Users\\DETLDAQ\\OPAL-RT\\RT-LABv2019.1_Workspace\\IEEE_1547.1_Phase_Jump\\" + "models\\Phase_Jump_A_B_A\\Phase_Jump_A_B_A.llp") + RtlabApi.OpenProject(projectName) + print("The connection with '%s' is completed." % projectName) + + modelState, realTimeMode = RtlabApi.GetModelState() + print(modelState, realTimeMode) + + + + + + diff --git a/Lib/svpelab/device_das_powerlogic_pm800.py b/Lib/svpelab/device_das_powerlogic_pm800.py index 6103d86..485361a 100644 --- a/Lib/svpelab/device_das_powerlogic_pm800.py +++ b/Lib/svpelab/device_das_powerlogic_pm800.py @@ -33,12 +33,59 @@ Questions can be directed to support@sunspec.org """ + +# data_points = [ +# 'TIME', +# 'DC_V', +# 'DC_I', +# 'AC_VRMS_1', +# 'AC_IRMS_1', +# 'DC_P', +# 'AC_S_1', +# 'AC_P_1', +# 'AC_Q_1', +# 'AC_FREQ_1', +# 'AC_PF_1', +# 'TRIG', +# 'TRIG_GRID' +# ] + +data_points = [ + 'TIME', + 'DC_V', + 'DC_I', + 'AC_VRMS_1', + 'AC_VRMS_2', + 'AC_VRMS_3', + 'AC_IRMS_1', + 'AC_IRMS_2', + 'AC_IRMS_3', + 'DC_P', + 'AC_S_1', + 'AC_S_2', + 'AC_S_3', + 'AC_P_1', + 'AC_P_2', + 'AC_P_3', + 'AC_Q_1', + 'AC_Q_2', + 'AC_Q_3', + 'AC_FREQ_1', + 'AC_FREQ_2', + 'AC_FREQ_3', + 'AC_PF_1', + 'AC_PF_2', + 'AC_PF_3', + 'TRIG', + 'TRIG_GRID' +] + import time try: import sunspec.core.modbus.client as client import sunspec.core.util as util import binascii -except Exception, e: +except Exception as e: print('SunSpec or binascii packages did not import!') @@ -59,6 +106,13 @@ def __init__(self, params=None, ts=None): self.ip_timeout = params.get('ip_timeout') self.slave_id = params.get('slave_id') + self.data_points = list(data_points) + self.points = None + self.point_indexes = [] + + self.rec = {} + self.recs = [] + self.open() def info(self): @@ -71,15 +125,15 @@ def open(self): try: self.device = client.ModbusClientDeviceTCP(slave_id=self.slave_id, ipaddr=self.ip_addr, ipport=self.ip_port, timeout=self.ip_timeout) - except Exception, e: + except Exception as e: raise DeviceError('Cannot connect to PM800: %s' % e) - def close(self): - self.device = None - def data_capture(self, enable=True): pass + def close(self): + self.device = None + def data_read(self): # Changed to the bulk read option to speed up acquisition time @@ -123,7 +177,39 @@ def data_read(self): None, None)} """ - return self.bulk_float_read() + data_dict = self.bulk_float_read() + + data_points = [ + data_dict['time'], #'TIME', + data_dict['dc'][0], #'DC_V', + data_dict['dc'][1], #'DC_I', + data_dict['ac_1'][0], #'AC_VRMS_1', + data_dict['ac_2'][0], #'AC_VRMS_2', + data_dict['ac_3'][0], #'AC_VRMS_3', + data_dict['ac_1'][1], #'AC_IRMS_1', + data_dict['ac_2'][1], #'AC_IRMS_2', + data_dict['ac_3'][1], #'AC_IRMS_3', + data_dict['dc'][2], #'DC_P', + data_dict['ac_1'][3], #'AC_S_1', + data_dict['ac_2'][3], #'AC_S_2', + data_dict['ac_3'][3], #'AC_S_3', + data_dict['ac_1'][2], #'AC_P_1', + data_dict['ac_2'][2], #'AC_P_2', + data_dict['ac_3'][2], #'AC_P_3', + data_dict['ac_1'][4], #'AC_Q_1', + data_dict['ac_2'][4], #'AC_Q_2', + data_dict['ac_3'][4], #'AC_Q_3', + data_dict['ac_1'][6], #'AC_FREQ_1', + data_dict['ac_2'][6], #'AC_FREQ_2', + data_dict['ac_3'][6], #'AC_FREQ_3', + data_dict['ac_1'][5], #'AC_PF_1', + data_dict['ac_2'][5], #'AC_PF_2', + data_dict['ac_3'][5], #'AC_PF_3', + None, #'TRIG', + None, #'TRIG_GRID' + ] + + return data_points def generic_float_read(self, reg_in_lit): data = self.device.read(reg_in_lit-1, 2) # the register is one less than reported in the literature @@ -164,6 +250,38 @@ def bulk_float_read(self, start=11700, end=11762): return datarec + def waveform_config(self, params): + """ + Configure waveform capture. + + params: Dictionary with following entries: + 'sample_rate' - Sample rate (samples/sec) + 'pre_trigger' - Pre-trigger time (sec) + 'post_trigger' - Post-trigger time (sec) + 'trigger_level' - Trigger level + 'trigger_cond' - Trigger condition - ['Rising_Edge', 'Falling_Edge'] + 'trigger_channel' - Trigger channel - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + 'timeout' - Timeout (sec) + 'channels' - Channels to capture - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + """ + pass + + def waveform_capture(self, enable=True, sleep=None): + """ + Enable/disable waveform capture. + """ + pass + + def waveform_status(self): + pass + + def waveform_force_trigger(self): + pass + + def waveform_capture_dataset(self): + pass + + def reg_shift(reg): r1 = (reg - 11700)*2 r2 = r1 + 4 @@ -225,7 +343,7 @@ def reg_shift(reg): def trace(msg): - print msg + print(msg) # Reg Name Size Type Access NV Scale Units Range @@ -426,12 +544,12 @@ def readBaudRate(): def bulk_float_read(device, start=11700, end=11762): actual_start = start - 1 # the register is one less than reported in the literature actual_length = (end - start) + 2 - print('Start Reg: %s, Read Length: %s' % (actual_start, actual_length)) + print(('Start Reg: %s, Read Length: %s' % (actual_start, actual_length))) data = device.read(actual_start, actual_length) - print('Data length: %s' % len(data)) - print('Start Reg: %s, End Reg: %s' % (reg_shift(11762)[0], reg_shift(11762)[1])) - print util.data_to_float(data[reg_shift(11762)[0]:reg_shift(11762)[1]]) + print(('Data length: %s' % len(data))) + print(('Start Reg: %s, End Reg: %s' % (reg_shift(11762)[0], reg_shift(11762)[1]))) + print(util.data_to_float(data[reg_shift(11762)[0]:reg_shift(11762)[1]])) datarec = {'time': time.time(), 'ac_1': (util.data_to_float(data[reg_shift(11720)[0]:reg_shift(11720)[1]]), # Voltage, A-N @@ -470,24 +588,24 @@ def bulk_float_read(device, start=11700, end=11762): if ipaddr: device = client.ModbusClientDeviceTCP(slave_id=22, ipaddr=ipaddr, ipport=502, timeout=10) #, trace_func=trace) - print('%s' % bulk_float_read(device)) + print(('%s' % bulk_float_read(device))) readVoltageAB() - print('Freq is = %s' % readHz()) - print('Power is = %s' % readPower()) + print(('Freq is = %s' % readHz())) + print(('Power is = %s' % readPower())) - print('Baud Rate = %s' % readBaudRate()) - print('Freq Nom = %s' % readFreqNom()) - print('Freq (float) = %s' % readFloatHz()) - print('Power (float) = %s' % readFloatPower()) - print('Power (float) = %s' % readFloatPF()) + print(('Baud Rate = %s' % readBaudRate())) + print(('Freq Nom = %s' % readFreqNom())) + print(('Freq (float) = %s' % readFloatHz())) + print(('Power (float) = %s' % readFloatPower())) + print(('Power (float) = %s' % readFloatPF())) - print('Scale A = %s' % scaleA()) - print('Scale B = %s' % scaleB()) - print('Scale D = %s' % scaleD()) - print('Scale E = %s' % scaleE()) - print('Scale F = %s' % scaleF()) + print(('Scale A = %s' % scaleA())) + print(('Scale B = %s' % scaleB())) + print(('Scale D = %s' % scaleD())) + print(('Scale E = %s' % scaleE())) + print(('Scale F = %s' % scaleF())) # for i in range(100): # print('Power (float) = %s' % readFloatPower()) diff --git a/Lib/svpelab/device_das_sandia_ni_pcie.py b/Lib/svpelab/device_das_sandia_ni_pcie.py index 80a7a81..51b04ce 100644 --- a/Lib/svpelab/device_das_sandia_ni_pcie.py +++ b/Lib/svpelab/device_das_sandia_ni_pcie.py @@ -37,23 +37,23 @@ import time import traceback import glob -import waveform -import dataset +from . import waveform +from . import dataset # Wrap driver import statements in try-except clauses to avoid SVP initialization errors try: from PyDAQmx import * -except Exception, e: +except Exception as e: print('Error: PyDAQmx python package not found!') # This will appear in the SVP log file. # raise # programmers can raise this error to expose the error to the SVP user try: import numpy as np -except Exception, e: +except Exception as e: print('Error: numpy python package not found!') # This will appear in the SVP log file. # raise # programmers can raise this error to expose the error to the SVP user try: - import waveform_analysis -except Exception, e: + from . import waveform_analysis +except Exception as e: print('Error: waveform_analysis file not found!') # This will appear in the SVP log file. # raise # programmers can raise this error to expose the error to the SVP user @@ -299,7 +299,7 @@ def __init__(self, params=None, ts=None): # Create list of analog channels to capture self.analog_channels = [] - for key, value in self.points_map.iteritems(): + for key, value in self.points_map.items(): self.analog_channels.append(value) self.analog_channels = sorted(self.analog_channels) # alphabetize # self.ts.log_debug('analog_channels = %s' % self.analog_channels) @@ -389,8 +389,8 @@ def data_read(self): status = DAQmxConnectTerms('/Dev%s/20MHzTimebase' % self.dev_numbers[0], '/Dev%s/RTSI7' % self.dev_numbers[len(self.sorted_unique)-1], DAQmx_Val_DoNotInvertPolarity) - except Exception, e: - print('Error: Task does not support DAQmxConnectTerms: %s' % e) + except Exception as e: + print(('Error: Task does not support DAQmxConnectTerms: %s' % e)) for k in range(len(self.sorted_unique)): if k == 0: # Master @@ -401,7 +401,7 @@ def data_read(self): self.n_samples) # uInt64 sampsPerChanToAcquire else: # Slave - print('Configuring Slave %s Sample Clock Timing.' % k) + print(('Configuring Slave %s Sample Clock Timing.' % k)) # DAQmxCfgSampClkTiming(taskHandle,"",rate,DAQmx_Val_Rising,DAQmx_Val_ContSamps,sampsPerChan) self.analog_input[k].CfgSampClkTiming('', # const char source[], The source terminal of the Sample Clock. self.sample_rate, # float64 rate, The sampling rate in samples per second per channel. @@ -410,24 +410,24 @@ def data_read(self): self.n_samples) # uInt64 sampsPerChanToAcquire try: - print('Configuring Slave %s Clock Time Base.' % k) + print(('Configuring Slave %s Clock Time Base.' % k)) self.analog_input[k].SetSampClkTimebaseSrc('/Dev3/RTSI7') - except Exception, e: - print('Task does not support SetSampClkTimebaseSrc: %s' % e) + except Exception as e: + print(('Task does not support SetSampClkTimebaseSrc: %s' % e)) try: - print('Configuring Slave %s Clock Time Rate.' % k) + print(('Configuring Slave %s Clock Time Rate.' % k)) self.analog_input[k].SetSampClkTimebaseRate(20e6) - except Exception, e: - print('Task does not support SetSampClkTimebaseRate: %s' % e) + except Exception as e: + print(('Task does not support SetSampClkTimebaseRate: %s' % e)) - print('Configuring Slave %s Trigger.' % k) + print(('Configuring Slave %s Trigger.' % k)) self.analog_input[k].CfgDigEdgeStartTrig('/Dev%s/ai/StartTrigger' % self.dev_numbers[0], DAQmx_Val_Rising) for k in range(len(self.sorted_unique)-1, -1, -1): # Start Master last so slave(s) will wait for trigger from master over RSTI bus - print('Starting Task: %s.' % k) + print(('Starting Task: %s.' % k)) self.analog_input[k].StartTask() # DAQmx Read Code @@ -445,14 +445,14 @@ def data_read(self): byref(self.read), # int32 *sampsPerChanRead, None) # bool32 *reserved); - print "Acquired %d points" % self.read.value - print('raw_data length: %s' % len(self.raw_data[k])) + print("Acquired %d points" % self.read.value) + print(('raw_data length: %s' % len(self.raw_data[k]))) try: for k in range(len(self.sorted_unique)-1, -1, -1): self.analog_input[k].StopTask() self.analog_input[k].TaskControl(DAQmx_Val_Task_Unreserve) - except Exception, e: + except Exception as e: self.ts.log_error('Error with DAQmx in StopTask. Returning nones... %s' % e) datarec = {'time': time.time(), 'ac_1': (None, # voltage @@ -593,8 +593,8 @@ def waveform_capture(self, enable=True, sleep=None): status = DAQmxConnectTerms('/Dev%s/20MHzTimebase' % self.dev_numbers[0], '/Dev%s/RTSI7' % self.dev_numbers[len(self.sorted_unique)-1], DAQmx_Val_DoNotInvertPolarity) - except Exception, e: - print('Error: Task does not support DAQmxConnectTerms: %s' % e) + except Exception as e: + print(('Error: Task does not support DAQmxConnectTerms: %s' % e)) for k in range(len(self.sorted_unique)): if k == 0: # Master @@ -624,7 +624,7 @@ def waveform_capture(self, enable=True, sleep=None): pass else: # Slave - print('Configuring Slave %s Sample Clock Timing.' % k) + print(('Configuring Slave %s Sample Clock Timing.' % k)) # DAQmxCfgSampClkTiming(taskHandle,"",rate,DAQmx_Val_Rising,DAQmx_Val_ContSamps,sampsPerChan) self.analog_input[k].CfgSampClkTiming('', # const char source[], The source terminal of the Sample Clock. self.sample_rate, # float64 rate, The sampling rate in samples per second per channel. @@ -633,24 +633,24 @@ def waveform_capture(self, enable=True, sleep=None): self.n_samples) # uInt64 sampsPerChanToAcquire try: - print('Configuring Slave %s Clock Time Base.' % k) + print(('Configuring Slave %s Clock Time Base.' % k)) self.analog_input[k].SetSampClkTimebaseSrc('/Dev3/RTSI7') - except Exception, e: - print('Task does not support SetSampClkTimebaseSrc: %s' % e) + except Exception as e: + print(('Task does not support SetSampClkTimebaseSrc: %s' % e)) try: - print('Configuring Slave %s Clock Time Rate.' % k) + print(('Configuring Slave %s Clock Time Rate.' % k)) self.analog_input[k].SetSampClkTimebaseRate(20e6) - except Exception, e: - print('Task does not support SetSampClkTimebaseRate: %s' % e) + except Exception as e: + print(('Task does not support SetSampClkTimebaseRate: %s' % e)) - print('Configuring Slave %s Trigger.' % k) + print(('Configuring Slave %s Trigger.' % k)) self.analog_input[k].CfgDigEdgeStartTrig('/Dev%s/ai/StartTrigger' % self.dev_numbers[0], DAQmx_Val_Rising) for k in range(len(self.sorted_unique)-1, -1, -1): # Start Master last so slave(s) will wait for trigger from master over RSTI bus - print('Starting Task: %s.' % k) + print(('Starting Task: %s.' % k)) self.analog_input[k].StartTask() for k in range(len(self.sorted_unique)): @@ -683,7 +683,7 @@ def waveform_status(self): for k in range(len(self.sorted_unique)-1, -1, -1): self.analog_input[k].StopTask() self.analog_input[k].TaskControl(DAQmx_Val_Task_Unreserve) - except Exception, e: + except Exception as e: self.ts.log_error('Error with DAQmx in StopTask. Returning nones... %s' % e) else: @@ -738,7 +738,7 @@ def IC2_relay(new_state='close'): ditigal_wfm_data = np.array([1], dtype=np.uint8) print('Closing IC2 Relay') else: - print('Unknown new switch state: %s' % new_state) + print(('Unknown new switch state: %s' % new_state)) return task = Task() @@ -753,8 +753,296 @@ def IC2_relay(new_state='close'): None) # bool32 *reserved task.StopTask() +def IC1_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening IC1 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing IC1 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev1/port0/line16", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv1_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 1 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 1 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev1/port0/line0", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv2_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 2 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 2 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev1/port0/line1", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv3_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 3 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 3 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev1/port0/line8", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv4_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 4 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 4 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev1/port0/line9", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv5_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 5 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 5 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev3/port0/line0", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv6_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 6 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 6 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev3/port0/line8", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv7_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 7 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 7 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev2/port0/line0", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv8_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 8 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 8 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev2/port0/line1", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv9_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 9 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 9 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev2/port0/line8", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + +def inv10_relay(new_state='close'): + if new_state == 'open': + ditigal_wfm_data = np.array([0], dtype=np.uint8) + print('Opening Inverter 10 Relay') + elif new_state == 'close': + ditigal_wfm_data = np.array([1], dtype=np.uint8) + print('Closing Inverter 10 Relay') + else: + print(('Unknown new switch state: %s' % new_state)) + return + + task = Task() + task.CreateDOChan("Dev2/port0/line9", "", DAQmx_Val_ChanForAllLines) + task.StartTask() + task.WriteDigitalLines(1, # int32 numSampsPerChan + 1, # bool32 autoStart + 10.0, # float64 timeout + DAQmx_Val_GroupByChannel, # bool32 dataLayout + ditigal_wfm_data, # uInt8 writeArray[] + None, # int32 *sampsPerChanWritten + None) # bool32 *reserved + task.StopTask() + + +def close_1_5(): + inv1_relay('close') + inv2_relay('close') + inv3_relay('close') + inv4_relay('close') + inv5_relay('close') + +def open_1_5(): + inv1_relay('open') + inv2_relay('open') + inv3_relay('open') + inv4_relay('open') + inv5_relay('open') + +def close_6_10(): + inv6_relay('close') + inv7_relay('close') + inv8_relay('close') + inv9_relay('close') + inv10_relay('close') + +def open_6_10(): + inv6_relay('open') + inv7_relay('open') + inv8_relay('open') + inv9_relay('open') + inv10_relay('open') + + if __name__ == "__main__": + IC2_relay(new_state='close') + close_1_5() + close_6_10() + + """ # consider moving to SuperTask in acq4 # https://github.com/acq4/acq4/blob/develop/acq4/drivers/nidaq/SuperTask.py @@ -982,4 +1270,5 @@ def IC2_relay(new_state='close'): for t in range(len(time_vector)): f.write('%0.6f, %0.6f, %0.6f\n' % (time_vector[t], data[analog_channels[0]][t], data[analog_channels[1]][t])) f.close() + """ diff --git a/Lib/svpelab/device_das_sim.py b/Lib/svpelab/device_das_sim.py index 34d6acc..571bf9f 100644 --- a/Lib/svpelab/device_das_sim.py +++ b/Lib/svpelab/device_das_sim.py @@ -30,9 +30,36 @@ Questions can be directed to support@sunspec.org """ -import os import time -import dataset +import random +import numpy as np +import datetime + +query_points = { + 'AC_VRMS': 'UTRMS', + 'AC_IRMS': 'ITRMS', + 'AC_P': 'P', + 'AC_S': 'S', + 'AC_Q': 'Q', + 'AC_PF': 'PF', + 'AC_FREQ': 'FCYC', + 'AC_INC': 'INCA', + 'DC_V': 'UDC', + 'DC_I': 'IDC', + 'DC_P': 'P' +} + +initiale_average_values = { + 'U': 120.00, + 'I': 12.00, + 'PF': 0.12, + 'FCYC': 67.00, + 'P': 12345.00, + 'Q': 11111.00, + 'S': 16609.00, + 'INCA': 1.00, + 'Unset': 9991.00 +} class DeviceError(Exception): @@ -45,21 +72,36 @@ class DeviceError(Exception): class Device(object): def __init__(self, params=None): - self.ts = params['ts'] - self.points = params['points'] - self.data_points = [] - self.data_file = params['data_file'] - self.at_end = params['at_end'] - self.file_= None - self.use_timestamp = params['use_timestamp'] - self.ds = dataset.Dataset() - self.index = 0 - - if self.data_file: - self.ds.from_csv(self.data_file) - self.data_points = list(self.ds.points) - else: - raise DeviceError('No data file specified') + self.params = params + self.channels = params.get('channels') + self.sample_interval = params.get('sample_interval') + self.data_points = ['TIME'] + self.average = initiale_average_values + # Connection object + self.start_time = None + self.current_time = None + self.query_chan_str = "" + item = 0 + + for i in range(1, 4): + chan = self.channels[i] + if chan is not None: + chan_type = chan.get('type') + points = chan.get('points') + if points is not None: + chan_label = chan.get('label') + if chan_type is None: + raise DeviceError('No channel type specified') + if points is None: + raise DeviceError('No points specified') + for p in points: + item += 1 + point_str = '%s_%s' % (chan_type, p) + chan_str = query_points.get(point_str) + self.query_chan_str += '%s%d?; ' % (chan_str, i) + if chan_label: + point_str = '%s_%s' % (point_str, chan_label) + self.data_points.append(point_str) def info(self): return 'DAS Simulator - 1.0' @@ -71,49 +113,58 @@ def close(self): pass def data_capture(self, enable=True): - pass + self.start_time = None def data_read(self): - data = [] - if len(self.ds.points) > 0: - count = len(self.ds.data[0]) - if count > 0: - if self.index >= count: - if self.at_end == 'Loop to start': - self.index = 0 - elif self.at_end == 'Repeat last record': - self.index = count - 1 - else: - raise DeviceError('End of data reached') - - for i in range(len(self.ds.points)): - data.append(self.ds.data[i][self.index]) + if self.start_time is None: + self.start_time = np.datetime64(datetime.datetime.utcnow(), 'us') + else : + self.current_time = np.datetime64(datetime.datetime.utcnow(), 'us') + data = [] + points = self.query_chan_str.split(";")[:-1] + for point in points: + if 'U' in point: + data.append(self._gen_data('U')) + elif 'I' in point and 'INCA' not in point: + data.append(self._gen_data('I')) + elif 'PF' in point: + data.append(self._gen_data('PF')) + elif 'FCYC' in point: + data.append(self._gen_data('FCYC')) + elif 'P' in point and 'PF' not in point: + data.append(self._gen_data('P')) + elif 'Q' in point: + data.append(self._gen_data('Q')) + elif 'S' in point and 'RMS' not in point: + data.append(self._gen_data('S')) + elif 'INCA' in point: + data.append(self._gen_data('INCA')) + else: + data.append(self._gen_data('Unset')) + data.insert(0, time.clock()) return data - def waveform_config(self, params): - pass - - def waveform_capture(self, enable=True, sleep=None): - """ - Enable/disable waveform capture. - """ - pass - - def waveform_status(self): - # mm-dd-yyyy hh_mm_ss waveform trigger.txt - # mm-dd-yyyy hh_mm_ss.wfm - # return INACTIVE, ACTIVE, COMPLETE - return 'COMPLETE' - - def waveform_force_trigger(self): - pass - - def waveform_capture_dataset(self): - return self.ds - -if __name__ == "__main__": - - pass + def _gen_data(self, key): + delta = random.uniform(-0.5, 0.5) + r = random.random() + + if key == 'INCA': + if r > 0.9: + self.average[key] = -1 + elif r > 0.8: + self.average[key] = 0 + else: + self.average[key] = 1 + else: + if r > 0.9: + self.average[key] += delta * 0.33*self.average[key] + elif r > 0.8: + # attraction to the initial value + delta += (0.5 if initiale_average_values[key] > self.average[key] else -0.5) + self.average[key] += delta*0.01*self.average[key] + else: + self.average[key] += delta*0.01*self.average[key] + return self.average[key] diff --git a/Lib/svpelab/device_das_typhoon.py b/Lib/svpelab/device_das_typhoon.py index fdeb8da..1af13d4 100644 --- a/Lib/svpelab/device_das_typhoon.py +++ b/Lib/svpelab/device_das_typhoon.py @@ -34,75 +34,141 @@ import os import traceback import glob -import waveform -import dataset +from . import waveform +from . import dataset try: - import typhoon.api.hil_control_panel as cp + import typhoon.api.hil as cp # control panel from typhoon.api.schematic_editor import model import typhoon.api.pv_generator as pv -except Exception, e: - print('Typhoon HIL API not installed. %s' % e) +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) -data_points = [ +data_points = [ # 3 phase 'TIME', 'DC_V', 'DC_I', - 'AC_VRMS', - 'AC_IRMS', + 'AC_VRMS_1', + 'AC_VRMS_2', + 'AC_VRMS_3', + 'AC_IRMS_1', + 'AC_IRMS_2', + 'AC_IRMS_3', 'DC_P', - 'AC_S', - 'AC_P', - 'AC_Q', - 'AC_FREQ', - 'AC_PF', + 'AC_S_1', + 'AC_S_2', + 'AC_S_3', + 'AC_P_1', + 'AC_P_2', + 'AC_P_3', + 'AC_Q_1', + 'AC_Q_2', + 'AC_Q_3', + 'AC_FREQ_1', + 'AC_FREQ_2', + 'AC_FREQ_3', + 'AC_PF_1', + 'AC_PF_2', + 'AC_PF_3', 'TRIG', 'TRIG_GRID' ] -# To be implemented later -# typhoon_points_asgc_1 = [ -# 'time', -# 'V( V_DC3 )', # DC voltage -# 'I( Ipv )', -# 'V( Vrms1 )', -# 'I( Irms1 )', -# 'DC_P', # calculated -# 'S', -# 'Pdc', -# 'Qdc', -# 'AC_FREQ', -# 'k', -# 'TRIG', -# 'TRIG_GRID' -# ] -# -# typhoon_points_asgc_3 = [ -# 'time', -# 'V( V_DC3 )', # DC voltage -# 'I( Ipv )', -# 'V( Vrms1 )', -# 'V( Vrms2 )', -# 'V( Vrms3 )', -# 'I( Irms1 )', -# 'I( Irms2 )', -# 'I( Irms3 )', -# 'DC_P', # calculated -# 'S', -# 'Pdc', -# 'Qdc', -# 'AC_FREQ', -# 'k', -# 'TRIG', -# 'TRIG_GRID' -# ] -# -# typhoon_points_map = { -# 'ASGC3': typhoon_points_asgc_3, # AGF circuit, 3 phase -# 'ASGC1': typhoon_points_asgc_1, # AGF circuit, single phase -# 'ASGC_Fault': typhoon_points_asgc_fault, # ride-through circuit -# 'ASGC_UI': typhoon_points_ui # unintentional islanding circuit -# } +# Legacy mapping +typhoon_points_asgc_old = { # data point : analog channel name + # in cases where the analog channel gets total value from all phases, scale by 1/3 + 'AC_VRMS_1': 'V( Vrms1 )', 'AC_VRMS_1_scaling': 1., + 'AC_VRMS_2': 'V( Vrms2 )', 'AC_VRMS_2_scaling': 1., + 'AC_VRMS_3': 'V( Vrms3 )', 'AC_VRMS_3_scaling': 1., + 'AC_IRMS_1': 'I( Irms1 )', 'AC_IRMS_1_scaling': 1., + 'AC_IRMS_2': 'I( Irms2 )', 'AC_IRMS_2_scaling': 1., + 'AC_IRMS_3': 'I( Irms3 )', 'AC_IRMS_3_scaling': 1., + 'AC_P_1': 'Pdc', 'AC_P_1_scaling': 1/3., + 'AC_P_2': 'Pdc', 'AC_P_2_scaling': 1/3., + 'AC_P_3': 'Pdc', 'AC_P_3_scaling': 1/3., + 'AC_Q_1': 'Qdc', 'AC_Q_1_scaling': 1/3., + 'AC_Q_2': 'Qdc', 'AC_Q_2_scaling': 1/3., + 'AC_Q_3': 'Qdc', 'AC_Q_3_scaling': 1/3., + 'AC_S_1': 'S', 'AC_S_1_scaling': 1/3., + 'AC_S_2': 'S', 'AC_S_2_scaling': 1/3., + 'AC_S_3': 'S', 'AC_S_3_scaling': 1/3., + 'AC_PF_1': 'k', 'AC_PF_1_scaling': 1., + 'AC_PF_2': 'k', 'AC_PF_2_scaling': 1., + 'AC_PF_3': 'k', 'AC_PF_3_scaling': 1., + 'AC_FREQ_1': 'AC_FREQ', 'AC_FREQ_1_scaling': 1., + 'AC_FREQ_2': 'AC_FREQ', 'AC_FREQ_2_scaling': 1., + 'AC_FREQ_3': 'AC_FREQ', 'AC_FREQ_3_scaling': 1., + 'DC_V': 'V( V_DC3 )', 'DC_V_scaling': 1., + 'DC_I': 'I( Ipv )', 'DC_I_scaling': 1., + 'DC_P': 'DC_P', 'DC_P_scaling': 1., + 'TRIG': 0, + 'TRIG_GRID': 0} + +# Mapping Aug 2018 +typhoon_map_aug2018 = { # data point : analog channel name + # in cases where the analog channel gets total value from all phases, scale by 1/3 + 'AC_VRMS_1': 'Van_grid_rms', 'AC_VRMS_1_scaling': 1., + 'AC_VRMS_2': 'Vbn_grid_rms', 'AC_VRMS_2_scaling': 1., + 'AC_VRMS_3': 'Vcn_grid_rms', 'AC_VRMS_3_scaling': 1., + 'AC_IRMS_1': 'Ia_grid_rms', 'AC_IRMS_1_scaling': 1., + 'AC_IRMS_2': 'Ib_grid_rms', 'AC_IRMS_2_scaling': 1., + 'AC_IRMS_3': 'Ic_grid_rms', 'AC_IRMS_3_scaling': 1., + 'AC_P_1': 'Grid P', 'AC_P_1_scaling': -1/3., + 'AC_P_2': 'Grid P', 'AC_P_2_scaling': -1/3., + 'AC_P_3': 'Grid P', 'AC_P_3_scaling': -1/3., + 'AC_Q_1': 'Grid Q', 'AC_Q_1_scaling': -1/3., + 'AC_Q_2': 'Grid Q', 'AC_Q_2_scaling': -1/3., + 'AC_Q_3': 'Grid Q', 'AC_Q_3_scaling': -1/3., + 'AC_S_1': 'Grid S', 'AC_S_1_scaling': 1/3., + 'AC_S_2': 'Grid S', 'AC_S_2_scaling': 1/3., + 'AC_S_3': 'Grid S', 'AC_S_3_scaling': 1/3., + 'AC_PF_1': 'Grid PF', 'AC_PF_1_scaling': 1., + 'AC_PF_2': 'Grid PF', 'AC_PF_2_scaling': 1., + 'AC_PF_3': 'Grid PF', 'AC_PF_3_scaling': 1., + 'AC_FREQ_1': 'AC_FREQ', 'AC_FREQ_1_scaling': 1., + 'AC_FREQ_2': 'AC_FREQ', 'AC_FREQ_2_scaling': 1., + 'AC_FREQ_3': 'AC_FREQ', 'AC_FREQ_3_scaling': 1., + 'DC_V': 'VDCm', 'DC_V_scaling': 1., + 'DC_I': 'IDCm', 'DC_I_scaling': 1., + 'DC_P': 'DC_P', 'DC_P_scaling': 1., + 'TRIG': 'Trigger Generator.V_Grid_Trig', + 'TRIG_GRID': 'Trigger Generator.Vs_Grid_Trig'} + +typhoon_map_june2019 = { # data point : analog channel name + # in cases where the analog channel gets total value from all phases, scale by 1/3 + 'AC_VRMS_1': 'Van_grid_rms', 'AC_VRMS_1_scaling': 1., + 'AC_VRMS_2': 'Vbn_grid_rms', 'AC_VRMS_2_scaling': 1., + 'AC_VRMS_3': 'Vcn_grid_rms', 'AC_VRMS_3_scaling': 1., + 'AC_IRMS_1': 'Ia_grid_rms', 'AC_IRMS_1_scaling': 1., + 'AC_IRMS_2': 'Ib_grid_rms', 'AC_IRMS_2_scaling': 1., + 'AC_IRMS_3': 'Ic_grid_rms', 'AC_IRMS_3_scaling': 1., + 'AC_P_1': 'Grid P1', 'AC_P_1_scaling': -1., + 'AC_P_2': 'Grid P2', 'AC_P_2_scaling': -1., + 'AC_P_3': 'Grid P3', 'AC_P_3_scaling': -1., + 'AC_Q_1': 'Grid Q1', 'AC_Q_1_scaling': -1., + 'AC_Q_2': 'Grid Q2', 'AC_Q_2_scaling': -1., + 'AC_Q_3': 'Grid Q3', 'AC_Q_3_scaling': -1., + 'AC_S_1': 'Grid S', 'AC_S_1_scaling': 1/3., + 'AC_S_2': 'Grid S', 'AC_S_2_scaling': 1/3., + 'AC_S_3': 'Grid S', 'AC_S_3_scaling': 1/3., + 'AC_PF_1': 'Grid PF', 'AC_PF_1_scaling': 1., + 'AC_PF_2': 'Grid PF', 'AC_PF_2_scaling': 1., + 'AC_PF_3': 'Grid PF', 'AC_PF_3_scaling': 1., + 'AC_FREQ_1': 'AC_FREQ', 'AC_FREQ_1_scaling': 1., + 'AC_FREQ_2': 'AC_FREQ', 'AC_FREQ_2_scaling': 1., + 'AC_FREQ_3': 'AC_FREQ', 'AC_FREQ_3_scaling': 1., + 'DC_V': 'VDCm', 'DC_V_scaling': 1., + 'DC_I': 'IDCm', 'DC_I_scaling': 1., + 'DC_P': 'DC_P', 'DC_P_scaling': 1., + 'TRIG': 'Trigger Generator.V_Grid_Trig', + 'TRIG_GRID': 'Trigger Generator.Vs_Grid_Trig'} + + +typhoon_points_map = { + 'ASGC': typhoon_map_june2019, # AGF circuit, 3 phase + 'ASGC_2018': typhoon_map_aug2018, # AGF circuit, 3 phase + 'ASGC_old': typhoon_points_asgc_old, # AGF circuit, single phase +} wfm_channels = ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] @@ -157,7 +223,10 @@ # 'I( Ig3 )': 'AC_I_3', 'S1_fb': 'EXT'} -event_map = {'Rising_Edge': 'Rising edge', 'Falling_Edge': 'Falling edge'} +event_map = {'Rising_Edge': 'Rising edge', + 'Rising Edge': 'Rising edge', + 'Falling_Edge': 'Falling edge', + 'Falling Edge': 'Falling edge'} class Device(object): @@ -167,7 +236,9 @@ def __init__(self, params=None): self.points = None self.point_indexes = [] - self.ts = self.params.get('ts') + self.ts = self.params['ts'] + self.map = self.params['map'] + self.sample_interval = self.params['sample_interval'] # waveform settings self.wfm_sample_rate = None @@ -207,50 +278,91 @@ def open(self): def close(self): pass - def data_read(self): + def data_capture(self, enable=True): + pass - v1 = float(cp.read_analog_signal(name='V( Vrms1 )')) - v2 = float(cp.read_analog_signal(name='V( Vrms2 )')) - v3 = float(cp.read_analog_signal(name='V( Vrms3 )')) - i1 = float(cp.read_analog_signal(name='I( Irms1 )')) - i2 = float(cp.read_analog_signal(name='I( Irms2 )')) - i3 = float(cp.read_analog_signal(name='I( Irms3 )')) - p = float(cp.read_analog_signal(name='Pdc')) # Note this is the AC power (fundamental) - va = float(cp.read_analog_signal(name='S')) - q = float(cp.read_analog_signal(name='Qdc')) - pf = float(cp.read_analog_signal(name='k')) - # f = cp.frequency - - dc_v = float(cp.read_analog_signal(name='V( V_DC3 )')) - dc_i = float(cp.read_analog_signal(name='I( Ipv )')) + def data_read(self): + # self.ts.log_debug('Analog Channels: %s' % cp.get_analog_signals()) + v1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_VRMS_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_VRMS_1_scaling')) + v2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_VRMS_2'))) * \ + float(typhoon_points_map.get(self.map).get('AC_VRMS_2_scaling')) + v3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_VRMS_3'))) * \ + float(typhoon_points_map.get(self.map).get('AC_VRMS_3_scaling')) + i1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_IRMS_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_IRMS_1_scaling')) + i2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_IRMS_2'))) * \ + float(typhoon_points_map.get(self.map).get('AC_IRMS_2_scaling')) + i3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_IRMS_3'))) * \ + float(typhoon_points_map.get(self.map).get('AC_IRMS_3_scaling')) + p1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_P_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_P_1_scaling')) + p2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_P_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_P_2_scaling')) + p3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_P_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_P_3_scaling')) + va1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_S_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_S_1_scaling')) + va2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_S_2'))) * \ + float(typhoon_points_map.get(self.map).get('AC_S_2_scaling')) + va3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_S_3'))) * \ + float(typhoon_points_map.get(self.map).get('AC_S_3_scaling')) + q1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_Q_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_Q_1_scaling')) + q2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_Q_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_Q_2_scaling')) + q3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_Q_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_Q_3_scaling')) + pf1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_PF_1'))) * \ + float(typhoon_points_map.get(self.map).get('AC_PF_1_scaling')) + pf2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_PF_2'))) * \ + float(typhoon_points_map.get(self.map).get('AC_PF_2_scaling')) + pf3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_PF_3'))) * \ + float(typhoon_points_map.get(self.map).get('AC_PF_3_scaling')) + # f1 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_FREQ_1')))* \ + # float(typhoon_points_map.get(self.map).get('AC_FREQ_1_scaling')) + # f2 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_FREQ_2')))* \ + # float(typhoon_points_map.get(self.map).get('AC_FREQ_2_scaling')) + # f3 = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('AC_FREQ_3')))* \ + # float(typhoon_points_map.get(self.map).get('AC_FREQ_3_scaling')) + dc_v = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('DC_V'))) * \ + float(typhoon_points_map.get(self.map).get('DC_V_scaling')) + dc_i = float(cp.read_analog_signal(name=typhoon_points_map.get(self.map).get('DC_I'))) * \ + float(typhoon_points_map.get(self.map).get('DC_I_scaling')) datarec = {'TIME': time.time(), 'AC_VRMS_1': v1, 'AC_IRMS_1': i1, - 'AC_P_1': p/3., - 'AC_S_1': va/3., - 'AC_Q_1': q/3., - 'AC_PF_1': pf, - 'AC_FREQ_1': None, + 'AC_P_1': p1, + 'AC_S_1': va1, + 'AC_Q_1': q1, + 'AC_PF_1': pf1, + 'AC_FREQ_1': 50., 'AC_VRMS_2': v2, 'AC_IRMS_2': i2, - 'AC_P_2': p/3., - 'AC_S_2': va/3., - 'AC_Q_2': q/3., - 'AC_PF_2': pf, - 'AC_FREQ_2': None, + 'AC_P_2': p2, + 'AC_S_2': va2, + 'AC_Q_2': q2, + 'AC_PF_2': pf2, + 'AC_FREQ_2': 50., 'AC_VRMS_3': v3, 'AC_IRMS_3': i3, - 'AC_P_3': p/3., - 'AC_S_3': va/3., - 'AC_Q_3': q/3., - 'AC_PF_3': pf, - 'AC_FREQ_3': None, + 'AC_P_3': p3, + 'AC_S_3': va3, + 'AC_Q_3': q3, + 'AC_PF_3': pf3, + 'AC_FREQ_3': 50., 'DC_V': dc_v, 'DC_I': dc_i, - 'DC_P': dc_i*dc_v} + 'DC_P': dc_i*dc_v, + 'TRIG': 0, + 'TRIG_GRID': 0} + + data = [] + for chan in data_points: + data.append(datarec[chan]) - return datarec + return data def waveform_config(self, params): """ @@ -298,35 +410,36 @@ def waveform_config(self, params): simulationStep = cp.get_sim_step() hil_sampling_rate = 1./simulationStep if self.wfm_sample_rate != hil_sampling_rate: - self.ts.log_warning('Waveform will be sampled at %s Samples/s because this is the simulation timestep ' - 'and then resampled to generate the waveform.' % hil_sampling_rate) + self.ts.log('Waveform will be sampled at %s Samples/s because this is the simulation timestep ' + 'and then resampled to generate the %s Hz waveform.' % + (hil_sampling_rate, self.wfm_sample_rate)) self.subsampling_rate = hil_sampling_rate/self.wfm_sample_rate if type(self.subsampling_rate) != 'int': - self.ts.log_warning('Subsampling HIL waveform factor is %s, but using integer %s to downsample data.' % - (self.subsampling_rate, int(self.subsampling_rate))) + self.ts.log('Subsampling HIL waveform factor is %s, but using integer %s to downsample data.' % + (self.subsampling_rate, int(self.subsampling_rate))) self.subsampling_rate = int(self.subsampling_rate) self.triggerOffset = (self.wfm_pre_trigger/(self.wfm_pre_trigger+self.wfm_post_trigger))*100. self.numberOfSamples = int(hil_sampling_rate*(self.wfm_pre_trigger+self.wfm_post_trigger)) if self.numberOfSamples > 32e6/len(self.analog_channels): - self.ts.log_warning('Number of samples is not less than 32e6/numberOfChannels!') - self.numberOfSamples = 32e6/len(self.analog_channels) # technically this only counts for analog channels - self.ts.log_warning('Number of samples set to 32e6/numberOfChannels!') - elif self.numberOfSamples < 256: + self.ts.log('Number of samples greater than 32e6/numberOfChannels!') + self.numberOfSamples = int(32e6/len(self.analog_channels)) # only counts for analog channels + self.ts.log('Number of samples set to 32e6/numberOfChannels!') + if self.numberOfSamples % 2 == 1: + self.ts.log_warning('Number of samples is not even!') + self.numberOfSamples -= 1 + self.ts.log_warning('Number of samples set to %d.' % self.numberOfSamples) + if self.numberOfSamples < 256: self.ts.log_warning('Number of samples is not greater than 256!') self.numberOfSamples = 256 self.ts.log_warning('Number of samples set to 256.') - elif self.numberOfSamples % 2 == 1: - self.ts.log_warning('Number of samples is not even!') - self.numberOfSamples += 1 - self.ts.log_warning('Number of samples set to %d.' % self.numberOfSamples) if wfm_typhoon_channel_type[wfm_typhoon_channels[self.wfm_trigger_channel]] == 'digital': self.captureSettings = [self.decimation, len(self.analog_channels), self.numberOfSamples, True] self.triggerSettings = ["Digital", wfm_typhoon_channels[self.wfm_trigger_channel], self.wfm_trigger_level, event_map[self.wfm_trigger_cond], self.triggerOffset] else: - self.captureSettings = [self.decimation, len(self.analog_channels), self.numberOfSamples] + self.captureSettings = [self.decimation, len(self.analog_channels), int(self.numberOfSamples)] self.triggerSettings = ["Analog", wfm_typhoon_channels[self.wfm_trigger_channel], self.wfm_trigger_level, event_map[self.wfm_trigger_cond], self.triggerOffset] @@ -341,9 +454,8 @@ def waveform_capture(self, enable=True, sleep=None): self.wfm_data = None # used as flag in waveform_status() - self.ts.log_debug('CaptureSettings: %s, triggerSettings: %s, channelSettings: %s, dataBuffer: %s' - % (self.captureSettings, self.triggerSettings, self.channelSettings, - self.capturedDataBuffer)) + self.ts.log_debug('CaptureSettings: %s, triggerSettings: %s, channelSettings: %s' + % (self.captureSettings, self.triggerSettings, self.channelSettings)) # start capture process and if everything ok, continue... if cp.start_capture(self.captureSettings, @@ -372,6 +484,8 @@ def waveform_capture(self, enable=True, sleep=None): self.triggerSettings, self.channelSettings, self.capturedDataBuffer)) + self.ts.log_error('Ensure number of samples is valid and the trigger channel is included ' + 'in the captured channels.') def waveform_status(self): # return INACTIVE, ACTIVE, COMPLETE @@ -430,19 +544,17 @@ def waveform_capture_dataset(self): hil.stop_simulation() model.get_hw_settings() - if not model.load(r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib/svpelab/Typhoon/ASGC.tse'): - print "Model did not load!" + if not model.load(r'D:/SVP/SVP 1.4.3 Directories 5-2-17/UL1741 SA for ASGC/Lib/svpelab/Typhoon/ASGC_AI.tse'): + print("Model did not load!") if not model.compile(): - print "Model did not compile!" + print("Model did not compile!") # first we need to load model - hil.load_model(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib' - r'/svpelab/Typhoon/ASGC Target files/ASGC.cpd') + hil.load_model(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/UL1741 SA for ASGC/Lib/svpelab/Typhoon/ASGC_AI Target files/ASGC_AI.cpd') # we could also open existing settings file... - hil.load_settings_file(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib/' - r'svpelab/Typhoon/settings2.runx') + hil.load_settings_file(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/UL1741 SA for ASGC/Lib/svpelab/Typhoon/settings2.runx') # after setting parameter we could start simulation hil.start_simulation() @@ -450,15 +562,15 @@ def waveform_capture_dataset(self): # let the inverter startup sleeptime = 15 for i in range(1, sleeptime): - print ("Waiting another %d seconds until the inverter starts. Power = %f." % - ((sleeptime-i), hil.read_analog_signal(name='Pdc'))) + print(("Waiting another %d seconds until the inverter starts. Power = %f." % + ((sleeptime-i), hil.read_analog_signal(name='Pdc')))) time.sleep(1) ''' Waveform capture ''' simulationStep = hil.get_sim_step() - print('Simulation time step is %f' % simulationStep) + print(('Simulation time step is %f' % simulationStep)) trigsamplingrate = 1./simulationStep pretrig = 0.5 posttrig = 1.0 @@ -476,7 +588,7 @@ def waveform_capture_dataset(self): # cpSettings - list[decimation,numberOfChannels,numberOfSamples, enableDigitalCapture] numberOfSamples = int(trigsamplingrate*(pretrig+posttrig)) - print('Numer of Samples is %d' % numberOfSamples) + print(('Numer of Samples is %d' % numberOfSamples)) if numberOfSamples > 32e6/n_analog_channels: print('Number of samples is not less than 32e6/numberOfChannels!') numberOfSamples = 32e6/n_analog_channels @@ -488,7 +600,7 @@ def waveform_capture_dataset(self): elif numberOfSamples % 2 == 1: print('Number of samples is not even!') numberOfSamples += 1 - print('Number of samples set to %d.' % numberOfSamples) + print(('Number of samples set to %d.' % numberOfSamples)) ''' triggerSource - channel or the name of signal that will be used for triggering (int value or string value) @@ -521,19 +633,26 @@ def waveform_capture_dataset(self): captureSettings = [1, n_analog_channels, numberOfSamples] - print captureSettings - print triggerSettings - print channelSettings - print('Power = %0.3f' % hil.read_analog_signal(name='Pdc')) + print(captureSettings) + print(triggerSettings) + print(channelSettings) + print(('Power = %0.3f' % hil.read_analog_signal(name='Pdc'))) # if hil.read_digital_signal(name='S1_fb') == 1: # print('Contactor is closed.') # else: # print('Contactor is open.') # start capture process... - if hil.start_capture(captureSettings, - triggerSettings, - channelSettings, + # if hil.start_capture(captureSettings, + # triggerSettings, + # channelSettings, + # dataBuffer=capturedDataBuffer, + # fileName=save_file_name, + # timeout=trigtimeout): + + if hil.start_capture([1, 6, 500000], + ['Forced'], + [['V( V_L1 )', 'V( V_L2 )', 'V( V_L3 )', 'I( Ia )', 'I( Ib )', 'I( Ic )']], dataBuffer=capturedDataBuffer, fileName=save_file_name, timeout=trigtimeout): @@ -556,9 +675,9 @@ def waveform_capture_dataset(self): (signalsNames, wfm_data, wfm_time) = capturedDataBuffer[0] subsampling_rate = 10 - print('Length of wfm_time = %s' % len(wfm_time)) + print(('Length of wfm_time = %s' % len(wfm_time))) wfm_time = wfm_time[0::subsampling_rate] - print('Length of wfm_time = %s' % len(wfm_time)) + print(('Length of wfm_time = %s' % len(wfm_time))) # unpack data for appropriate captured signals # V_dc = wfm_data[0] # first row for first signal and so on @@ -570,8 +689,8 @@ def waveform_capture_dataset(self): # plt.plot(wfm_time, V_ac, 'b', wfm_time, i_ac, 'r', wfm_time, contactor_trig*100, 'k') # plt.show() - print(len(wfm_data[0])) - print(len(wfm_data[0][0::subsampling_rate])) + print((len(wfm_data[0]))) + print((len(wfm_data[0][0::subsampling_rate]))) V_1 = wfm_data[0][0::subsampling_rate] # first row for first signal and so on V_2 = wfm_data[1][0::subsampling_rate] diff --git a/Lib/svpelab/device_elspec_g4420.py b/Lib/svpelab/device_elspec_g4420.py new file mode 100644 index 0000000..c0c7f54 --- /dev/null +++ b/Lib/svpelab/device_elspec_g4420.py @@ -0,0 +1,402 @@ +""" +Communications to a EGX100 Gateway to the Schneider Electric PowerLogic PM800 Series Power Meters +Communications use Modbus TCP/IP + +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import time +try: + import sunspec.core.modbus.client as client + import sunspec.core.util as util + import binascii +except Exception as e: + print('SunSpec or binascii packages did not import!') + +data_points = [ + 'TIME', + 'DC_V', + 'DC_I', + 'AC_VRMS_1', + 'AC_VRMS_2', + 'AC_VRMS_3', + 'AC_IRMS_1', + 'AC_IRMS_2', + 'AC_IRMS_3', + 'DC_P', + 'AC_S_1', + 'AC_S_2', + 'AC_S_3', + 'AC_P_1', + 'AC_P_2', + 'AC_P_3', + 'AC_Q_1', + 'AC_Q_2', + 'AC_Q_3', + 'AC_FREQ_1', + 'AC_FREQ_2', + 'AC_FREQ_3', + 'AC_PF_1', + 'AC_PF_2', + 'AC_PF_3', + 'TRIG', + 'TRIG_GRID' +] + +class DeviceError(Exception): + pass + + +class Device(object): + + def __init__(self, params=None, ts=None): + self.ts = ts + self.device = None + self.data_points = list(data_points) + + self.comm = params.get('comm') + if self.comm == 'Modbus TCP': + self.ip_addr = params.get('ip_addr') + self.ip_port = params.get('ip_port') + self.ip_timeout = params.get('ip_timeout') + self.slave_id = params.get('slave_id') + + self.open() + + def info(self): + return 'DAS Hardware: Elspec G4420' + + def open(self): + """ + Open the communications resources associated with the device. + """ + try: + self.device = client.ModbusClientDeviceTCP(slave_id=self.slave_id, ipaddr=self.ip_addr, + ipport=self.ip_port, timeout=self.ip_timeout) + except Exception as e: + raise DeviceError('Cannot connect to PM800: %s' % e) + + def close(self): + self.device = None + + def data_capture(self, enable=True): + pass + + def data_read(self): + + # Changed to the bulk read option to speed up acquisition time + + # freq = self.generic_float_read(999) + + #p1 = self.generic_float_read(1025) + #p2 = self.generic_float_read(1027) + #p3 = self.generic_float_read(1029) + + read_start = 999 + read_end = 1121 #2440 + data = self.bulk_float_read(start=read_start, end=read_end) + + freq_offset = 999 - read_start + freq = util.data_to_float(data[freq_offset*2+0:freq_offset*2+4]) + + p_offset = 1025 - read_start + p1 = util.data_to_float(data[p_offset*2+0:p_offset*2+4]) + p2 = util.data_to_float(data[p_offset*2+4:p_offset*2+8]) + p3 = util.data_to_float(data[p_offset*2+8:p_offset*2+12]) + + var_offset = 1041 - read_start + var1 = util.data_to_float(data[var_offset*2+0:var_offset*2+4]) + var2 = util.data_to_float(data[var_offset*2+4:var_offset*2+8]) + var3 = util.data_to_float(data[var_offset*2+8:var_offset*2+12]) + + v_offset = 1103 - read_start + v1 = util.data_to_float(data[v_offset*2+0:v_offset*2+4]) + v2 = util.data_to_float(data[v_offset*2+4:v_offset*2+8]) + v3 = util.data_to_float(data[v_offset*2+8:v_offset*2+12]) + + va_offset = 1057 - read_start + va1 = util.data_to_float(data[va_offset*2+0:va_offset*2+4]) + va2 = util.data_to_float(data[va_offset*2+4:va_offset*2+8]) + va3 = util.data_to_float(data[va_offset*2+8:va_offset*2+12]) + + i_offset = 1117 - read_start + i1 = util.data_to_float(data[i_offset*2+0:i_offset*2+4]) + i2 = util.data_to_float(data[i_offset*2+4:i_offset*2+8]) + i3 = util.data_to_float(data[i_offset*2+8:i_offset*2+12]) + + read_start = 3973 # Elspec unit rejects any read that includes 2441, so need to do a second read + read_end = 3977 + data = self.bulk_float_read(start=read_start, end=read_end) + + pf_offset = 3973 - read_start + pf1 = util.data_to_float(data[pf_offset*2+0:pf_offset*2+4]) + pf1 = -p1 / va1 * var1/abs(var1) + pf2 = util.data_to_float(data[pf_offset*2+4:pf_offset*2+8]) + pf2 = -p2 / va2 * var2/abs(var2) + pf3 = util.data_to_float(data[pf_offset*2+8:pf_offset*2+12]) + pf3 = -p3 / va3 * var3/abs(var3) + + '''data = self.bulk_float_read(start=3475, end=3479) + pf1 = 1 + pf2 = 2 + pf3 = 3''' + + # 3 phase option + datarec = {'TIME': time.time(), + 'AC_VRMS_1': v1, + 'AC_IRMS_1': i1, + 'AC_P_1': p1, + 'AC_S_1': va1, + 'AC_Q_1': var1, + 'AC_PF_1': pf1, + 'AC_FREQ_1': freq, + 'AC_VRMS_2': v2, + 'AC_IRMS_2': i2, + 'AC_P_2': p2, + 'AC_S_2': va2, + 'AC_Q_2': var2, + 'AC_PF_2': pf2, + 'AC_FREQ_2': freq, + 'AC_VRMS_3': v3, + 'AC_IRMS_3': i3, + 'AC_P_3': p3, + 'AC_S_3': va3, + 'AC_Q_3': var3, + 'AC_PF_3': pf3, + 'AC_FREQ_3': freq, + 'DC_V': None, + 'DC_I': None, + 'DC_P': None, + 'TRIG': None, + 'TRIG_GRID': None} + + data = [] + for chan in data_points: + data.append(datarec[chan]) + + return data + + def generic_float_read(self, reg): + data = self.device.read(reg, 2, op=client.FUNC_READ_INPUT) + data_num = util.data_to_float(data) + return data_num + + def bulk_float_read(self, start=11700, end=11762): + actual_start = start #- 1 # the register is one less than reported in the literature + actual_length = (end - start) + 2 + data = self.device.read(actual_start, actual_length, op=client.FUNC_READ_INPUT) + + return data + + def waveform_config(self, params): + """ + Configure waveform capture. + + params: Dictionary with following entries: + 'sample_rate' - Sample rate (samples/sec) + 'pre_trigger' - Pre-trigger time (sec) + 'post_trigger' - Post-trigger time (sec) + 'trigger_level' - Trigger level + 'trigger_cond' - Trigger condition - ['Rising_Edge', 'Falling_Edge'] + 'trigger_channel' - Trigger channel - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + 'timeout' - Timeout (sec) + 'channels' - Channels to capture - ['AC_V_1', 'AC_V_2', 'AC_V_3', 'AC_I_1', 'AC_I_2', 'AC_I_3', 'EXT'] + """ + pass + + def waveform_capture(self, enable=True, sleep=None): + """ + Enable/disable waveform capture. + """ + pass + + def waveform_status(self): + pass + + def waveform_force_trigger(self): + pass + + def waveform_capture_dataset(self): + pass + + +def reg_shift(reg): + r1 = (reg)*2 + r2 = r1 + 4 + return r1, r2 + +def data_read(): + + # Changed to the bulk read option to speed up acquisition time + + # freq = self.generic_float_read(999) + + #p1 = self.generic_float_read(1025) + #p2 = self.generic_float_read(1027) + #p3 = self.generic_float_read(1029) + + read_start = 999 + read_end = 1121 #2440 + data = bulk_float_read(start=read_start, end=read_end) + + freq_offset = 999 - read_start + freq = util.data_to_float(data[freq_offset*2+0:freq_offset*2+4]) + + p_offset = 1025 - read_start + p1 = util.data_to_float(data[p_offset*2+0:p_offset*2+4]) + p2 = util.data_to_float(data[p_offset*2+4:p_offset*2+8]) + p3 = util.data_to_float(data[p_offset*2+8:p_offset*2+12]) + + var_offset = 1041 - read_start + var1 = util.data_to_float(data[var_offset*2+0:var_offset*2+4]) + var2 = util.data_to_float(data[var_offset*2+4:var_offset*2+8]) + var3 = util.data_to_float(data[var_offset*2+8:var_offset*2+12]) + + v_offset = 1103 - read_start + v1 = util.data_to_float(data[v_offset*2+0:v_offset*2+4]) + v2 = util.data_to_float(data[v_offset*2+4:v_offset*2+8]) + v3 = util.data_to_float(data[v_offset*2+8:v_offset*2+12]) + + va_offset = 1057 - read_start + va1 = util.data_to_float(data[va_offset*2+0:va_offset*2+4]) + va2 = util.data_to_float(data[va_offset*2+4:va_offset*2+8]) + va3 = util.data_to_float(data[va_offset*2+8:va_offset*2+12]) + + i_offset = 1117 - read_start + i1 = util.data_to_float(data[i_offset*2+0:i_offset*2+4]) + i2 = util.data_to_float(data[i_offset*2+4:i_offset*2+8]) + i3 = util.data_to_float(data[i_offset*2+8:i_offset*2+12]) + + read_start = 3483 # Elspec unit rejects any read that includes 2441, so need to do a second read + read_end = 3487 + data = bulk_float_read(start=read_start, end=read_end) + + pf_offset = 3483 - read_start + pf1 = util.data_to_float(data[pf_offset*2+0:pf_offset*2+4]) + pf1 = -p1 / va1 * var1/abs(var1) + pf2 = util.data_to_float(data[pf_offset*2+4:pf_offset*2+8]) + pf2 = -p2 / va2 * var2/abs(var2) + pf3 = util.data_to_float(data[pf_offset*2+8:pf_offset*2+12]) + pf3 = -p3 / va3 * var3/abs(var3) + + '''data = self.bulk_float_read(start=3475, end=3479) + pf1 = 1 + pf2 = 2 + pf3 = 3''' + + # 3 phase option + datarec = {'TIME': time.time(), + 'AC_VRMS_1': v1, + 'AC_IRMS_1': i1, + 'AC_P_1': p1, + 'AC_S_1': va1, + 'AC_Q_1': var1, + 'AC_PF_1': pf1, + 'AC_FREQ_1': freq, + 'AC_VRMS_2': v2, + 'AC_IRMS_2': i2, + 'AC_P_2': p2, + 'AC_S_2': va2, + 'AC_Q_2': var2, + 'AC_PF_2': pf2, + 'AC_FREQ_2': freq, + 'AC_VRMS_3': v3, + 'AC_IRMS_3': i3, + 'AC_P_3': p3, + 'AC_S_3': va3, + 'AC_Q_3': var3, + 'AC_PF_3': pf3, + 'AC_FREQ_3': freq, + 'DC_V': None, + 'DC_I': None, + 'DC_P': None} + + return datarec + +def generic_float_read(reg): + data = device.read(reg, 2, op=client.FUNC_READ_INPUT) + data_num = util.data_to_float(data) + return data_num + +def bulk_float_read(start=11700, end=11762): + actual_start = start #- 1 # the register is one less than reported in the literature + actual_length = (end - start) + 2 + data = device.read(actual_start, actual_length, op=client.FUNC_READ_INPUT) + '''actual_start = start - 1 # the register is one less than reported in the literature + actual_length = (end - start) + 2 + data = device.read(actual_start, actual_length, op=client.FUNC_READ_INPUT)''' + return data + +''' +Registers +999: Frequency +1025: Power 1 +1027: Power 2 +1029: Power 3 +1041: Var 1 +1043: Var 2 +1045: Var 3 +1057: VA 1 +1059: VA 2 +1061: VA 3 +1103: V1 +1105: V2 +1107: V3 +1117: I1 +1119: I2 +1121: I3 +3475: PF1 +3477: PF2 +3479: PF3 +''' + +if __name__ == "__main__": + + ipaddr = '1.1.1.39' + #ipaddr = str(raw_input('ip address: ')) + device = None + + if ipaddr: + device = client.ModbusClientDeviceTCP(slave_id=159, ipaddr=ipaddr, ipport=502, timeout=10)#, trace_func=trace) + + data = device.read(1025, 2, op=client.FUNC_READ_INPUT) + print((util.data_to_float(data))) + data = device.read(1027, 2, op=client.FUNC_READ_INPUT) + print((util.data_to_float(data))) + data = device.read(1029, 2, op=client.FUNC_READ_INPUT) + print((util.data_to_float(data))) + + print(('%s' % data_read())) + print((data_read()['AC_P_1'])) + print((data_read()['AC_P_2'])) + print((data_read()['AC_P_3'])) + + diff --git a/Lib/svpelab/device_genset_caterpillar.py b/Lib/svpelab/device_genset_caterpillar.py new file mode 100644 index 0000000..8da4601 --- /dev/null +++ b/Lib/svpelab/device_genset_caterpillar.py @@ -0,0 +1,405 @@ +""" +Copyright (c) 2021, Sandia National Laboratories +All rights reserved. + +V1.2 - Jay Johnson - 7/31/2018 +""" + +try: + import sunspec.core.modbus.client as client + import sunspec.core.util as suns_util + import binascii +except Exception as e: + print('SunSpec or binascii packages did not import!') +try: + import numpy as np +except Exception as e: + print('Error: numpy python package not found!') # This will appear in the SVP log file. + # raise # programmers can raise this error to expose the error to the SVP user +try: + from scapy.all import * +except Exception as e: + print('Error: scapy file not found!') # This will appear in the SVP log file. + # raise # programmers can raise this error to expose the error to the SVP user + +GROUP_NAME = 'cat_genset' + +class Device(object): + + def __init__(self, params=None, ts=None): + self.ts = ts + self.group_name = 'genset' + self.data_ipaddr = params.get('data_ipaddr') + self.data_ipport = params.get('data_ipport') + self.data_slave_id = params.get('data_slave_id') + self.ctrl_ipaddr = params.get('cntl_ipaddr') + self.ctrl_ipport = params.get('cntl_ipport') + self.ctrl_slave_id = params.get('cntl_slave_id') + + self.data_reg_start = 50042 + self.data_modbus_read_length = 79 + self.data_device = client.ModbusClientDeviceTCP(slave_id=self.data_slave_id, ipaddr=self.data_ipaddr, + ipport=self.data_ipport, timeout=2) + + self.ctrl_reg_start = 50105 + self.ctrl_modbus_read_length = 15 + self.ctrl_device = client.ModbusClientDeviceTCP(slave_id=self.ctrl_slave_id, ipaddr=self.ctrl_ipaddr, + ipport=self.ctrl_ipport, timeout=2) + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.open() + + def open(self): + ipaddr = self.param_value('ipaddr') + ipport = self.param_value('ipport') + slave_id = self.param_value('slave_id') + + self.genset = client.ModbusClientDeviceTCP(slave_id, ipaddr, ipport, timeout=5) + + def info(self): + """ Get DER device information. + + Params: + Manufacturer + Model + Version + Options + SerialNumber + + :return: Dictionary of information elements. + """ + params = {'Manufacturer': 'Catepillar', 'Model': ''} + return params + + def nameplate(self): + """ Get nameplate ratings. + + Params: + WRtg - Active power maximum rating + VARtg - Apparent power maximum rating + VArRtgQ1, VArRtgQ2, VArRtgQ3, VArRtgQ4 - VAr maximum rating for each quadrant + ARtg - Current maximum rating + PFRtgQ1, PFRtgQ2, PFRtgQ3, PFRtgQ4 - Power factor rating for each quadrant + WHRtg - Energy maximum rating + AhrRtg - Amp-hour maximum rating + MaxChaRte - Charge rate maximum rating + MaxDisChaRte - Discharge rate maximum rating + + :return: Dictionary of nameplate ratings. + """ + + params = {} + params['WRtg'] = 225000. + params['VARtg'] = 250000. + params['VArRtgQ1'] = 250000. + params['VArRtgQ4'] = 250000. + return params + + def measurements(self): + """ Get measurement data. + + Params: + + :return: Dictionary of measurement data. + """ + params = {} + data = self.data_device.read(self.data_reg_start, self.data_modbus_read_length) + + reg_start = self.data_reg_start + for reg in range(self.data_modbus_read_length): + data_point = suns_util.data_to_u16(data[reg * 2:2 + reg * 2]) + # print('Register: %s = %s' % (reg + reg_start, data_point)) + if (reg + reg_start) == 50061: + params['Utility_V1'] = data_point + if reg + reg_start == 50062: + params['Utility_V2'] = data_point + if reg + reg_start == 50063: + params['Utility_V3'] = data_point + if reg + reg_start == 50064: + params['Utility_V4'] = data_point + if reg + reg_start == 50080: + params['Generator_V5'] = data_point + if reg + reg_start == 50120: + params['Generator_V7'] = data_point + + if reg + reg_start == 50073: + params['Utility_F1'] = data_point + if reg + reg_start == 50081: + params['Generator_F2'] = data_point + + if reg + reg_start == 50069: + params['Utility_I1'] = data_point + if reg + reg_start == 50070: + params['Utility_I2'] = data_point + if reg + reg_start == 50071: + params['Utility_I3'] = data_point + if reg + reg_start == 50072: + params['Utility_I4'] = data_point + + if reg + reg_start == 50059: + params['Utility_kW'] = data_point + + return params + + def settings(self, params=None): + """ + Get/set capability settings. + + Params: + WMax - Active power maximum + VRef - Reference voltage + VRefOfs - Reference voltage offset + VMax - Voltage maximum + VMin - Voltage minimum + VAMax - Apparent power maximum + VArMaxQ1, VArMaxQ2, VArMaxQ3, VArMaxQ4 - VAr maximum for each quadrant + WGra - Default active power ramp rate + PFMinQ1, PFMinQ2, PFMinQ3, PFMinQ4 + VArAct + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for connect. + """ + if params is not None: + pass + else: + params = {} + return params + + + def conn_status(self, params=None): + """ Get status of controls (binary True if active). + :return: Dictionary of active controls. + """ + if params is not None: + pass + else: + params = {} + return params + + + def controls_status(self, params=None): + """ Get status of controls (binary True if active). + :return: Dictionary of active controls. + """ + if params is not None: + pass + else: + params = {} + reg_start = self.ctrl_reg_start + + data = self.ctrl_device.read(self.ctrl_reg_start, self.ctrl_modbus_read_length) + print(data) + + for reg in range(self.ctrl_modbus_read_length): + # print('Register: %s = %s' % (reg + reg_start, data[reg * 2:2 + reg * 2])) + try: + data_point = suns_util.data_to_u16(data[reg * 2:2 + reg * 2]) + print('Register: %s = %s' % (reg + reg_start, data_point)) + except Exception as e: + print('Warning: %s' % e) + # data = self.ctrl_device.read(4790, 2) + # print('Power: %s' % suns_util.data_to_u16(data)) + # + # data = self.ctrl_device.read(4729, 2) + # print('Power: %s' % suns_util.data_to_s16(data)) + + return params + + + def reactive_power(self, params=None): + """ Set the reactive power + + Params: + Ena - Enabled (True/False) + Q - Reactive power as %Qmax (positive is overexcited, negative is underexcited) + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for Q control. + """ + if params is not None: + if params['Q'] is not None: + targ_q = params['Q'] + if 0 <= targ_q <= 50: + self.ctrl_device.write(4729, suns_util.u16_to_data(int(targ_q))) + else: + print('Genset target reative power out of range.') + else: + params = {} + return params + + def active_power(self, params=None): + """ Get/set active power of EUT + + Params: + Ena - Enabled (True/False) + P - Active power in %Wmax (positive is exporting (discharging), negative is importing (charging) power) + WinTms - Randomized start time delay in seconds + RmpTms - Ramp time in seconds to updated output level + RvrtTms - Reversion time in seconds + + :param params: Dictionary of parameters to be updated. + :return: Dictionary of active settings for HFRT control. + """ + if params is not None: + if params['P'] is not None: + targ_power = params['P'] + if 0 <= targ_power <= 150: + self.ctrl_device.write(4790, suns_util.u16_to_data(int(targ_power))) + else: + print('Genset target power out of range.') + else: + params = {} + params['P'] = 25000 + return params + + +if __name__ == '__main__': + ''' + 192.168.0.1 - MAC Address: 00:12:8C:00:3C:B2 (Woodward Governor) - Control + 192.168.0.33 - MAC Address: 00:12:8C:00:40:C2 (Woodward Governor) - Data + 192.168.0.61 - MAC Address: 00:12:8C:00:40:60 (Woodward Governor) + 192.168.0.100 - MAC Address: 00:01:23:1C:13:F0 (Digital Electronics) + 6000/tcp open X11 + 6001/tcp open X11:1 + 192.168.0.128 - MAC Address: 00:A0:45:35:A3:C0 (Phoenix Contact Electronics Gmbh) + 21/tcp open ftp + 80/tcp open http + ''' + + # params = {'data_ipaddr': '192.168.0.33', + # 'data_ipport': 502, + # 'data_slave_id': 0, + # 'cntl_ipaddr': '192.168.0.1', + # 'cntl_ipport': 502, + # 'cntl_slave_id': 0, + # } + + params = {'data_ipaddr': '10.1.13.32', + 'data_ipport': 502, + 'data_slave_id': 0, + 'cntl_ipaddr': '10.1.13.31', + 'cntl_ipport': 502, + 'cntl_slave_id': 0, + } + + genset = Device(params=params) + + # print(genset.controls_status()) + # for i in range(10): + # print(genset.active_power(params={'P': 25})) + # print(genset.reactive_power(params={'Q': 35})) + # time.sleep(1) + # print(genset.controls_status()) + # print(genset.active_power(params={'P': 45})) + # print(genset.reactive_power(params={'Q': 15})) + # time.sleep(1) + # print(genset.controls_status()) + + for i in range(5): + print(genset.measurements()) + time.sleep(1) + + print(genset.active_power(params={'P': 45})) + print(genset.reactive_power(params={'Q': 15})) + + # ABB PLC + # ipaddr = '172.22.61.102' + # reg_start = 1161 + # modbus_read_length = 1 + # device = client.ModbusClientDeviceTCP(slave_id=0, ipaddr=ipaddr, ipport=502, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('\nABB PCS Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + + # '3.12.25.R' + # ipaddr = '10.1.13.4' + # reg_start = 1161 + # modbus_read_length = 1 + # device = client.ModbusClientDeviceTCP(slave_id=3, ipaddr=ipaddr, ipport=502, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('\nSMA Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + + + # ipaddr = '10.1.2.28' + # reg_start = 2 + # modbus_read_length = 30 + # device = client.ModbusClientDeviceTCP(slave_id=1, ipaddr=ipaddr, ipport=5000, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('\nRTAC Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + # device.write(8, suns_util.s16_to_data(-25)) # Real Power + # device.write(9, suns_util.s16_to_data(-10)) # Reactive Power + + # opal_sim_pts = { + # 'CB101 Switch': {'Name': 'CB101', 'Port': 501}, + # 'CB102 Switch': {'Name': 'CB102', 'Port': 502}, + # 'CB103 Switch': {'Name': 'CB106', 'Port': 503}, + # 'CB104 Switch': {'Name': 'CB104', 'Port': 504}, + # 'CB105 Switch': {'Name': 'CB105', 'Port': 505}, + # 'CB106 Switch': {'Name': 'CB106', 'Port': 506}, + # 'CB107 Switch': {'Name': 'CB107', 'Port': 507}, + # 'BUS101-Switch 4': {'Name': 'CB108', 'Port': 508}, + # 'Gen_2': {'Name': 'GEN 2 (BUS103)', 'Port': 500}, + # 'CB110 Switch': {'Name': 'CB110', 'Port': 510}, + # 'CB114 Switch': {'Name': 'CB114', 'Port': 511}, + # 'BUS106-Switch 1': {'Name': 'CB109', 'Port': 509}, + # } + # + # name = 'CB106 Switch' + # ipaddr = '10.1.2.3' + # reg_start = 2 + # modbus_read_length = 30 + # device = client.ModbusClientDeviceTCP(slave_id=1, ipaddr=ipaddr, ipport=500, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('\nRTAC Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + + # Genset + # ipaddr = '192.168.0.1' + # device = client.ModbusClientDeviceTCP(slave_id=0, ipaddr=ipaddr, ipport=502, timeout=2) + # + # device.write(4790, suns_util.u16_to_data(75)) # Set power + # device.write(4729, suns_util.u16_to_data(35)) # set reactive power + + # ipaddr = '192.168.0.33' + # reg_start = 50042 + # modbus_read_length = 79 + # device = client.ModbusClientDeviceTCP(slave_id=255, ipaddr=ipaddr, ipport=502, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('Woodward 1 Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + + # ipaddr = '192.168.0.1' + # reg_start = 50105 + # modbus_read_length = 15 + # device = client.ModbusClientDeviceTCP(slave_id=255, ipaddr=ipaddr, ipport=502, timeout=2) + # data = device.read(reg_start, modbus_read_length) + # print('\nWoodward 2 Data') + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print(('Register: %s = %s' % (reg+reg_start, data_point))) + + # Wireshark captures: modbus.func_code == 6 & & ip.src == 192.168.0.100 + # sniff(count=10) + # a = sniff(filter='port 502 && src 192.168.0.33 && dst 192.168.0.100', count=1, prn=lambda x: x.sprintf('Packet {} ==> {}'.format(x[0][1].src, x[0][1].dst))) + # a.nsummary() + # print(a[0].show()) + diff --git a/Lib/svpelab/device_keysightAPV.py b/Lib/svpelab/device_keysightAPV.py new file mode 100644 index 0000000..d93fe2e --- /dev/null +++ b/Lib/svpelab/device_keysightAPV.py @@ -0,0 +1,235 @@ +""" +Copyright (c) 2019, Sandia National Laboratories, SunSpec Alliance, and Tecnalia +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import sys +import time +import socket + +SAS_CURVE = 'SAS CURVE' +SVP_CURVE = 'SVP CURVE' + +STATUS_PROFILE_RUNNING = 64 +STATUS_PROFILE_PAUSED = 128 +STATUS_PROFILE_IN_PROGRESS = STATUS_PROFILE_RUNNING + STATUS_PROFILE_PAUSED + +class KeysightAPVError(Exception): + pass + +class KeysightAPV(object): + + def __init__(self, ipaddr='127.0.0.1', ipport=5025, timeout=5): + self.ipaddr = ipaddr + self.ipport = ipport + self.timeout = timeout + self.buffer_size = 1024 + self.conn = None + + def _cmd(self, cmd_str): + try: + if self.conn is None: + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + except Exception as e: + raise + + def _query(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': + more_data = False + break + except Exception as e: + raise KeysightAPVError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + try: + self._cmd(cmd_str) + resp = self._query('SYST:ERR?\n') + + if len(resp) > 0: + if resp[1] != '0': + raise KeysightAPVError(resp) + except Exception as e: + raise KeysightAPVError(str(e)) + finally: + self.close() + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise KeysightAPVError(str(e)) + finally: + self.close() + + return resp + + def info(self): + return self.query('*IDN?\n') + + def reset(self): + self.cmd('*RST\r') + + def scan(self): + self.idn = self.info() + self.channels = [None] + count = int(self.query('SYSTem:CHANnel:COUNt?\n')) + + for c in range(1, count + 1): + self.channels.append(Channel(self, c)) + + for c in self.channels[1:]: + pass + + def close(self): + try: + if self.conn is not None: + self.conn.close() + except Exception as e: + pass + finally: + self.conn = None + + def curve_SAS(self, mode='CURVe', imp=6.6, vmp=100, isc=7, voc=120): + self.cmd('SAS:CURV:IMP %s; ISC %s; VMP %s; VOC %s\n' % (imp,isc,vmp,voc)) + self.cmd('SOURce:SASimulator:MODE %s\n' % (mode)) + + def curve_SAS_read(self): + response=self.query('SAS:CURV:IMP?; ISC?; VMP?; VOC?\n') + data=response.split(';'); + return data + + +class Channel(object): + + def __init__(self, ksas, index): + self.ksas = ksas + self.index = index + self.curve = None + self.profile = None + self.irradiance = 1000 + self.channels = [] + self.group_index = None + + def group(self, channels): + self.channels = channels + self.group_index = channels[0] + + def irradiance_set(self, irradiance): + self.imp_red = (irradiance/10) + self.ksas.cmd('SAS:SCAL:CURR %f\n' % self.imp_red) + # All previously programmed curve parameters are calculated and transferred to the PV simulator(s). + + def output_is_on(self): + state = self.ksas.query('OUTPut:STATe?\n') + if state == 1: + return True + return False + + def output_set_off(self): + self.ksas.cmd('OUTPut:STATe 0\n') + + def output_set_on(self): + self.ksas.cmd('OUTPut:STATe 1\n') + + def status(self): + return self.ksas.query('STATus:OPERation:CONDition?\n') + + def overvoltage_protection_set(self, voltage=330): + self.ksas.cmd('SOURce:VOLTage:PROTection:LEVel %s\n' % voltage) + #[SOURce:]CURRent:PROTection[:LEVel] [,(@chanlist)] + +if __name__ == "__main__": + + try: + ksas = KeysightAPV(ipaddr='127.0.0.1') + # ksas = KeysightAPV(ipaddr='192.168.0.196') + # ksas = KeysightAPV(ipaddr='10.10.10.10') + + ksas.scan() + + ksas.reset() + + ksas.curve_en50530(pmp=3000, vmp=460) + ksas.curve('BP Solar - BP 3230T (60 cells)') + + ksas.profile('STPsIrradiance') + ksas.profile('Cloudy day') + + print('groups =', ksas.groups_get()) + print('profiles =', ksas.profiles_get()) + print('curves =', ksas.curves_get()) + + channel = ksas.channels[1] + print('is on =', channel.output_is_on()) + + channel.profile_set('STPsIrradiance') + channel.curve_set(EN_50530_CURVE) + channel.profile_start() + channel.output_set_on() + + print('channel curve =', channel.curve_get()) + print('channel profile =', channel.profile_get()) + print('is on =', channel.output_is_on()) + + time.sleep(10) + print('is on =', channel.output_is_on()) + channel.profile_abort() + channel.profile_set('Cloudy day') + channel.curve_set('BP Solar - BP 3230T (60 cells)') + + channel.profile_start() + + print('channel curve =', channel.curve_get()) + print('channel profile =', channel.profile_get()) + print('is on =', channel.output_is_on()) + + ksas.close() + + except Exception as e: + raise + print('Error running KeysightAPV setup: %s' % (str(e))) diff --git a/Lib/svpelab/device_loadsim_icselect_8064.py b/Lib/svpelab/device_loadsim_icselect_8064.py new file mode 100644 index 0000000..b20995e --- /dev/null +++ b/Lib/svpelab/device_loadsim_icselect_8064.py @@ -0,0 +1,313 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import time +from . import vxi11 +import numpy as np +import itertools +import csv + + +def p_q_profile(csvfile=None): + if file is not None: + time = [] + power = [] + q_l = [] + q_c = [] + with open(csvfile) as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + for row in csv_reader: + try: + time.append(float(row[0])) + if r_load: + power.append(float(row[1])) + else: + power.append(0) + if l_load: + q_l.append(float(row[2])) + else: + q_l.append(0) + if c_load: + q_c.append(float(row[3])) + else: + q_c.append(0) + except Exception as e: + print(('Not an numerical entry...skipping data for row %s. Error: %s' % (row, e))) + return time, power, q_l, q_c + +class DeviceError(Exception): + """ + Exception to wrap all loadbank generated exceptions. + """ + pass + + +class Device(object): + + def __init__(self, params): + self.vx = None + self.params = params + self.vx = vxi11.Instrument(self.params['ip_addr']) + self.time = [] + self.target = [] + self.switch_map = self.params['switch_map'] + + def open(self): + pass + + def close(self): + if self.vx is not None: + self.vx.close() + self.vx = None + + def cmd(self, cmd_str): + try: + self.vx.write(cmd_str) + resp = self.query('SYST:ERRor?') + + if len(resp) > 0: + if resp[0] != '0': + raise DeviceError(resp) + except Exception as e: + raise DeviceError('ICS Electronics 8064 communication error: %s' % str(e)) + + def query(self, cmd_str): + try: + resp = self.vx.ask(cmd_str) + except Exception as e: + raise DeviceError('ICS Electronics 8064 communication error: %s' % str(e)) + + return resp + + def info(self): + return self.query('*IDN?') + + def version(self): + return self.query('SYSTem:VERSion?') + + def query_status(self): + event = self.query('STAT:OPER:EVENt?') + condition = self.query('STAT:OPER:CONDition?') + enable = self.query('STAT:OPER:ENABle?') + print('Event: %s, Condition: %s, Enable: %s' % (event, condition, enable)) + return event, condition, enable + + def query_relay_control_state(self): + state = self.query('Q?') + print('Relay Control State: %s' % state) + return state + + def cmd_open_all(self): + status = self.cmd('ROUT:OPEN:ALL') + print('Opened all relays') + return status + + def cmd_close(self, relays): + if relays is not [None]: + if relays == [None]: + self.cmd('ROUT:OPEN:ALL') + print('Closing Relays: %s' % relays) + else: + cmd_str = 'C (@' + for r in relays: + cmd_str += '%s,' % r + cmd_str = cmd_str[:-1] + ')' + print('Closing Relays: %s' % relays) + self.cmd(cmd_str) + else: + print('ERROR: Relays to be closed were not passed as a list.') + return None + + def set_value(self, value): + self.target = [value] + switches, loads, error = self.find_closest_sum(index=0) + return switches, loads, error + + def find_closest_sum(self, index=None): + for t in [self.target[index]]: + if not list(self.switch_map.keys()): + break + combs = sum([list(itertools.combinations(list(self.switch_map.keys()), r)) for r in range(1, len(list(self.switch_map.keys()))+1)], []) + sums = np.asarray(list(map(sum, combs))) + bestcomb = combs[np.argmin(np.abs(np.asarray(sums) - t))] + # print("Target: {}, combination: {}".format(t, bestcomb)) + + switches = [] + loads = [] + for value in bestcomb: + loads.append(value) + switches.append(self.switch_map[value]) + error = abs(sum(loads)-t) + return switches, loads, error + +if __name__ == "__main__": + r_load = True + l_load = False + c_load = False + + filename = 'C:\\Users\detldaq\Downloads\Load_test.csv' + sec, target_W, target_VA_l, target_VA_c = p_q_profile(filename) + + if r_load: + params = {} + params['ip_addr'] = '10.1.32.63' + params['switch_map'] = {0: None, + 263: 1, + 526: 2, + 1052: 3, + 2106: 4, + 4210: 5, + 1053: 6, + 8421: 7, + 2105: 8, + 9080: 9, + 3158: 10} + + params['target'] = target_W + LB_W = Device(params=params) + print(LB_W.info()) + print(LB_W.version()) + LB_W.query_status() + LB_W.query_relay_control_state() + + if l_load: + params = {} + params['ip_addr'] = '0.0.0.0' + params['switch_map'] = {0: None, + 197: 1, + 390: 2, + 788: 3, + 1582: 4, + 3170: 5, + 790: 6, + 6175: 7, + 1562: 8, + 9080: 9, + 2340: 10} + params['target'] = target_VA_l + LB_VA_l = Device(params=params) + print(LB_VA_l.info()) + print(LB_VA_l.version()) + LB_VA_l.query_status() + LB_VA_l.query_relay_control_state() + + if c_load: + params = {} + params['ip_addr'] = '0.0.0.0' + params['switch_map'] = {0: None, + 197: 1, + 390: 2, + 788: 3, + 1582: 4, + 3170: 5, + 790: 6, + 6175: 7, + 1562: 8, + 9080: 9, + 2340: 10} + params['target'] = target_VA_c + LB_VA_c = Device(params=params) + print(LB_VA_c.info()) + print(LB_VA_c.version()) + LB_VA_c.query_status() + LB_VA_c.query_relay_control_state() + + + + # for t in range(3): + # LB_W.query_relay_control_state() + # time.sleep(30) + # # d.cmd_close([1, 3, 7, 16]) + # # d.cmd_close([t + 1]) + # # d.cmd_close([1]) + # LB_W.query_relay_control_state() + # time.sleep(30) + # LB_W.cmd_open_all() + # time.sleep(1) + start = time.time() + time.sleep(0.1) + i = 0 + + + while i < len(sec): + # switches_VA, loads_VA, error_VA = LB_VA.find_closest_sum([load_VA[i]]) + now = time.time() + elapsed = now - start + if elapsed >= sec[i]: + if r_load: + switches_W, loads_W, error_W = LB_W.set_value(target_W[i]) + print(('Resistive: Target = %s W, Total power = %s, switches: %s, loads: %s, power error = %s W' % + (target_W[i], sum(loads_W), switches_W, loads_W, error_W))) + LB_W.cmd_close(switches_W) + else: + error_W = 0 + loads_W = target_W + LB_W.cmd_open_all() + if l_load: + switches_VA_l, loads_VA_l, error_VA_l = LB_VA_l.set_value(target_VA_l) + print(('Inductive: Target = %s VA_l, Total Var = %s, switches: %s, loads: %s, power error = %s W' % + (target_VA_l[i], sum(loads_VA_l), switches_VA_l, loads_VA_l, error_VA_l))) + LB_VA_l.cmd_close(switches_VA_l) + LB_VA_l.cmd_open_all() + else: + error_VA_l = 0 + loads_VA_l = target_VA_l + if c_load: + switches_VA_c, loads_VA_c, error_VA_c = LB_VA_c.set_value(target_VA_c) + LB_VA_c.cmd_close(switches_VA_c) + LB_VA_c.cmd_open_all() + print(('Capacitive: Target = %s W, Total power = %s, switches: %s, loads: %s, power error = %s W' % + (target_VA_c[i], sum(loads_W), switches_W, loads_VA_c, error_W))) + else: + error_VA_c = 0 + loads_VA_c = target_VA_c + + print(('Target = %s W, %s inductive var, %s capacitive var at time = %s s.' % + (target_W[i], target_VA_l[i], target_VA_c[i], round(elapsed, 1)))) + + i += 1 + else: + time.sleep(0.05) + + if r_load: + LB_W.cmd_open_all() + if l_load: + LB_VA_l.cmd_open_all() + if c_load: + LB_VA_c.cmd_open_all() + + + + + + + diff --git a/Lib/svpelab/device_ni_crio_avtron_reactive.py b/Lib/svpelab/device_ni_crio_avtron_reactive.py new file mode 100644 index 0000000..416bbc7 --- /dev/null +++ b/Lib/svpelab/device_ni_crio_avtron_reactive.py @@ -0,0 +1,161 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import sys +import math +import time +import pyvisa as visa + +TERMINATOR = '\n' + +def LoadBankError(Exception): + pass + +class AvtronReactive(object): + """ + Communications to an Avtron 55 kVar load bank via a NI cRIO-9073 + """ + + def __init__(self, visa_device=None, visa_path=None): + self.rm = None # Resource Manager for VISA + self.conn = None # Connection to instrument for VISA-GPIB + self.visa_device = visa_device + self.visa_path = visa_path + self.data_query = '' + + self.open() # open communications, not the relay + + def open(self): + + try: + self.rm = visa.ResourceManager(self.visa_path) + self.conn = self.rm.open_resource(self.visa_device) + self.conn.write_termination = TERMINATOR + + except Exception as e: + raise LoadBankError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device, str(e))) + + def close(self): + try: + if self.rm is not None: + if self.conn is not None: + self.conn.close() + self.rm.close() + time.sleep(1) + except Exception as e: + raise LoadBankError(str(e)) + + def info(self): + return self._query('*IDN?') + + def cmd(self, cmd_str): + + try: + cmd_str = cmd_str.strip() + self._write(cmd_str) + resp = self._query('SYSTem:ERRor?') #\r + if len(resp) > 0: + if resp[0] != '0': + raise LoadBankError(resp + ' ' + cmd_str) + except Exception as e: + raise LoadBankError(str(e)) + + def _query(self, cmd_str): + try: + cmd_str.strip() + if self.conn is None: + raise LoadBankError('Connection not open') + return self.conn.query(cmd_str) + + except Exception as e: + raise LoadBankError(str(e)) + + def _write(self, cmd_str): + try: + if self.conn is None: + raise LoadBankError('Connection not open') + return self.conn.write(cmd_str) + except Exception as e: + raise LoadBankError(str(e)) + + def voltset(self, v): + self.volts = v + + def freqset(self, f): + self.freq = f + + def resistance(self, ph=None, r=None): + b = 0.0 + if r is not None: + # Calculate resistance. + if r == 0: + self._write('PHASE%s:RLOAD 000' % (ph)) + else: + b = (1 / float(r)) + b = BASE_RESISTANCE*b + b = int(round(b,0)) + b = format(b, '011b') + b = b[::-1] + r_value = int(b,2) + self.cmd('PHASE%s:RLOAD %s' % (ph,r_value)) + + def inductance(self, ph, i): + if i is not None: + self._write('PHASE%s:LLOAD %s' % (ph, calcL (i, self.freq, self.volts))) + + def capacitance(self, ph, i): + if i is not None: + self._write('PHASE%s:CLOAD %s' % (ph, calcC (i, self.freq, self.volts))) + + def capacitor_q(self, q=None): + raise NotImplementedError('capacitor_q() is not implemented') + + def inductor_q(self, q=None): + raise NotImplementedError('inductor_q() is not implemented') + + def resistance_p(self, p=None, v=None, i=None): + # P=V^2*R, P = I^2*R + raise NotImplementedError('resistance_p() is not implemented') + + def tune_current(self, i=None): + raise NotImplementedError('tune_current() is not implemented') + +if __name__ == "__main__": + + # rio://192.168.1.231/RIO0 + # visa://192.168.1.231/ASRL1::INSTR + + loadbank = AvtronReactive('//192.168.1.231/ASRL1::INSTR', 'C:/Windows/System32/visa32.dll') + # print loadbank.info() + loadbank.close() + diff --git a/Lib/svpelab/device_pvsim_sps.py b/Lib/svpelab/device_pvsim_sps.py new file mode 100644 index 0000000..bbd777e --- /dev/null +++ b/Lib/svpelab/device_pvsim_sps.py @@ -0,0 +1,303 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import sys +import time +import socket + +EN_50530_CURVE = 'EN 50530 CURVE' +SVP_CURVE = 'SVP CURVE' + +STATUS_PROFILE_RUNNING = 64 +STATUS_PROFILE_PAUSED = 128 +STATUS_PROFILE_IN_PROGRESS = STATUS_PROFILE_RUNNING + STATUS_PROFILE_PAUSED + +class SPSError(Exception): + pass + +class SPS(object): + + def __init__(self, comm='VISA', visa_id='GPIB1::19::INSTR', ipaddr='127.0.0.1', ipport=4944, timeout=5): + self.comm = comm # 'Network' or 'VISA' + + # TCP/IP communications + self.ipaddr = ipaddr + self.ipport = ipport + self.timeout = timeout + self.buffer_size = 1024 + self.conn = None + self.curve = None # I-V Curve handle + self.profile = None # Irradiance/temperature vs time profile + self.irradiance = 1000 # initial irradiance + self.group_index = None + + # if using VISA, configure the connection + if self.comm == 'VISA': + try: + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(visa_id) + # the default pyvisa write termination is '\r\n' which does not work with the SPS + self.conn.write_termination = '\n' + self.ts.sleep(1) + except Exception as e: + raise Exception('Cannot open VISA connection to %s\n\t%s' % (visa_id, str(e))) + + # TCP/IP command + def _cmd(self, cmd_str): + try: + if self.conn is None or self.conn is 'Network': + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + except Exception as e: + raise + + # TCP/IP query + def _query(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\r': + more_data = False + break + except Exception as e: + raise SPSError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + try: + if self.comm == 'Network': + self._cmd(cmd_str) + resp = self._query('SYSTem:ERRor?\r') + + if len(resp) > 0: + if resp[0] != '0': + raise SPSError(resp) + elif self.comm == 'VISA': + self.conn.write(cmd_str) + except Exception as e: + raise SPSError(str(e)) + finally: + self.close() + + def query(self, cmd_str): + resp = None + try: + if self.comm == 'Network': + resp = self._query(cmd_str).strip() + elif self.comm == 'VISA': + resp = self.conn.query(cmd_str) + except Exception as e: + raise SPSError(str(e)) + finally: + self.close() + + return resp + + def info(self): + return self.query('*IDN?\r') + + def reset(self): + self.cmd('*RST\r') + + def scan(self): # used to scan for channels on other pvsims + pass + + def close(self): + try: + if self.conn is not None: + self.conn.close() + except Exception as e: + pass + finally: + self.conn = None + + def curves_get(self): + return self.query('CURVe:CATalog?\r').strip().split(',') + + def curve(self, voc=None, isc=None, vmp=None, imp=None, form_factor=None, + beta_v=None, beta_p=None, kfactor_voltage=None, kfactor_irradiance=None): + + try: + self.cmd('CURVe:DELEte "%s"\r' % SVP_CURVE) # Must delete the previous curve + except Exception as e: + print(('Curve not found: %s' % e)) + + if voc is not None and isc is not None: + self.cmd('CURVe:VIparms %s, %s\r' % (voc, isc)) + if vmp is not None and imp is not None: + self.cmd('CURVe:MPPparms %s, %s\r' % (vmp, imp)) + if form_factor is not None: + self.cmd('CURVe:FORMfactor %s\r' % (form_factor)) + + if beta_v is not None and beta_p is not None: + self.cmd('CURVe:BETAparms %s, %s\r' % (beta_v, beta_p)) + # Sets the voltage and power temperature coefficients, expressed in percent values per + # degree Kelvin. Some manufacturers report the voltage coefficient in mV/K. + # Divide by Voc to obtain a percentage. Allowed range is +1.99 to -1.99. + + if kfactor_voltage is not None and kfactor_irradiance is not None: + self.cmd('CURVe:KFactor %s, %s\r' % (kfactor_voltage, kfactor_irradiance)) + # Sets the irradiance correction factor by entering parameters V1 and E1. + # See "Photovoltaic curve > Create" for more details. The voltage must be + # equal to or less than Voc. The irradiance must be between 100 and 800 W/m2. + + import datetime + # Not possible to make new IV Curves using a name saved on the hard drive, so a new file is generated + curve_name = str(datetime.datetime.utcnow()) + curve_name = curve_name.translate(None, ':') # remove invalid characters + self.cmd('CURVe:ADD "%s"\r' % curve_name) # Save new curve to disk and add to graphic pool + + return curve_name # return IV curve name + + def curve_en50530(self, tech='CSI', sim_type='STA', pmp=1000, vmp=100): + self.cmd('CURVe:EN50530:SIMtype %s, %s\r' % (tech, sim_type)) + self.cmd('CURVe:EN50530:MPPparms %s, %s\r' % (pmp, vmp)) + self.cmd('CURVe:EN50530:ADD\r') + + def profile(self, filename): + self.cmd('PROFile:READFile "%s"\r' % filename) + + def profiles_get(self): + plist = [] + profiles = self.query('PROFile:CATalog?\r').split(',') + for p in profiles: + plist.append(p.split('.')[0]) + return plist + + def groups_get(self): + groups = self.query('SYSTem:GROup:CATalog?\r').split(',') + return groups + + def curve_get(self): + return self.query('SOURce:CURVe?\r') + + def curve_set(self, name): + if name is not None: + self.cmd('SOURce:CURVe "%s"\r' % name) + else: # if no name provided, use the latest SVP curve + self.cmd('SOURce:CURVe "%s"\r' % SVP_CURVE) + # self.cmd('SOURce:IRRadiance 1000, (@%s)\r' % self.index) + # self.cmd('SOURce:TEMPerature 25, (@%s)\r' % self.index) + self.cmd('SOURce:EXECute\r') + # The indicated curve is applied on the selected channels. If the name is blank, curve 0 is + # applied. Specify name "EN 50530 CURVE" to execute the EN50530 curve. + + def irradiance_set(self, irradiance): + self.irradiance = irradiance + self.cmd('SOURce:IRRadiance %d\r' % self.irradiance) + self.cmd('SOURce:EXECute\r') + # All previously programmed curve parameters are calculated and transferred to the PV simulator(s). + + def output_is_on(self): + state = self.query('OUTPut:STATe?\r') + if state == 'ON': + return True + return False + + def output_set_off(self): + self.cmd('OUTPut:STATe OFF\r') + + def output_set_on(self): + self.cmd('OUTPut:STATe ON\r') + + def profile_abort(self, timeout=2): + try: + self.cmd('ABORt\r') + except SPSError: + pass + time_left = float(timeout) + while time_left > 0: + if self.profile_is_active(): + time.sleep(.2) + time_left -= .2 + else: + break + + def profile_get(self): + return self.query('SOURce:PROFile?\r') + + def profile_is_active(self): + if int(self.status()) & STATUS_PROFILE_IN_PROGRESS: + return True + return False + + def profile_pause(self): + self.cmd('TRIGger:PAUse\r') + + def profile_set(self, name): + self.profile = name + self.cmd('SOURce:PROFile "%s"\r' % name) + + def profile_start(self): + try: + self.cmd('ABORt\r') + except SPSError: + pass + self.cmd('TRIGger:RESet\r') + self.cmd('TRIGger\r') + + def status(self): + return self.query('STATus:OPERation:CONDition?\r') + + def overvoltage_protection_set(self, voltage=330): + self.cmd('SOURce:VOLTage:PROTection %s\r' % voltage) + +if __name__ == "__main__": + + try: + sps = SPS(comm='VISA', visa_id='GPIB1::19::INSTR') + sps.info() + + sps.curve_en50530(pmp=3000, vmp=460) + sps.curve('BP Solar - BP 3230T (60 cells)') + + sps.profile('STPsIrradiance') + sps.profile('Cloudy day') + + sps.close() + + except Exception as e: + raise 'Error running SPS setup: %s' % (str(e)) diff --git a/Lib/svpelab/device_px8000.py b/Lib/svpelab/device_px8000.py index 260533f..08ec98d 100644 --- a/Lib/svpelab/device_px8000.py +++ b/Lib/svpelab/device_px8000.py @@ -32,7 +32,7 @@ import time -import vxi11 +from . import vxi11 ''' data_query_str = ( @@ -81,6 +81,29 @@ } +def pf_scan(points, pf_points): + for i in range(len(points)): + if points[i].startswith('AC_PF'): + label = points[i][5:] + try: + p_index = points.index('AC_P%s' % (label)) + q_index = points.index('AC_Q%s' % (label)) + pf_points.append((i, p_index, q_index)) + except ValueError: + pass + +def pf_adjust_sign(data, pf_idx, p_idx, q_idx): + """ + Power factor sign is the opposite sign of the product of active power and reactive power + """ + pq = data[p_idx] * data[q_idx] + # sign should be opposite of product of p and q + pf = abs(data[pf_idx]) + if pq >= 0: + pf = pf * -1 + return pf + + class DeviceError(Exception): """ Exception to wrap all das generated exceptions. @@ -95,6 +118,7 @@ def __init__(self, params): self.params = params self.channels = params.get('channels') self.data_points = ['TIME'] + self.pf_points = [] # create query string for configured channels query_chan_str = '' @@ -122,6 +146,8 @@ def __init__(self, params): self.query_str = ':NUMERIC:FORMAT ASCII\nNUMERIC:NORMAL:NUMBER %d\n' % (item) + query_chan_str + pf_scan(self.data_points, self.pf_points) + self.vx = vxi11.Instrument(self.params['ip_addr']) # clear any error conditions @@ -143,13 +169,13 @@ def cmd(self, cmd_str): if len(resp) > 0: if resp[0] != '0': raise DeviceError(resp) - except Exception, e: + except Exception as e: raise DeviceError('PX8000 communication error: %s' % str(e)) def query(self, cmd_str): try: resp = self.vx.ask(cmd_str) - except Exception, e: + except Exception as e: raise DeviceError('PX8000 communication error: %s' % str(e)) return resp @@ -161,8 +187,11 @@ def data_capture(self, enable=True): self.capture(enable) def data_read(self): - data = [float(i) for i in self.query(self.query_str).split(',')] + q = self.query(self.query_str) + data = [float(i) for i in q.split(',')] data.insert(0, time.time()) + for p in self.pf_points: + data[p[0]] = pf_adjust_sign(data, *p) return data def capture(self, enable=None): @@ -245,17 +274,32 @@ def trigger_config(self, params): except: pass + points_default = { + 'AC': ('VRMS', 'IRMS', 'P', 'S', 'Q', 'PF', 'FREQ'), + 'DC': ('V', 'I', 'P') + } + points = dict(points_default) + + channels = [None] + for i in range(1, 5): + chan_type = self._param_value('chan_%d' % (i)) + chan_label = self._param_value('chan_%d_label' % (i)) + if chan_label == 'None': + chan_label = '' + chan = {'type': chan_type, 'points': self.points.get(chan_type), 'label': chan_label} + channels.append(chan) + d = Device(params=params) - print d.info() + print(d.info()) # initialize temp directory d.cmd('FILE:DRIV SD') path = d.query('FILE:PATH?') if path != ':FILE:PATH "Path = SD"': - print 'Drive not found: %s' % 'SD' + print('Drive not found: %s' % 'SD') try: d.cmd('FILE:DEL "SVP_WAVEFORM";*WAI') - print 'deleted SVP temp directory' + print('deleted SVP temp directory') except: pass ''' @@ -277,19 +321,19 @@ def trigger_config(self, params): # capture waveform # POS 50? d.cmd('TRIG:MODE SING;HYST LOW;LEV 6.00000E-03;SLOP FALL;SOUR P2') - print d.query('TRIG:MODE?') - print d.query('TRIG:SIMP?') - print d.query('ACQ?') + print(d.query('TRIG:MODE?')) + print(d.query('TRIG:SIMP?')) + print(d.query('ACQ?')) d.cmd('ACQ:CLOC INT; COUN INF; MODE NORM; RLEN 250000') - print d.query('ACQ?') + print(d.query('ACQ?')) d.cmd('TIM:SOUR INT; TDIV 500.0E-03') - print d.query('TIM?') + print(d.query('TIM?')) d.cmd(':STAR') running = True while running: cond = int(d.query('STAT:COND?')) if cond & COND_RUNNING == COND_RUNNING: - print 'still waiting (%s) ...\r' % cond, + print('still waiting (%s) ...\r' % cond, end=' ') time.sleep(1) else: running = False @@ -297,7 +341,7 @@ def trigger_config(self, params): # save waveform d.cmd('FILE:SAVE:ANAM OFF;NAME "svp_waveform"') - print 'saving' + print('saving') d.cmd('FILE:SAVE:ASC:EXEC') # transfer waveform diff --git a/Lib/svpelab/device_pz4000.py b/Lib/svpelab/device_pz4000.py index e041427..bd2e16e 100644 --- a/Lib/svpelab/device_pz4000.py +++ b/Lib/svpelab/device_pz4000.py @@ -67,17 +67,17 @@ def open(self): elif self.params['comm'] == 'VISA': try: # sys.path.append(os.path.normpath(self.visa_path)) - import visa + import pyvisa as visa self.rm = visa.ResourceManager() self.conn = self.rm.open_resource(self.params['visa_address']) - except Exception, e: + except Exception as e: raise DeviceError('PZ4000 communication error: %s' % str(e)) else: raise ValueError('Unknown communication type %s. Use GPIB or VISA' % self.params['comm']) - except Exception, e: + except Exception as e: raise DeviceError(str(e)) @@ -96,7 +96,7 @@ def close(self): self.rm.close() - except Exception, e: + except Exception as e: raise DeviceError('PZ4000 communication error: %s' % str(e)) else: raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.params['comm']) @@ -105,7 +105,7 @@ def cmd(self, cmd_str): try: self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise DeviceError('PZ4000 communication error: %s' % str(e)) def query(self, cmd_str): diff --git a/Lib/svpelab/device_regatron_topcon_quadro.py b/Lib/svpelab/device_regatron_topcon_quadro.py new file mode 100644 index 0000000..939c36d --- /dev/null +++ b/Lib/svpelab/device_regatron_topcon_quadro.py @@ -0,0 +1,119 @@ +""" +Regatron driver developed by ZHAW and SNL +""" + +import sys +import time +import socket + +class RegatronError(Exception): + pass + +class Regatron(object): + + def __init__(self, ipaddr='10.0.0.4', ipport=771, timeout=5): + self.ipaddr = ipaddr + self.ipport = ipport + self.timeout = timeout + self.buffer_size = 1024 + self.conn = None + + def _cmd(self, cmd_str): + try: + print('Trying to send command in _cmd') + if self.conn is None: + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print('cmd> %s' % (cmd_str)) + self.conn.send(cmd_str) + except Exception as e: + raise + + def _query(self, cmd_str): + print('Getting response to query in _query') + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\r': + more_data = False + break + except Exception as e: + raise RegatronError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + try: + self._cmd(cmd_str) + resp = self._query('SYSTem:ERRor?\r') + if len(resp) > 0: + if resp[0] != '0': + raise RegatronError(resp) + except Exception as e: + raise RegatronError(str(e)) + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise RegatronError(str(e)) + finally: + self.close() + + return resp + + def info(self): + return self.query('*IDN?\r') + + def reset(self): + self.cmd('*RST\r') + + def irradiance_set(self, irradiance=1000): + pass + + def output_set_off(self): + pass + + def output_set_on(self): + pass + + def profile_load(self, profile_name): + # use pv_profiles.py to generate time vs irradiance/power profiles + pass + + def profile_start(self): + pass + + def close(self): + try: + if self.conn is not None: + self.conn.close() + except Exception as e: + pass + finally: + self.conn = None + + +if __name__ == "__main__": + + # Instantiate regatron object + reg = Regatron(ipaddr='10.0.0.4', ipport=771, timeout=5) + + # test the information method + print((reg.info())) + + # close the connection to the regatron + reg.close() + + + diff --git a/Lib/svpelab/device_sandia_dsm.py b/Lib/svpelab/device_sandia_dsm.py index 3806fc7..e3123f0 100644 --- a/Lib/svpelab/device_sandia_dsm.py +++ b/Lib/svpelab/device_sandia_dsm.py @@ -34,21 +34,21 @@ import time import traceback import glob -import waveform -import dataset +from . import waveform +from . import dataset data_points = [ 'TIME', 'DC_V', 'DC_I', - 'AC_VRMS', - 'AC_IRMS', + 'AC_VRMS_1', + 'AC_IRMS_1', 'DC_P', - 'AC_S', - 'AC_P', - 'AC_Q', - 'AC_FREQ', - 'AC_PF', + 'AC_S_1', + 'AC_P_1', + 'AC_Q_1', + 'AC_FREQ_1', + 'AC_PF_1', 'TRIG', 'TRIG_GRID' ] @@ -271,7 +271,7 @@ class DeviceError(Exception): class Device(object): def extract_points(self, points_str, op): - x = map(op, points_str[points_str.rfind('[')+1:points_str.rfind(']')].strip().split(',')) + x = list(map(op, points_str[points_str.rfind('[')+1:points_str.rfind(']')].strip().split(','))) return x def __init__(self, params): @@ -280,6 +280,7 @@ def __init__(self, params): self.dsm_id = self.params.get('dsm_id') self.comp = self.params.get('comp') self.file_path = self.params.get('file_path') + self.sample_interval = self.params.get('sample_interval') self.data_file = os.path.join(self.file_path, DATA_FILE) self.points_file = os.path.join(self.file_path, POINTS_FILE) self.wfm_trigger_file = os.path.join(self.file_path, WFM_TRIGGER_FILE) @@ -314,22 +315,37 @@ def __init__(self, params): try: if self.points_file is None: raise Exception('Point file not specified') - if self.dsm_method != 'Sandia LabView DSM': - raise Exception('Method not supported: %s' % (self.dsm_method)) - - f = open(self.points_file) - channels = f.read() - f.close() - - self.points = self.extract_points(channels, str) - print self.points - for p in self.points_map: - try: - index = self.points.index(p) - except ValueError: - index = -1 - self.point_indexes.append(index) - except Exception, e: + if self.dsm_method == 'Sandia LabView DSM UDP': + import socket + UDP_IP = "0.0.0.0" + UDP_PORT = 6495 + sock = socket.socket(socket.AF_INET, # Internet + socket.SOCK_DGRAM) # UDP + sock.bind((UDP_IP, UDP_PORT)) + while True: + data, addr = sock.recvfrom(4096) + if self.ts is not None: + self.ts.log("received message: %s" % data) + else: + print(("received message: %s" % data)) + else: + f = open(self.points_file) + channels = f.read() + f.close() + + self.points = self.extract_points(channels, str) + # if self.ts is not None: + # self.ts.log("self.points: %s" % self.points) + # else: + # print(self.points) + + for p in self.points_map: + try: + index = self.points.index(p) + except ValueError: + index = -1 + self.point_indexes.append(index) + except Exception as e: raise DeviceError(traceback.format_exc()) ''' try: @@ -368,12 +384,12 @@ def data_read(self): f = open(self.data_file) data = f.read() f.close() - except Exception, e: + except Exception as e: retries -= 1 if data is not None: points = self.extract_points(data, float) - print zip(self.points, points) + print(list(zip(self.points, points))) if points is not None: if len(points) == len(self.points): for index in self.point_indexes: @@ -385,7 +401,7 @@ def data_read(self): else: raise Exception('Error reading points: point count mismatch %d %d' % (len(points), len(self.points))) - except Exception, e: + except Exception as e: raise Exception(traceback.format_exc()) return rec @@ -419,7 +435,7 @@ def waveform_config(self, params): dsm_chan = wfm_dsm_channels[c] if dsm_chan is not None: self.wfm_dsm_channels.append('%s_%s' % (dsm_chan, self.dsm_id)) - print('Channels to record: %s' % str(self.wfm_channels)) + print(('Channels to record: %s' % str(self.wfm_channels))) def waveform_capture(self, enable=True, sleep=None): @@ -466,7 +482,7 @@ def waveform_capture(self, enable=True, sleep=None): wait_time = self.wfm_timeout for i in range(int(wait_time) + 1): - print ('looking for %s' % self.wfm_trigger_file) + print(('looking for %s' % self.wfm_trigger_file)) if not os.path.exists(self.wfm_trigger_file): break if i >= wait_time: @@ -474,7 +490,7 @@ def waveform_capture(self, enable=True, sleep=None): sleep(1) filename = os.path.join(self.file_path, '* %s' % WFM_TRIGGER_FILE) - print ('looking for %s' % filename) + print(('looking for %s' % filename)) files = glob.glob(filename) if len(files) == 0: raise DeviceError('No waveform trigger result file') @@ -507,7 +523,7 @@ def waveform_capture_dataset(self): ds = dataset.Dataset() f = open(self.wfm_capture_name_path, 'r') ids = f.readline().strip().split('\t') - print (str(ids)) + print((str(ids))) if ids[0] != 'Time': raise DeviceError('Unexpected time point name in waveform capture: %s' % ids[0]) ds.points.append('TIME') @@ -542,8 +558,105 @@ def waveform_capture_dataset(self): return ds +def send(data, port=50003, addr='127.0.0.1'): + """send(data[, port[, addr]]) - multicasts a UDP datagram.""" + # Create the socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Make the socket multicast-aware, and set TTL. + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 20) # Change TTL (=20) to suit + # Send the data + s.sendto(data, (addr, port)) + +def recv(port=50003, addr="127.0.0.1", buf_size=1024): + """recv([port[, addr[,buf_size]]]) - waits for a datagram and returns the data.""" + + # Create the socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Set some options to make it multicast-friendly + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + pass # Some systems don't support SO_REUSEPORT + s.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_TTL, 20) + s.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1) + + # Bind to the port + s.bind(('', port)) + + # Set some more multicast options + intf = socket.gethostbyname(socket.gethostname()) + s.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(intf)) + s.setsockopt(socket.SOL_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(addr) + socket.inet_aton(intf)) + + # Receive the data, then unregister multicast receive membership, then close the port + data, sender_addr = s.recvfrom(buf_size) + s.setsockopt(socket.SOL_IP, socket.IP_DROP_MEMBERSHIP, socket.inet_aton(addr) + socket.inet_aton('0.0.0.0')) + s.close() + return data + if __name__ == "__main__": + from netifaces import interfaces, ifaddresses, AF_INET + import struct + import socket + import time + import sys + + print((socket.gethostbyname(socket.gethostname()))) + + # Create a TCP/IP socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Bind the socket to the port + server_address = ('239.100.100.100', 51051) + print('starting up on %s port %s' % server_address) + # sock.bind(server_address) + while True: + print('\nwaiting to receive message', file=sys.stderr) + data, address = sock.recvfrom(4096) + + print('received %s bytes from %s' % (len(data), address)) + print(data) + + ''' + ip_addr = '10.1.2.78' + for interface in interfaces(): + for link in ifaddresses(interface)[AF_INET]: + local_addr = link['addr'].split('.', 3) + targ_addr = ip_addr.split('.', 3) + if '%s.%s.%s' % (local_addr[0], local_addr[1], local_addr[2]) == \ + '%s.%s.%s' % (targ_addr[0], targ_addr[1], targ_addr[2]): + iface_for_comms = interface + print('interface: %s, IP: %s' % (interface, link['addr'])) + print('Interface for comms: %s' % iface_for_comms) + ''' + + UDP_IP = '10.1.2.78' + IP = '10.1.2.218' + UDP_PORT = 51051 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((UDP_IP, UDP_PORT)) + # mreq = struct.pack("=4sl", socket.inet_aton(UDP_IP), socket.INADDR_ANY) + # sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + for i in range(10): + data, addr = sock.recvfrom(4096) + print("received message:", data) + time.sleep(0.1) + + ''' + import pyshark + capture = pyshark.LiveCapture() + capture.sniff(timeout=50) + for packet in capture.sniff_continuously(packet_count=5): + print('Just arrived:', packet) + capture = pyshark.LiveCapture(capture_filter='udp') + capture.apply_on_packets(packet_captured) + packet['ip'].dst + packet.ip.src + params = {} params['dsm_method'] = 'Sandia LabView DSM' params['file_path'] = 'c:\\users\\bob\\pycharmprojects\\loadsim\\files\\python_dsm' @@ -569,6 +682,7 @@ def waveform_capture_dataset(self): ds = d.waveform_capture_dataset() print ds.points ds.to_csv('c:\\users\\bob\\pycharmprojects\\loadsim\\files\\python_dsm\\wave.csv') + ''' diff --git a/Lib/svpelab/device_switch_prosoft_mvi46-mnet.py b/Lib/svpelab/device_switch_prosoft_mvi46-mnet.py new file mode 100644 index 0000000..04f31bf --- /dev/null +++ b/Lib/svpelab/device_switch_prosoft_mvi46-mnet.py @@ -0,0 +1,187 @@ +""" +Copyright (c) 2018, Sandia National Laboratories +All rights reserved. + +This Modbus interface controls a ProSoft MVI46-MNET connected to a AB 5/05 SLC PLC + +The ladder logic is built in a way so the control of these Modbus registers triggers different switching operations +for microgrid testing and other experiments. + +V1.0 - Jay Johnson - 7/31/2018 +""" + +try: + import sunspec.core.modbus.client as client + import sunspec.core.util as suns_util + import binascii +except Exception as e: + print('SunSpec or binascii packages did not import!') +import time + + +class Device(object): + + def __init__(self, params=None, ts=None): + self.ts = ts + self.device = None + self.ipaddr = params.get('ipaddr') + self.ipport = params.get('ipport') + self.slave_id = params.get('slave_id') + self.reg = params.get('register') + self.group_name = None + + def param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + self.open() + + def open(self): + self.device = client.ModbusClientDeviceTCP(self.slave_id, self.ipaddr, self.ipport, timeout=5) + + def info(self): + """ Get DER device information. + + Params: + Manufacturer + Model + Version + Options + SerialNumber + + :return: Dictionary of information elements. + """ + params = {'Manufacturer': 'ProSoft', 'Model': 'MVI46-MNET'} + + # Registers: + # 0: Reset + # 1: Stop + # 2: Islanding Request + # 3: Utility Switch (button) + # 4: Diesel Switch (button) + # 5: Simulator Switch (button) + + return params + + def switch_open(self): + """ + Open the switch associated with this device + """ + self.device.write(self.reg, suns_util.u16_to_data(1)) + + def switch_close(self): + """ + Close the switch associated with this device + """ + self.device.write(self.reg, suns_util.u16_to_data(0)) + + def switch_state(self): + """ + Get the state of the switch associated with this device + """ + pass + + def reset_button(self, state=False): + if state: + self.device.write(0, suns_util.u16_to_data(1)) + else: + self.device.write(0, suns_util.u16_to_data(0)) + + def stop_button(self, state=True): + """ + Note that upon power loss, all registers reset to 0, so B3:2/6 must reset to 1 for proper operation + + :param state: bool for "press the stop button" + :return: None + """ + # Stop button is nominally high (1) and goes low (0) when pressed + if state: + self.device.write(1, suns_util.u16_to_data(1)) + else: + self.device.write(1, suns_util.u16_to_data(0)) + + def islanding_button(self, state=False): + if state: + self.device.write(2, suns_util.u16_to_data(1)) + else: + self.device.write(2, suns_util.u16_to_data(0)) + + def utility_button(self, state=False): + if state: + self.device.write(3, suns_util.u16_to_data(1)) + else: + self.device.write(3, suns_util.u16_to_data(0)) + + def diesel_button(self, state=False): + if state: + self.device.write(4, suns_util.u16_to_data(1)) + else: + self.device.write(4, suns_util.u16_to_data(0)) + + def simulator_button(self, state=False): + if state: + self.device.write(5, suns_util.u16_to_data(1)) + else: + self.device.write(5, suns_util.u16_to_data(0)) + + def press_reset(self): + self.reset_button(True) + time.sleep(0.5) + self.reset_button(False) + + def press_stop(self): + self.stop_button(False) + time.sleep(0.5) + self.stop_button(True) + + def set_default_switch_mode(self): + self.reset_button() + self.stop_button() + self.islanding_button() + self.utility_button() + self.diesel_button() + self.simulator_button() + + +if __name__ == '__main__': + + ipaddr = '10.1.2.100' + + PLC = Device(params={'ipaddr': ipaddr, 'ipport': 502, 'slave_id': 1, 'register': 0}) + PLC.config() + PLC.set_default_switch_mode() + + # PLC.stop_button(state=False) + # PLC.utility_button(False) + # PLC.diesel_button(False) + + # device = client.ModbusClientDeviceTCP(slave_id=1, ipaddr=ipaddr, ipport=502, timeout=2) + # Read Modbus registers + # reg_start = 100 + # modbus_read_length = 10 + # data = device.read(reg_start, modbus_read_length) + # for reg in range(modbus_read_length): + # data_point = suns_util.data_to_u16(data[reg*2:2+reg*2]) + # print('Register: %s = %s' % (reg+reg_start, data_point)) + # + # for i in range(4): + # # Write Modbus Register + # device.write(0, suns_util.u16_to_data(1)) + # time.sleep(2) + # device.write(0, suns_util.u16_to_data(0)) + # + # device.write(1, suns_util.u16_to_data(1)) + # time.sleep(2) + # device.write(1, suns_util.u16_to_data(0)) + # + # device.write(2, suns_util.u16_to_data(1)) + # time.sleep(2) + # device.write(2, suns_util.u16_to_data(0)) + # + # device.write(3, suns_util.u16_to_data(1)) + # time.sleep(2) + # device.write(3, suns_util.u16_to_data(0)) + # + # device.write(4, suns_util.u16_to_data(1)) + # time.sleep(2) + # device.write(4, suns_util.u16_to_data(0)) diff --git a/Lib/svpelab/device_switch_typhoon.py b/Lib/svpelab/device_switch_typhoon.py new file mode 100644 index 0000000..af229ba --- /dev/null +++ b/Lib/svpelab/device_switch_typhoon.py @@ -0,0 +1,73 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +try: + import typhoon.api.hil as cp # control panel + from typhoon.api.schematic_editor import model + import typhoon.api.pv_generator as pv +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) + +class Device(object): + + def __init__(self, params=None): + self.params = params + self.ts = self.params.get('ts') + self.name = self.params.get('name') + + def info(self): + if cp.available_contactors(): + self.ts.log('Contactors in the model: %s' % cp.get_contactors()) + else: + self.ts.log_warning('No contactors in the model.') + return 'Switch Controller Typhoon - 1.0' + + def open(self): + pass + + def close(self): + pass + + def switch_open(self): + cp.set_contactor(self.name, swControl=True, swState=False) + # e.g., self.name = 'Anti-islanding1.Grid' + + def switch_close(self): + cp.set_contactor(self.name, swControl=True, swState=True) + # e.g., self.name = 'Anti-islanding1.Grid' + + def switch_state(self): + pass + + + + diff --git a/Lib/svpelab/device_tektronix_dpo3000.py b/Lib/svpelab/device_tektronix_dpo3000.py new file mode 100644 index 0000000..a410a83 --- /dev/null +++ b/Lib/svpelab/device_tektronix_dpo3000.py @@ -0,0 +1,937 @@ +""" +Copyright (c) 2020, Sandia National Laboratories +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import time +from . import vxi11 +import numpy as np +from pylab import * +import math +from . import dataset + +DATA_POINTS = [ # 3 phase + 'TIME', + 'DC_V', + 'DC_I', + 'AC_VRMS_1', + 'AC_VRMS_2', + 'AC_VRMS_3', + 'AC_IRMS_1', + 'AC_IRMS_2', + 'AC_IRMS_3', + 'DC_P', + 'AC_S_1', + 'AC_S_2', + 'AC_S_3', + 'AC_P_1', + 'AC_P_2', + 'AC_P_3', + 'AC_Q_1', + 'AC_Q_2', + 'AC_Q_3', + 'AC_FREQ_1', + 'AC_FREQ_2', + 'AC_FREQ_3', + 'AC_PF_1', + 'AC_PF_2', + 'AC_PF_3', + 'TRIG', + 'TRIG_GRID', + 'SWITCH_LOSS_1', + 'BLOCK_LOSS_1', + 'CONDUCT_LOSS_1', + 'SWITCH_LOSS_2', + 'BLOCK_LOSS_2', + 'CONDUCT_LOSS_2', + 'DCBUS_RIPPLE_V', + 'DCBUS_V', + 'DCBUS_RIPPLE_I', + 'DCBUS_I', + 'V_OFF_1', + 'I_OFF_1', + 'V_OFF_2', + 'I_OFF_2', + 'WAVENAME' +] + + +def pf_scan(points, pf_points): + for i in range(len(points)): + if points[i].startswith('AC_PF'): + label = points[i][5:] + try: + p_index = points.index('AC_P%s' % label) + q_index = points.index('AC_Q%s' % label) + pf_points.append((i, p_index, q_index)) + except ValueError: + pass + + +class DeviceError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass + + +class Device(object): + + def __init__(self, params): + self.vx = None # tcp implementation + self.conn = None # visa implementation + self.params = params + self.comm = params.get('comm') # the communication connection type, e.g., "VISA", "TCPIP", "GPIB" + self.visa_id = params.get('visa_id') + self.ts = params.get('ts') + self.sample_interval = params.get('sample_interval') + self.save_wave = params.get('save_wave') + + self.data_points = [] + for x in range(len(DATA_POINTS)): + self.data_points.append(DATA_POINTS[x]) # don't link DATA_POINTS to self.data_points + self.channel_types = params.get('channel_types') # List + # Options: 'Switch_Current', 'Switch_Voltage', 'Bus_Voltage', 'None' + self.chan_types = {1: self.channel_types[0], + 2: self.channel_types[1], + 3: self.channel_types[2], + 4: self.channel_types[3]} + + self.vertical_scale = params.get('vertical_scale') + if self.vertical_scale is None: + self.vertical_scale = [5., 5., 5., 5.] # V/div + + self.trig_chan = params.get('trig_chan') + if self.trig_chan is None: + self.ts.log_debug('Trigger Channel is None!!') + self.trig_chan = 'Chan 4' + + self.trig_level = params.get('trig_level') + if self.trig_level is None: + self.trig_level = 20 + + self.trig_slope = params.get('trig_slope') + if self.trig_slope is None: + self.trig_slope = 'Fall' + + self.horiz_scale = params.get('horiz_scale') + if self.horiz_scale is None: + self.horiz_scale = 20e-6 # time scale s/div...full scale = 10 * scale + + self.sample_rate = params.get('sample_rate') + if self.sample_rate is None: + self.sample_rate = 2.5e9 # sets rate of sampling...total time = length / rate + + self.length = params.get('length') + if self.length is None: + self.length = '1k' # sets record length...valid values are 1k, 10k, 100k, 1M, 5M + + if self.params.get('comm') == 'VISA': + try: + # sys.path.append(os.path.normpath(self.visa_path)) + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(params.get('visa_id')) + self.conn.encoding = 'latin_1' + self.conn.write_termination = '\n' + + try: + if self.ts is not None: + self.ts.sleep(1) + else: + time.sleep(1) + except Exception as e: + time.sleep(1) + + except Exception as e: + raise Exception('Cannot open VISA connection to %s\n\t%s' % (params.get('visa_id'), str(e))) + + # clear any error conditions + self.cmd('*CLS') + self.config() + self.dType, self.bigEndian = self.get_waveform_info() + + def config(self): + self.cmd('AUTOSet EXECute') + if self.ts is not None: + self.ts.sleep(5) + else: + time.sleep(5) + opc = self.query('*OPC?') # waiting for command execution? + while opc != '1\n': + # self.ts.log(opc) + time.sleep(1) + # self.ts.log('waiting for previous command to finish...') + self.ts.log_debug('Setting vertical and horizontal scale...') + self.set_vertical_scale() + self.set_horizontal_scale() + self.set_trigger() + + # turn on all channels + for i in range(0, 4): + self.ts.log('turning on channel ' + str(i + 1) + '...') + self.cmd('SELect:CH' + str(i + 1) + ' ON') + self.cmd('ACQ:STOPA SEQ') + self.cmd('ACQUIRE:STATE RUN') # go to RUN state + # ACQuire:MODe {SAMple|PEAKdetect|HIRes|AVErage|ENVelope} + # self.cmd('ACQuire:MODe AVErage') + # self.cmd('ACQuire:MODe HIRes') + self.cmd('ACQuire:MODe SAMple') + # self.cmd('ACQuire:NUMEnv 16') # get 16 samples on average + # self.cmd('ACQuire:NUMAVg 32') + + def set_vertical_scale(self): + for i in range(0, 4): + self.ts.log('setting vertical scale for CH' + str(i + 1) + ' at ' + str(self.vertical_scale[i]) + '...') + self.cmd('CH' + str(i + 1) + ':SCAle ' + str(self.vertical_scale[i])) + if self.chan_types.get(i + 1) == 'Bus_Voltage': + self.cmd('CH' + str(i + 1) + ':POSition ' + str(-5)) + self.cmd('CH' + str(i + 1) + ':OFFset ' + str(430)) + else: + self.cmd('CH' + str(i + 1) + ':POSition ' + str(0)) + self.cmd('CH' + str(i + 1) + ':OFFSet ' + str(0)) + return self.query('CH' + str(i + 1) + ':SCAle?') + + def set_horizontal_scale(self): + self.cmd('HORizontal:RECOrdlength ' + str(self.length)) + self.cmd('HORizontal:SCAle ' + str(self.horiz_scale)) + # self.ts.log('setting number of acquired points to ', self.query(':HORIZONTAL:ACQLENGTH?') + '...') + self.cmd('HORizontal:DELay:MODe OFF') + # self.ts.log('setting sampling rate to maximum...') + + max_sample = float(self.query('ACQUIRE:MAXSAMPLERATE?')) + if max_sample < self.sample_rate: + raise DeviceError('Sample rate is greater than supported rate of %s' % max_sample) + self.cmd('HORIZONTAL:MAIN:SAMPLERATE ' + str(self.sample_rate)) + horizontal = self.query('HORizontal?') + # print(horizontal) + # self.ts.log(self.query('HORizontal:ACQLENGTH?')) + # self.ts.log(self.query('HORizontal:MAIn?')) + # self.ts.log(horizontal) + return horizontal + + def cmd(self, cmd_str): + if self.params['comm'] == 'VISA': + try: + self.conn.write(cmd_str) + except Exception as e: + raise DeviceError('DPO3000 communication error: %s' % str(e)) + + def query(self, cmd_str, binary=None): + try: + resp = '' + if self.params.get('comm') == 'VISA': + if binary: + # returns list + resp = self.conn.query_binary_values(cmd_str, datatype=self.dType, is_big_endian=self.bigEndian) + else: + # returns str + resp = self.conn.query(cmd_str) + + except Exception as e: + raise DeviceError('DPO3000 communication error: %s' % str(e)) + + return resp + + def open(self): + pass + + def close(self): + try: + if self.conn is not None: + self.conn.close() + except Exception as e: + self.ts.log_error('Could not close DPO3000: %s' % e) + finally: + self.conn = None + + def info(self): + return self.query('*IDN?').rstrip('\n\r') + + def data_capture(self, enable=True): + """ + Enable/disable data capture. + + If sample_interval == 0, there will be no autonomous data captures and self.data_sample should be used to add + data points to the capture + """ + + # self.cmd("SAVE:WAVEFORM:FILEFORMAT SPREADSHEET") + # self.cmd("SAVE:WAVEFORM:SPREADSHEET:RESOLUTION FULL") + # + # # Create directory where files will be saved + # self.cmd("FILESYSTEM:MAKEDIR \"E:/Saves\"") + pass + + def data_read(self): + """ + Return the last data sample from the data capture in expanded format. + """ + self.start_acquisition() + + wfm_sw_i = None + wfm_sw_v = None + wfm_sw_i_2 = None + wfm_sw_v_2 = None + wfm_bus_v = None + wfm_bus_i = None + times = None + for i in range(1, 5): # pull data from each channel + self.ts.log_debug('Pulling data from Channel %i' % i) + if self.chan_types.get(i) != 'None': + # self.ts.log_debug('Bus Type = %s' % self.chan_types.get(i)) + if self.chan_types.get(i) == 'Switch_Current': + times, wfm_sw_i = self.bitstream_to_analog(channel=i) + if self.chan_types.get(i) == 'Switch_Voltage': + times, wfm_sw_v = self.bitstream_to_analog(channel=i) + if self.chan_types.get(i) == 'Bus_Voltage': + times, wfm_bus_v = self.bitstream_to_analog(channel=i) + # self.ts.log(wfm_bus_v) + if self.chan_types.get(i) == 'Bus_Current': + times, wfm_bus_i = self.bitstream_to_analog(channel=i) + + # save the waveform data to a csv in the test manifest + wave_filename = None + if self.save_wave == 'Yes': + self.ts.log('Saving a .csv file of the waveform. This will take a while...') + ds = dataset.Dataset() + wave_filename = '%s_wave.csv' % time.time() + self.ts.log('Saving file: %s' % wave_filename) + ds.points.append('TIME') + if times is not None: + ds.data.append(times) + else: + ds.data.append([0, 0]) + for chan in range(0, 4): + self.ts.log_debug(chan + 1) + self.ts.log_debug(self.chan_types.get(chan + 1)) + if self.channel_types[chan] != 'None': + ds.points.append(self.channel_types[chan]) + if self.chan_types.get(chan + 1) == 'Switch_Current': + # self.ts.log_debug(wfm_sw_i) + ds.data.append(wfm_sw_i) + if self.chan_types.get(chan + 1) == 'Switch_Voltage': + # self.ts.log_debug(wfm_sw_v) + ds.data.append(wfm_sw_v) + if self.chan_types.get(chan + 1) == 'Bus_Voltage': + # self.ts.log_debug(wfm_bus_v) + ds.data.append(wfm_bus_v) + if self.chan_types.get(chan + 1) == 'Bus_Current': + # self.ts.log_debug(wfm_bus_i) + ds.data.append(wfm_bus_i) + self.ts.log_debug(wave_filename) + self.ts.log_debug(self.ts.result_file_path(wave_filename)) + ds.to_csv(self.ts.result_file_path(wave_filename)) + self.ts.result_file(wave_filename) + + v_off_1 = None + i_off_1 = None + v_off_2 = None + i_off_2 = None + if wfm_sw_i is not None and wfm_sw_v is not None: + switch_loss_energy, block_loss_energy, conduct_loss_energy, v_off_1, i_off_1 = \ + self.calc_switch_loss(time_vect=times, current=wfm_sw_i, voltage=wfm_sw_v) + else: + switch_loss_energy = None + block_loss_energy = None + conduct_loss_energy = None + + # TODO add 2nd switch loss + if wfm_sw_i_2 is not None and wfm_sw_v_2 is not None: + switch_loss_energy_2, block_loss_energy_2, conduct_loss_energy_2, v_off_2, i_off_2 = \ + self.calc_switch_loss(time_vect=times, current=wfm_sw_i, voltage=wfm_sw_v) + else: + switch_loss_energy_2 = None + block_loss_energy_2 = None + conduct_loss_energy_2 = None + + if wfm_bus_v is not None: + bus_ripple_v, bus_v = self.calc_bus_ripple(time_vect=times, data=wfm_bus_v) + self.ts.log_debug('Bus_V = %s' % bus_v) + self.ts.log_debug('Bus_Ripple_V = %s' % bus_ripple_v) + else: + bus_ripple_v = None + bus_v = None + + if wfm_bus_i is not None: + bus_ripple_i, bus_i = self.calc_bus_ripple(time_vect=times, data=wfm_bus_i) + self.ts.log_debug('Bus_I = %s' % bus_i) + self.ts.log_debug('Bus_Ripple_I = %s' % bus_ripple_i) + else: + bus_ripple_i = None + bus_i = None + + datarec = {'TIME': time.time(), + 'AC_VRMS_1': None, + 'AC_IRMS_1': None, + 'AC_P_1': None, + 'AC_S_1': None, + 'AC_Q_1': None, + 'AC_PF_1': None, + 'AC_FREQ_1': None, + 'AC_VRMS_2': None, + 'AC_IRMS_2': None, + 'AC_P_2': None, + 'AC_S_2': None, + 'AC_Q_2': None, + 'AC_PF_2': None, + 'AC_FREQ_2': None, + 'AC_VRMS_3': None, + 'AC_IRMS_3': None, + 'AC_P_3': None, + 'AC_S_3': None, + 'AC_Q_3': None, + 'AC_PF_3': None, + 'AC_FREQ_3': None, + 'DC_V': None, + 'DC_I': None, + 'DC_P': None, + 'TRIG': 0, + 'TRIG_GRID': 0, + 'SWITCH_LOSS_1': switch_loss_energy, + 'SWITCH_LOSS_2': switch_loss_energy_2, + 'BLOCK_LOSS_1': block_loss_energy, + 'BLOCK_LOSS_2': block_loss_energy_2, + 'CONDUCT_LOSS_1': conduct_loss_energy, + 'CONDUCT_LOSS_2': conduct_loss_energy_2, + 'DCBUS_RIPPLE_V': bus_ripple_v, + 'DCBUS_V': bus_v, + 'DCBUS_RIPPLE_I': bus_ripple_i, + 'DCBUS_I': bus_i, + 'V_OFF_1': v_off_1, + 'I_OFF_1': i_off_1, + 'V_OFF_2': v_off_2, + 'I_OFF_2': i_off_2, + 'WAVENAME': wave_filename} + + data = [] + # self.ts.log_debug('DATA_POINTS=%s, self.data_points=%s' % (DATA_POINTS, self.data_points)) + for chan in DATA_POINTS: + # self.ts.log_debug('chan = %s' % chan) + if chan[0:3] != 'SC_': + if datarec.get(chan) is not None: + data.append(datarec[chan]) + # self.ts.log_debug('data = %s' % datarec[chan]) + else: + data.append(None) + # self.ts.log_debug('data = NO DATA/NONE') + + return data + + def set_trigger(self): + if self.trig_chan == 'Chan 1': + chan = 'CH1' + elif self.trig_chan == 'Chan 2': + chan = 'CH2' + elif self.trig_chan == 'Chan 3': + chan = 'CH3' + elif self.trig_chan == 'Chan 4': + chan = 'CH4' + elif self.trig_chan == 'Line': + chan = 'Line' + else: + print('unknown trigger channel, assuming channel 1 for the trigger') + chan = 'Chan 1' + + self.ts.log_debug('Trigger Channel = %s' % self.trig_chan) + self.ts.log_debug('Trigger Level = %s' % self.trig_level) + self.ts.log_debug('Trigger Slope = %s' % self.trig_slope) + + self.cmd('TRIGger:A:EDGE:SOUrce ' + chan) + self.cmd('TRIGger:A:LEVel ' + str(self.trig_level)) + self.cmd('TRIGger:A:EDGE:SLOpe ' + self.trig_slope) + # TRIGger:A:EDGE:COUPling {AC|DC|HFRej|LFRej|NOISErej} + self.cmd('TRIGger:A:EDGE:COUPling HFRej') + pass + + def calc_bus_ripple(self, time_vect=None, data=None): + # number of slices in data to calculate ripple + # num_slice = 1000 + # slice_length = int(self.length) / num_slice + # rip = [] + # for n in range(0, num_slice - 1): + # start = n * slice_length + # stop = (n + 1) * slice_length + # temp = [n, num_slice, slice_length, start, stop, len(data)] + # # self.ts.log_debug(temp) + # rip.append(max(data[start:stop])-min(data[start:stop])) + # # self.ts.log_debug('Measured Ripple = %s' % rip) + # bus_rip = mean(rip) + # bus_rip = min(rip) + # bus_rip = max(data) - min(data) + bus_mag = mean(data) + + f_s = 1 / (time_vect[1] - time_vect[0]) # Hz sampling frequency + f = 1.0 # Hz + N = len(time_vect) + T = 1 / f_s + + FFT = np.fft.fft(data) + n = len(FFT) + yf = np.linspace(0.0, 1.0 / (2.0 * T), N // 2) + # print('f_s = ', f_s, f_s/N, yf[round(120/(f_s/N))], 2.0/N * np.abs(FFT[round(120/(f_s/N))]), + # sum(2.0/N * np.abs(FFT))) + subset = FFT[0:N // 2] + + # self.ts.log_debug(yf[round(110/(f_s/N)):round(130/(f_s/N))]) + # self.ts.log_debug(2.0/N * np.abs(FFT[round(110/(f_s/N)):round(130/(f_s/N))])) + # self.ts.log_debug(sum(2.0/N * np.abs(FFT[round(110/(f_s/N)):round(130/(f_s/N))]))) + + amplitude_120 = sum(2.0 / N * np.abs(FFT[round(110 / (f_s / N)):round(130 / (f_s / N))])) + pk_pk_120 = 2 * amplitude_120 + # amplitude_mppt = sum(2/N * np.abs(FFT[round(1/(f_s/N)):round(10/(f_s/N))])) + # pk_pk_mppt = 2 * sum(2/N * np.abs(FFT[round(1/(f_s/N)):round(10/(f_s/N))])) + bus_rip = pk_pk_120 + # self.ts.log_debug(pk_pk_120) + # self.ts.log_debug(bus_rip) + + self.ts.log_debug('Measured 120Hz Ripple = %s' % pk_pk_120) + self.ts.log_debug('Bus Mag = %s' % bus_mag) + # self.ts.log_debug('Max Ripple = %s' % max(rip)) + + return bus_rip, bus_mag + + def calc_switch_loss(self, time_vect=None, current=None, voltage=None): + """ + Calculate total dissipated energy (J/s) + + param: time_vect - time vector list + param: current - current list + param: voltage - voltage list + + """ + + # determine time step + dt = round(time_vect[-1] - time_vect[-2], 11) + # self.ts.log('dT = ', dT) + for i in range(1, len(time_vect)): + temp = round(time_vect[i] - time_vect[i - 1], 11) + if temp != dt: + self.ts.log_warning('Uneven time step!') + self.ts.log_warning(temp, dt) + + # need to determine I_offset and V_offset automatically + print('voltage offset') + volt_offset, volt_max = self.get_probe_offset(voltage) + print(volt_offset) + print('current offset') + curr_offset, curr_max = self.get_probe_offset(current) + print(curr_offset) + self.ts.log_debug('Voltage Offset = %s' % volt_offset) + self.ts.log_debug('Current Offset = %s' % curr_offset) + self.ts.log_debug('Voltage Max = %s' % volt_max) + + # self.ts.log('Voltage offset = %s' % volt_offset) + # self.ts.log('Current offset = %s' % curr_offset) + + # re-bias time so it always starts at 0 + start = time_vect[0] + for i in range(0, len(time_vect)): + time_vect[i] = time_vect[i] - start + + if curr_offset <= 0: + current[i] = current[i] - curr_offset + else: + current[i] = current[i] + curr_offset + if volt_offset <= 0: + voltage[i] = voltage[i] + volt_offset + else: + voltage[i] = voltage[i] - volt_offset + + # Calculate the instantaneous power + # Calculate the cumulative energy + + power = [] + block_power = [] + conduct_power = [] + + energy = [] + block_energy = [] + conduct_energy = [] + + cum_energy = [0] + cum_block_energy = [0] + cum_conduct_energy = [0] + + unknown = [] + + high = 0.90 + low = 0.10 + current_status = [] + for i in range(0, len(current)): + if voltage[i] < (low * volt_max): + power.append(0) + block_power.append(0) + conduct_power.append(current[i] * voltage[i]) + unknown.append(0) + current_status.append('conducting') + elif (high * volt_max) >= voltage[i] >= (low * volt_max): + power.append(current[i] * voltage[i]) + conduct_power.append(0) + block_power.append(0) + unknown.append(0) + current_status.append('switching') + elif voltage[i] > (high * volt_max): + power.append(0) + conduct_power.append(0) + block_power.append(current[i] * voltage[i]) + unknown.append(0) + current_status.append('blocking') + else: + power.append(0) + conduct_power.append(0) + block_power.append(0) + unknown.append(current[i] * voltage[i]) + current_status.append('unknown') + pre = 20 + for i in range(pre, len(current_status)): + if current_status[i] == 'switching' and current_status[i - 1] == 'blocking': + # print(block_power[i - pre:i - 1], power[i - pre:i - 1]) + current_status[i - pre:i - 1] = ['switching'] * len(current_status[i - pre:i - 1]) + power[i - pre:i - 1] = block_power[i - pre:i - 1] + block_power[i - pre:i - 1] = [0] * len(block_power[i - pre:i - 1]) + # print(block_power[i-pre:i-1], power[i-pre:i-1]) + else: + pass + + for i in range(len(current_status), pre, -1): + # self.ts.log_debug('i is %s' % i) + if current_status[i - 1] == 'switching' and current_status[i] == 'blocking': + # print(block_power[i:i + pre], power[i:i + pre]) + current_status[i:i + pre] = ['switching'] * len(current_status[i:i + pre]) + power[i:i + pre] = block_power[i:i + pre] + block_power[i:i + pre] = [0] * len(block_power[i:i + pre]) + # print(block_power[i:i+pre], power[i:i+pre]) + else: + pass + for i in range(0, len(current)): + energy.append(power[i] * dt) + cum_energy.append(energy[i] + cum_energy[-1]) + + block_energy.append(block_power[i] * dt) + cum_block_energy.append(block_energy[i] + cum_block_energy[-1]) + + conduct_energy.append(conduct_power[i] * dt) + cum_conduct_energy.append(conduct_energy[i] + cum_conduct_energy[-1]) + self.ts.log_debug('Average Switch Power (W) = %s' % str(mean(power))) + self.ts.log_debug('Average Conducting Power (W) = %s' % str(mean(conduct_power))) + self.ts.log_debug('Average Blocking Power (W) = %s' % str(mean(block_power))) + self.ts.log_debug('') + self.ts.log_debug('Cumulative Switch Energy (J) = %s' % str(cum_energy[-1])) + self.ts.log_debug('Switch Energy (J) per cycle (J/cycle)= %s' % str(cum_energy[-1] / time_vect[-1] * 16.66e-3)) + self.ts.log_debug('Cumulative Conducting Energy (J) = %s' % str(cum_conduct_energy[-1])) + self.ts.log_debug('Conducting Energy (J) per cycle (J/cycle)= %s' % + str(cum_conduct_energy[-1] / time_vect[-1] * 16.66e-3)) + self.ts.log_debug('Cumulative Blocking Energy (J) = %s' % str(cum_block_energy[-1])) + self.ts.log_debug('Blocking Energy (J) per cycle (J/cycle)= %s' % + str(cum_block_energy[-1] / time_vect[-1] * 16.66e-3)) + self.ts.log_debug('') + self.ts.log_debug('Switch Power (J/s) = %s' % str(cum_energy[-1] / time_vect[-1])) + self.ts.log_debug('Conducting Power (J/s) = %s' % str(cum_conduct_energy[-1] / time_vect[-1])) + self.ts.log_debug('Blocking Power (J/s) = %s' % str(cum_block_energy[-1] / time_vect[-1])) + self.ts.log_debug('') + return cum_energy[-1] / time_vect[-1], cum_block_energy[-1] / time_vect[-1], \ + cum_conduct_energy[-1] / time_vect[-1], volt_offset, curr_offset # Total dissipated energy (J/s) + + def get_probe_offset(self, data): + """ + Determine probe offset using histogram + """ + sor = sort(data) + sor_min = sor[sor <= (0.5 * sor[-1])] + sor_max = sor[sor > (0.5 * sor[-1])] + + # find min + data_gram = histogram(data, bins=np.arange(sor_min[0], sor_min[-1], (sor_min[-1] - sor_min[0]) / 500)) + loc = np.where(data_gram[0] == max(data_gram[0])) + data_offset = data_gram[1][loc][0] + # find max + data_gram = histogram(data, bins=np.arange(sor_max[0], sor_max[-1], (sor_max[-1] - sor_max[0]) / 500)) + loc2 = np.where(data_gram[0] == max(data_gram[0])) + data_max = data_gram[1][loc2][0] + # sor = sort(data) + # hgram = histogram(data, bins=np.arange(sor[0], sor[-1], float(sor[-1]-sor[0]) / 100.)) + # loc = np.where(hgram[0] == max(hgram[0][0:int(round(len(hgram[0])/2))])) + # loc2 = np.where(hgram[0] == max(hgram[0][round(len(hgram[0])/2):])) + # data_max = hgram[1][loc2][0] + # data_offset = hgram[1][loc][0] + return data_offset, data_max + + def start_acquisition(self): + # trigger a measurement + permitted_failures = 10 + while permitted_failures >= 0: + permitted_failures -= 1 + trig_state = self.query('TRIGger:STATE?').split('\n')[0] + if self.ts is not None: + self.ts.log('Scope is in ' + trig_state + ' mode...') + else: + print(('Scope is in ' + trig_state + ' mode...')) + + time.sleep(5) + + time.sleep(1) + if trig_state == 'ARMED': + if self.ts is not None: + self.ts.log('Scope is acquiring pretrigger information...') + self.ts.log('triggering...') + else: + print('Scope is acquiring pretrigger information...') + print('triggering...') + self.cmd('TRIGger') + break + elif trig_state == 'AUTO': + if self.ts is not None: + self.ts.log('Scope is in the automatic mode and acquires data even in the absence of a trigger...') + self.cmd('TRIGger:A:MODe NORMal') + else: + print('Scope is in the automatic mode and acquires data even in the absence of a trigger...') + self.cmd('TRIGger:A:MODe NORMal') + break + elif trig_state == 'READY': + if self.ts is not None: + self.ts.log('all pretrigger information has been acquired and scope is ready to accept a trigger..') + self.ts.log('triggering...') + else: + print('all pretrigger information has been acquired and scope is ready to accept a trigger..') + print('triggering...') + self.cmd('TRIGger') + break + elif trig_state == 'SAVE' or trig_state == 'SAV': + if self.ts is not None: + self.ts.log('Scope is in save mode and is not acquiring data...') + else: + print('Scope is in save mode and is not acquiring data...') + self.cmd('FPANEL:PRESS RUnstop') + elif trig_state == 'TRIGGER' or trig_state == 'TRIG': + if self.ts is not None: + self.ts.log('Scope triggered and is acquiring the post trigger information...') + else: + print('Scope triggered and is acquiring the post trigger information...') + break + else: + if self.ts is not None: + self.ts.log('unknown trigger state...') + self.ts.log('Trigger State is: %s' % trig_state) + else: + print('unknown trigger state...') + self.ts.log('Trigger State is: %s' % trig_state) + + # Start single sequence acquisition + self.cmd("ACQ:STOPA SEQ") + + def waveform(self): + """ + Return waveform (Waveform) created from last waveform capture. + """ + pass + + def bitstream_to_analog(self, channel=1): + """ + Collect data and convert channels to current/voltage values + """ + + self.cmd('DATa:SOUrce CH' + str(channel)) # setup the channel to read + + """ + Get the conversion parameters to move bit stream into voltage/current values + """ + + x_incr = float(self.query('WFMOutpre:XINcr?').split('\n')[0]) + y_mu = float(self.query('WFMOutpre:YMUlt?').split('\n')[0]) + y_offset = float(self.query('WFMOutpre:YOFf?').split('\n')[0]) + y_zero = float(self.query('WFMOutpre:YZEro?').split('\n')[0]) + + total_length = float(self.query('HORizontal:RECOrdlength?').split('\n')[0]) + """ + Get the conversion parameters to move bit stream into voltage/current values + """ + # self.ts.log(y_offset) + # self.ts.log(y_mu) + # self.ts.log(y_zero) + waveform = [] # single channel data set as list + x = [] # time vector as list + + """ + Can only transfer 1M points at a time, so if the number of points is greater than 1M, then have to break it up + """ + data = [] + + if total_length > 1e6: + num = int(math.ceil(total_length / 1e6)) + max_size = 1e6 + else: + num = 1 + max_size = total_length + + for i in range(0, num): + # self.ts.log('data start = ', str(1 + i * 1e6)) + # self.ts.log('data stop = ', str((i + 1) * 1e6)) + self.cmd('DATa:STARt ' + str(1 + i * max_size)) + self.cmd('DATa:STOP ' + str((i + 1) * max_size)) + opc = self.query('*OPC?') + while opc != '1\n': + # self.ts.log(opc) + if self.ts is not None: + self.ts.sleep(1) + else: + time.sleep(1) + # self.ts.log('waiting for previous command to finish...') + record = self.query('CURVe?', binary=True) + opc = self.query('*OPC?') + while opc != '1\n': + # self.ts.log(opc) + if self.ts is not None: + self.ts.sleep(1) + else: + time.sleep(1) + # self.ts.log('waiting for previous command to finish...') + # self.ts.log(data) + # data = data + record.split(',') + data += record + if self.ts is not None: + self.ts.sleep(2) + else: + time.sleep(2) + + lst = [int(i) for i in data] + # self.ts.log_debug(lst) + rst = [tmp == int(127) for tmp in lst] + rst_2 = [tmp == int(-127) for tmp in lst] + # self.ts.log_debug(rst) + if True in rst: + # self.ts.log(rst) + self.ts.log_warning('Positive Clipping at ' + str(len([i for i in lst if i == int(127)])) + ' of ' + + str(len(rst)) + ' elements!!! Increase Channel Scale') + if True in rst_2: + # self.ts.log(rst_2) + self.ts.log_warning('Negative Clipping at ' + str(len([i for i in lst if i == int(-127)])) + ' of ' + + str(len(rst_2)) + ' elements!!! Reduce Channel Scale') + + # self.ts.log('data length = ', len(data)) + # Formula for computing horizontal (time) point value: + # Xi= XZEro + XINcr * (i - 1) + # + # Formula for computing vertical (amplitude) point value: + # Yi= YZEro + (YMUlt * DataPoint_i) + # where: + # i is the index of a curve data point 1 based: first data point is point number 1 + # Xi is the ith horizontal value in XUNits + # Yi is the ith vertical value in YUNits + + """ + Convert data from bitstream into voltage/current values + """ + + for n in range(0, len(data)): + # data[n] = (float(data[n]) * y_mu) + y_zero + data[n] = ((float(data[n]) - y_offset) * y_mu) + y_zero + waveform.append(data[n]) + # x.append(x_zero + len(x) * x_incr) + x.append(len(x) * x_incr) + # self.ts.log('number of datapoints = ', len(waveform), i) + + return x, waveform + + def status(self): + """ + Returns dict with following entries: + 'trigger_wait' - waiting for trigger - True/False + 'capturing' - waveform capture is active - True/False + """ + pass + + def get_waveform_info(self): + self.conn.write('acquire:stopafter sequence') + self.conn.write('acquire:state on') + # dpo.query('*OPC?') + binaryFormat = self.conn.query('wfmoutpre:bn_fmt?').rstrip() + print('Binary format: ', binaryFormat) + numBytes = self.conn.query('wfmoutpre:byt_nr?').rstrip() + print('Number of Bytes: ', numBytes) + byteOrder = self.conn.query('wfmoutpre:byt_or?').rstrip() + print('Byte order: ', byteOrder) + encoding = self.conn.query('data:encdg?').rstrip() + print('Encoding: ', encoding) + if 'RIB' in encoding or 'FAS' in encoding: + dType = 'b' + bigEndian = True + elif encoding.startswith('RPB'): + dType = 'B' + bigEndian = True + elif encoding.startswith('SRI'): + dType = 'b' + bigEndian = False + elif encoding.startswith('SRP'): + dType = 'B' + bigEndian = False + elif encoding.startswith('FP'): + dType = 'f' + bigEndian = True + elif encoding.startswith('SFP'): + dType = 'f' + bigEndian = False + elif encoding.startswith('ASCI'): + raise visa.InvalidBinaryFormat('ASCII Formatting.') + else: + raise visa.InvalidBinaryFormat + return dType, bigEndian + + +if __name__ == "__main__": + import time + import ftplib + import pyvisa as visa + + params = {'comm': 'VISA'} + ip_addr = '10.1.2.87' + params['channels'] = [None, None, None, None, None] + params['visa_id'] = "TCPIP::%s::INSTR" % ip_addr + params['vertical_scale'] = [250., 250., 10., 5.] # V/div + params['horiz_scale'] = 2e-3 # time scale s/div...full scale = 10 * scale + params['sample_rate'] = 2.5e9 # sets rate of sampling...total time = length / rate + params['length'] = 1000 # sets record length...valid values are 1k, 10k, 100k, 1M, 5M + # params['channel_types'] = ['Switch_Current', 'Switch_Voltage', 'None', 'None'] + params['channel_types'] = ['Switch_Voltage', 'Bus_Voltage', 'Bus_Current', 'Switch_Current', 'None'] + params['trig_level'] = -20.4 + params['trig_chan'] = 'Chan 3' + params['trig_slope'] = 'Fall' + + das = Device(params=params) + + # Setup Acquisition + das.config() + das.set_horizontal_scale() + das.set_vertical_scale() + das.set_trigger() + + # trigger measurement + print((das.data_read())) diff --git a/Lib/svpelab/terrasas.py b/Lib/svpelab/device_terrasas.py similarity index 54% rename from Lib/svpelab/terrasas.py rename to Lib/svpelab/device_terrasas.py index 8d65201..a6b20e9 100644 --- a/Lib/svpelab/terrasas.py +++ b/Lib/svpelab/device_terrasas.py @@ -28,6 +28,8 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Questions can be directed to support@sunspec.org + +Documentation: http://www.programmablepower.com/custom-power-supply/ETS/downloads/M609155-01_revH.pdf """ import sys @@ -35,6 +37,7 @@ import socket EN_50530_CURVE = 'EN 50530 CURVE' +SVP_CURVE = 'SVP CURVE' STATUS_PROFILE_RUNNING = 64 STATUS_PROFILE_PAUSED = 128 @@ -60,8 +63,9 @@ def _cmd(self, cmd_str): self.conn.connect((self.ipaddr, self.ipport)) # print 'cmd> %s' % (cmd_str) + cmd_str = cmd_str.encode('utf-8') self.conn.send(cmd_str) - except Exception, e: + except Exception as e: raise def _query(self, cmd_str): @@ -72,14 +76,14 @@ def _query(self, cmd_str): while more_data: try: - data = self.conn.recv(self.buffer_size) + data = self.conn.recv(self.buffer_size).decode('utf-8') if len(data) > 0: for d in data: resp += d if d == '\r': more_data = False break - except Exception, e: + except Exception as e: raise TerraSASError('Timeout waiting for response') return resp @@ -92,7 +96,7 @@ def cmd(self, cmd_str): if len(resp) > 0: if resp[0] != '0': raise TerraSASError(resp) - except Exception, e: + except Exception as e: raise TerraSASError(str(e)) finally: self.close() @@ -100,7 +104,7 @@ def cmd(self, cmd_str): def query(self, cmd_str): try: resp = self._query(cmd_str).strip() - except Exception, e: + except Exception as e: raise TerraSASError(str(e)) finally: self.close() @@ -128,7 +132,7 @@ def close(self): try: if self.conn is not None: self.conn.close() - except Exception, e: + except Exception as e: pass finally: self.conn = None @@ -139,20 +143,48 @@ def curves_get(self): def curve(self, filename=None, voc=None, isc=None, vmp=None, imp=None, form_factor=None, beta_v=None, beta_p=None, kfactor_voltage=None, kfactor_irradiance=None): + curve_name = SVP_CURVE + if filename is not None: - self.cmd('CURVe:READFile "%s"\r' % (filename)) - if voc is not None and isc is not None: - self.cmd('CURVe:VIparms %s, %s\r' % (voc, isc)) - if vmp is not None and imp is not None: - self.cmd('CURVe:MPPparms %s, %s\r' % (vmp, imp)) - if form_factor is not None: - self.cmd('CURVe:FORMfactor %s\r' % (form_factor)) - if beta_v is not None and beta_p is not None: - self.cmd('CURVe:BETAparms %s, %s\r' % (beta_v, beta_p)) - if kfactor_voltage is not None and kfactor_irradiance is not None: - self.cmd('CURVe:KFactor %s, %s\r' % (kfactor_voltage, kfactor_irradiance)) - - def curve_en50530(self, tech='CSI', sim_type='STA', pmp=1000, vmp=100): + try: + self.cmd('CURVe:DELEte "%s"\r' % filename) # Must delete the curve from GUI + except Exception as e: + print(('Curve not found: %s' % e)) + self.cmd('CURVe:READFile "%s"\r' % (filename)) # Read it from disk + else: + try: + self.cmd('CURVe:DELEte "%s"\r' % SVP_CURVE) # Must delete the previous curve + except Exception as e: + print(('Curve not found: %s' % e)) + + if voc is not None and isc is not None: + self.cmd('CURVe:VIparms %s, %s\r' % (voc, isc)) + if vmp is not None and imp is not None: + self.cmd('CURVe:MPPparms %s, %s\r' % (vmp, imp)) + if form_factor is not None: + self.cmd('CURVe:FORMfactor %s\r' % (form_factor)) + + if beta_v is not None and beta_p is not None: + self.cmd('CURVe:BETAparms %s, %s\r' % (beta_v, beta_p)) + # Sets the voltage and power temperature coefficients, expressed in percent values per + # degree Kelvin. Some manufacturers report the voltage coefficient in mV/K. + # Divide by Voc to obtain a percentage. Allowed range is +1.99 to -1.99. + + if kfactor_voltage is not None and kfactor_irradiance is not None: + self.cmd('CURVe:KFactor %s, %s\r' % (kfactor_voltage, kfactor_irradiance)) + # Sets the irradiance correction factor by entering parameters V1 and E1. + # See "Photovoltaic curve > Create" for more details. The voltage must be + # equal to or less than Voc. The irradiance must be between 100 and 800 W/m2. + + import datetime + # Not possible to make new IV Curves using a name saved on the hard drive, so a new file is generated + curve_name = str(datetime.datetime.utcnow()) + curve_name = curve_name.translate(None, ':') # remove invalid characters + self.cmd('CURVe:ADD "%s"\r' % curve_name) # Save new curve to disk and add to graphic pool + + return curve_name # return IV curve name + + def curve_en50530(self, tech='CSI', sim_type='DYN', pmp=1000, vmp=100): self.cmd('CURVe:EN50530:SIMtype %s, %s\r' % (tech, sim_type)) self.cmd('CURVe:EN50530:MPPparms %s, %s\r' % (pmp, vmp)) self.cmd('CURVe:EN50530:ADD\r') @@ -187,9 +219,15 @@ def curve_get(self): return self.tsas.query('SOURce:CURVe? (@%s)\r' % (self.index)) def curve_set(self, name): - self.curve = name - self.tsas.cmd('SOURce:CURVe "%s", (@%s)\r' % (name, self.index)) + if name is not None: + self.tsas.cmd('SOURce:CURVe "%s", (@%s)\r' % (name, self.index)) + else: # if no name provided, use the latest SVP curve + self.tsas.cmd('SOURce:CURVe "%s", (@%s)\r' % (SVP_CURVE, self.index)) + # self.tsas.cmd('SOURce:IRRadiance 1000, (@%s)\r' % self.index) + # self.tsas.cmd('SOURce:TEMPerature 25, (@%s)\r' % self.index) self.tsas.cmd('SOURce:EXECute (@%s)\r' % (self.index)) + # The indicated curve is applied on the selected channels. If the name is blank, curve 0 is + # applied. Specify name "EN 50530 CURVE" to execute the EN50530 curve. def group(self, channels): self.channels = channels @@ -199,6 +237,7 @@ def irradiance_set(self, irradiance): self.irradiance = irradiance self.tsas.cmd('SOURce:IRRadiance %d, (@%s)\r' % (self.irradiance, self.index)) self.tsas.cmd('SOURce:EXECute (@%s)\r' % (self.index)) + # All previously programmed curve parameters are calculated and transferred to the PV simulator(s). def output_is_on(self): state = self.tsas.query('OUTPut:STATe? (@%s)\r' % (self.index)) @@ -251,54 +290,84 @@ def profile_start(self): def status(self): return self.tsas.query('STATus:OPERation:CONDition? (@%s)\r' % (self.index)) + def voltage_protection_level(self): + return self.tsas.query('VOLTage:PROTection:LEVel? (@%s)\r' % (self.index)) -if __name__ == "__main__": - - try: - tsas = TerraSAS(ipaddr='127.0.0.1') - # tsas = TerraSAS(ipaddr='192.168.0.196') - # tsas = TerraSAS(ipaddr='10.10.10.10') - - tsas.scan() - - tsas.reset() - - tsas.curve_en50530(pmp=3000, vmp=460) - tsas.curve('BP Solar - BP 3230T (60 cells)') + def current_protection_level(self): + return self.tsas.query('CURRent:PROTection:LEVel? (@%s)\r' % (self.index)) - tsas.profile('STPsIrradiance') - tsas.profile('Cloudy day') + def clear_protection_faults(self): + return self.tsas.cmd('OUTPut:PROTection:CLEar (@%s)\r' % (self.index)) - print 'groups =', tsas.groups_get() - print 'profiles =', tsas.profiles_get() - print 'curves =', tsas.curves_get() + def overvoltage_protection_set(self, voltage=330): + self.tsas.cmd('SOURce:VOLTage:PROTection %s, (@%s)\r' % (voltage, self.index)) + #[SOURce:]CURRent:PROTection[:LEVel] [,(@chanlist)] - channel = tsas.channels[1] - print 'is on =', channel.output_is_on() + def measurements_get(self): + """ + Measure the voltage, current, and power of the channel + :return: dictionary with power data with keys: 'DC_V', 'DC_I', 'DC_P', 'MPPT_Accuracy' + """ + meas = {'DC_V': float(self.tsas.query('MEASure:SCALar:VOLTage:DC? (@%s)\r' % self.index)), + 'DC_I': float(self.tsas.query('MEASure:SCALar:CURRent:DC? (@%s)\r' % self.index)), + 'MPPT_Accuracy': float(self.tsas.query('MEASure:SCALar:MPPaccuracy? (@%s)\r' % self.index)), + 'DC_P': float(self.tsas.query('MEASure:SCALar:POWer:DC? (@%s)\r' % self.index))} + return meas - channel.profile_set('STPsIrradiance') - channel.curve_set(EN_50530_CURVE) - channel.profile_start() - channel.output_set_on() - print 'channel curve =', channel.curve_get() - print 'channel profile =', channel.profile_get() - print 'is on =', channel.output_is_on() +if __name__ == "__main__": - time.sleep(10) - print 'is on =', channel.output_is_on() - channel.profile_abort() - channel.profile_set('Cloudy day') - channel.curve_set('BP Solar - BP 3230T (60 cells)') + try: + # tsas = TerraSAS(ipaddr='127.0.0.1') + tsas = TerraSAS(ipaddr='192.168.0.167') + # tsas = TerraSAS(ipaddr='10.10.10.10') - channel.profile_start() + tsas.scan() - print 'channel curve =', channel.curve_get() - print 'channel profile =', channel.profile_get() - print 'is on =', channel.output_is_on() + channel = tsas.channels[4] + print(channel.current_protection_level()) + print(channel.voltage_protection_level()) + print(channel.clear_protection_faults()) + + # tsas.scan() + # tsas.reset() + # + # tsas.curve_en50530(pmp=3000, vmp=460) + # tsas.curve('BP Solar - BP 3230T (60 cells)') + # + # tsas.profile('STPsIrradiance') + # tsas.profile('Cloudy day') + # + # print('groups =', tsas.groups_get()) + # print('profiles =', tsas.profiles_get()) + # print('curves =', tsas.curves_get()) + # + # channel = tsas.channels[1] + # print('is on =', channel.output_is_on()) + # + # channel.profile_set('STPsIrradiance') + # channel.curve_set(EN_50530_CURVE) + # channel.profile_start() + # channel.output_set_on() + # + # print('channel curve =', channel.curve_get()) + # print('channel profile =', channel.profile_get()) + # print('is on =', channel.output_is_on()) + # + # time.sleep(10) + # print('is on =', channel.output_is_on()) + # channel.profile_abort() + # channel.profile_set('Cloudy day') + # channel.curve_set('BP Solar - BP 3230T (60 cells)') + # + # channel.profile_start() + # + # print('channel curve =', channel.curve_get()) + # print('channel profile =', channel.profile_get()) + # print('is on =', channel.output_is_on()) tsas.close() - except Exception, e: + except Exception as e: raise - print 'Error running TerraSAS setup: %s' % (str(e)) + print('Error running TerraSAS setup: %s' % (str(e))) diff --git a/Lib/svpelab/device_wavegen_manual.py b/Lib/svpelab/device_wavegen_manual.py index 44b60bb..25c1668 100644 --- a/Lib/svpelab/device_wavegen_manual.py +++ b/Lib/svpelab/device_wavegen_manual.py @@ -33,7 +33,7 @@ class Device(object): def __init__(self, params=None): - pass + self.params = params def info(self): return 'Waveform Generator Manual - 1.0' diff --git a/Lib/svpelab/device_wt1600.py b/Lib/svpelab/device_wt1600.py new file mode 100644 index 0000000..4a0b122 --- /dev/null +++ b/Lib/svpelab/device_wt1600.py @@ -0,0 +1,355 @@ +import os +import socket +import sys +import time +from . import vxi11 +""" +data_query_str = ( +':NUMERIC:FORMAT ASCII\n' +'NUMERIC:NORMAL:NUMBER 24\n' +':NUMERIC:NORMAL:ITEM1 U,1;' +':NUMERIC:NORMAL:ITEM2 I,1;' +':NUMERIC:NORMAL:ITEM3 P,1;' +':NUMERIC:NORMAL:ITEM4 S,1;' +':NUMERIC:NORMAL:ITEM5 Q,1;' +':NUMERIC:NORMAL:ITEM6 LAMBDA,1;' +':NUMERIC:NORMAL:ITEM7 FU,1;' +':NUMERIC:NORMAL:ITEM8 U,2;' +':NUMERIC:NORMAL:ITEM9 I,2;' +':NUMERIC:NORMAL:ITEM10 P,2;' +':NUMERIC:NORMAL:ITEM11 S,2;' +':NUMERIC:NORMAL:ITEM12 Q,2;' +':NUMERIC:NORMAL:ITEM13 LAMBDA,2;' +':NUMERIC:NORMAL:ITEM14 FU,2;' +':NUMERIC:NORMAL:ITEM15 U,3;' +':NUMERIC:NORMAL:ITEM16 I,3;' +':NUMERIC:NORMAL:ITEM17 P,3;' +':NUMERIC:NORMAL:ITEM18 S,3;' +':NUMERIC:NORMAL:ITEM19 Q,3;' +':NUMERIC:NORMAL:ITEM20 LAMBDA,3;' +':NUMERIC:NORMAL:ITEM21 FU,3;' +':NUMERIC:NORMAL:ITEM22 UDC,4;' +':NUMERIC:NORMAL:ITEM23 IDC,4;' +':NUMERIC:NORMAL:ITEM24 P,4;\n' +':NUMERIC:NORMAL:VALUE?' +) +""" +""" +data_config_str = ( +':NUM:NORM:ITEM1 U,1;' +':NUM:NORM:ITEM2 I,1;' +':NUM:NORM:ITEM3 P,1;' +':NUM:NORM:ITEM4 S,1;' +':NUM:NORM:ITEM5 Q,1;' +':NUM:NORM:ITEM6 LAMBDA,1;' +':NUM:NORM:ITEM7 FU,1;' +':NUM:NORM:ITEM8 U,2;' +':NUM:NORM:ITEM9 I,2;' +':NUM:NORM:ITEM10 P,2;' +':NUM:NORM:ITEM11 S,2;' +':NUM:NORM:ITEM12 Q,2;' +':NUM:NORM:ITEM13 LAMBDA,2;' +':NUM:NORM:ITEM14 FU,2;' +':NUM:NORM:ITEM15 U,3;' +':NUM:NORM:ITEM16 I,3;' +':NUM:NORM:ITEM17 P,3;' +':NUM:NORM:ITEM18 S,3;' +':NUM:NORM:ITEM19 Q,3;' +':NUM:NORM:ITEM20 LAMBDA,3;' +':NUM:NORM:ITEM21 FU,3;' +':NUM:NORM:ITEM22 UDC,4;' +':NUM:NORM:ITEM23 IDC,4;' +':NUM:NORM:ITEM24 P,4;\n' +) +""" + +# map data points to query points +query_points = { + 'AC_VRMS': 'URMS', + 'AC_IRMS': 'IRMS', + 'AC_P': 'P', + 'AC_S': 'S', + 'AC_Q': 'Q', + 'AC_PF': 'LAMBDA', + 'AC_FREQ': 'FU', + 'DC_V': 'UDC', + 'DC_I': 'IDC', + 'DC_P': 'P' +} + + +def pf_scan(points, pf_points): + for i in range(len(points)): + if points[i].startswith('AC_PF'): + label = points[i][5:] + try: + p_index = points.index('AC_P%s' % (label)) + q_index = points.index('AC_Q%s' % (label)) + pf_points.append((i, p_index, q_index)) + except ValueError: + pass + +def pf_adjust_sign(data, pf_idx, p_idx, q_idx): + """ + Power factor sign is the opposite sign of the product of active power and reactive power + """ + pq = data[p_idx] * data[q_idx] + # sign should be opposite of product of p and q + pf = abs(data[pf_idx]) + if pq >= 0: + pf = pf * -1 + return pf + + +class DeviceError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass + + +class Device(object): + + def __init__(self, params): + self.vx = None # tcp implementation + self.conn = None # visa implementation + self.params = params + self.channels = params.get('channels') + self.visa_id = params.get('visa_id') + self.ip_addr = params.get('ip_addr') + self.ip_port = params.get('ip_port') + self.username = params.get('username') + self.password = params.get('password') + self.ts = params.get('ts') + self.data_points = ['TIME'] + self.pf_points = [] + self.buffer_size=255 + self.config_array= [] + self.b_expct=6 + + # create query string for configured channels + query_chan_str = '' + item = 0 + for i in range(1, 5): + chan = self.channels[i] + if chan is not None: + chan_type = chan.get('type') + points = chan.get('points') + if points is not None: + chan_label = chan.get('label') + if chan_type is None: + raise DeviceError('No channel type specified') + if points is None: + raise DeviceError('No points specified') + for p in points: + item += 1 + point_str = '%s_%s' % (chan_type, p) + chan_str = query_points.get(point_str) + self.config_array.append(':NUMERIC:NORMAL:ITEM%d %s,%d;' % (item, chan_str, i)) + #query_chan_str += ':NUMERIC:NORMAL:ITEM%d %s,%d;' % (item, chan_str, i) + if chan_label: + point_str = '%s_%s' % (point_str, chan_label) + self.data_points.append(point_str) + #query_chan_str += '\n:NUMERIC:NORMAL:VALUE?' + # self.query_str = ':NUMERIC:FORMAT ASCII\nNUMERIC:NORMAL:NUMBER %d\n' % (item) + query_chan_str + self.query_str = ':NUMERIC:NORMAL:VALUE?' + self.config_array.insert(0,':NUMERIC:FORMAT ASCII\nNUMERIC:NORMAL:NUMBER %d\n' % item) + pf_scan(self.data_points, self.pf_points) + if self.params.get('comm') == 'Network': + # self.vx = vxi11.Instrument(self.params['ip_addr']) + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_address = (self.ip_addr, self.ip_port) + self.conn.connect(server_address) + self.conn.settimeout(2.0) + + self.ts.log_debug('WT1600 is Connected') + + # Enter the username "anoymous" and password "". + # If the WT1600 is not configured correctly, a connection cannot be made. + + # Read the WT1600 device asking for username + resp = self._query(None) + self.ts.log_debug('WT1600 response: %s' % resp[4:len(resp)]) + + # Provide the username + resp = self.query(self.username) # Read the WT1600 device asking for password, but ignore response + self.ts.log_debug('WT1600 response: %s' % resp[4:len(resp)]) + + resp = self.query(self.password) # Read the WT1600 device asking for password, but ignore response + self.ts.log_debug('WT1600 response: %s' % resp[4:len(resp)]) # Should print a password OK message + + self.b_expct = 4 + for n in range(1,24): + resp = self.query(self.config_array[n]) # Send channel configuration + self.b_expct = 6 + resp = self.query(':NUMERIC:NORMAL?') # Read the WT1600 Channel configuration + self.ts.log_debug('WT1600 Channel Configuration: %s' % resp[4:len(resp)]) # Print Channel Configuration + + elif self.params.get('comm') == 'VISA': + try: + # sys.path.append(os.path.normpath(self.visa_path)) + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(params.get('visa_id')) + + # the default pyvisa write termination is '\r\n' which does not work with the SPS + self.conn.write_termination = '\n' + + self.ts.sleep(1) + + except Exception as e: + raise Exception('Cannot open VISA connection to %s\n\t%s' % (params.get('visa_id'), str(e))) + + # clear any error conditions + self.cmd('*CLS') + + def _cmd(self, cmd_str): + """ low-level TCP/IP socket connection to WT1600 """ + try: + if self.conn is None: + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ip_addr, self.ip_port)) + # print 'cmd> %s' % (cmd_str) + + framesize = len(cmd_str) + frame = chr(0x80) + chr(0x00) + chr((framesize >> 8) & 0xFF) + chr(framesize & 0xFF) + cmd_str + self.conn.send(frame) + + except Exception as e: + raise + + def _query(self, cmd_str): + """ low-level query to WT1600 """ + resp = '' + more_data = True + + if cmd_str is not None: + self._cmd(cmd_str) + b_recv = 0 + b_expct = self.b_expct + while b_recv < b_expct: + # try: + data = self.conn.recv(self.buffer_size) + b_recv+= len(data) + #except Exception, e: + # raise DeviceError('Timeout waiting for response') + return data + + def cmd(self, cmd_str): + if self.params['comm'] == 'Network': + try: + # self.vx.write(cmd_str) + self._cmd(cmd_str) + except Exception as e: + raise DeviceError('WT1600 communication error: %s' % str(e)) + + elif self.params['comm'] == 'VISA': + try: + # self.ts.log(self.conn.query(cmd_str)) + self.conn.sendall(cmd_str) + except Exception as e: + raise DeviceError('WT1600 communication error: %s' % str(e)) + + def query(self, cmd_str): + try: + resp = '' + if self.params.get('comm') == 'Network': + # resp = self.vx.ask(cmd_str) + resp = self._query(cmd_str).strip() + elif self.params.get('comm') == 'VISA': + resp = self.conn.query(cmd_str) + except Exception as e: + raise DeviceError('WT1600 communication error: %s' % str(e)) + + return resp + + def open(self): + pass + + def close(self): + try: + # if self.vx is not None: + # self.vx.close() + # self.vx = None + if self.conn is not None: + self.conn.close() + except Exception as e: + pass + finally: + self.conn = None + + def info(self): + return self.query('*IDN?') + + def data_capture(self, enable=True): + self.capture(enable) + + def data_read(self): + q = self.query(self.query_str) + #q = self.query(self.query_str2) + m = q[4:len(q)] + # self.ts.log(m) + data = [float(i) for i in m.split(',')] + data.insert(0, time.time()) + for p in self.pf_points: + data[p[0]] = pf_adjust_sign(data, *p) + return data + + def capture(self, enable=None): + """ + Enable/disable capture. + """ + if enable is not None: + if enable is True: + self.cmd('STAR') + else: + self.cmd('STOP') + + def trigger(self, value=None): + """ + Create trigger event with provided value. + """ + pass + + COND_RUN = 0x1000 + COND_TRG = 0x0004 + COND_CAP = 0x0001 + + def status(self): + """ + Returns dict with following entries: + 'trigger_wait' - waiting for trigger - True/False + 'capturing' - waveform capture is active - True/False + """ + cond = int(d.query('STAT:COND?',6)) + result = {'trigger_wait': (cond & COND_TRG), + 'capturing': (cond & COND_CAP), + 'cond': cond} + return result + + def waveform(self): + """ + Return waveform (Waveform) created from last waveform capture. + """ + pass + + def trigger_config(self, params): + """ + slope - (rise, fall, both) + level - (V, I, P) + chan - (chan num) + action - (memory save) + position - (trigger % in capture) + """ + + """ + samples/sec + secs pre/post + + rise/fall + level (V, A) + """ + + pass + diff --git a/Lib/svpelab/device_wt3000.py b/Lib/svpelab/device_wt3000.py new file mode 100644 index 0000000..fa2910c --- /dev/null +++ b/Lib/svpelab/device_wt3000.py @@ -0,0 +1,459 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import time +from . import vxi11 + +''' +data_query_str = ( +':NUMERIC:FORMAT ASCII\n' +'NUMERIC:NORMAL:NUMBER 24\n' +':NUMERIC:NORMAL:ITEM1 U,1;' +':NUMERIC:NORMAL:ITEM2 I,1;' +':NUMERIC:NORMAL:ITEM3 P,1;' +':NUMERIC:NORMAL:ITEM4 S,1;' +':NUMERIC:NORMAL:ITEM5 Q,1;' +':NUMERIC:NORMAL:ITEM6 LAMBDA,1;' +':NUMERIC:NORMAL:ITEM7 FU,1;' +':NUMERIC:NORMAL:ITEM8 U,2;' +':NUMERIC:NORMAL:ITEM9 I,2;' +':NUMERIC:NORMAL:ITEM10 P,2;' +':NUMERIC:NORMAL:ITEM11 S,2;' +':NUMERIC:NORMAL:ITEM12 Q,2;' +':NUMERIC:NORMAL:ITEM13 LAMBDA,2;' +':NUMERIC:NORMAL:ITEM14 FU,2;' +':NUMERIC:NORMAL:ITEM15 U,3;' +':NUMERIC:NORMAL:ITEM16 I,3;' +':NUMERIC:NORMAL:ITEM17 P,3;' +':NUMERIC:NORMAL:ITEM18 S,3;' +':NUMERIC:NORMAL:ITEM19 Q,3;' +':NUMERIC:NORMAL:ITEM20 LAMBDA,3;' +':NUMERIC:NORMAL:ITEM21 FU,3;' +':NUMERIC:NORMAL:ITEM22 UDC,4;' +':NUMERIC:NORMAL:ITEM23 IDC,4;' +':NUMERIC:NORMAL:ITEM24 P,4;\n' +':NUMERIC:NORMAL:VALUE?' +) +''' + + +# map data points to query points +query_points = { + 'AC_VRMS': 'U', + 'AC_IRMS': 'I', + 'AC_P': 'P', + 'AC_S': 'S', + 'AC_Q': 'Q', + 'AC_PF': 'LAMBDA', + 'AC_FREQ': 'FU', + 'DC_V': 'U', + 'DC_I': 'I', + 'DC_P': 'P' +} + + +def pf_scan(points, pf_points): + for i in range(len(points)): + if points[i].startswith('AC_PF'): + label = points[i][5:] + try: + p_index = points.index('AC_P%s' % (label)) + q_index = points.index('AC_Q%s' % (label)) + pf_points.append((i, p_index, q_index)) + except ValueError: + pass + +def pf_adjust_sign(data, pf_idx, p_idx, q_idx): + """ + Power factor sign is the opposite sign of the product of active power and reactive power + """ + pq = data[p_idx] * data[q_idx] + # sign should be opposite of product of p and q + pf = abs(data[pf_idx]) + if pq >= 0: + pf = pf * -1 + return pf + + +class DeviceError(Exception): + """ + Exception to wrap all das generated exceptions. + """ + pass + + +class Device(object): + + def __init__(self, params): + self.vx = None # tcp implementation + self.conn = None # visa implementation + self.params = params + self.channels = params.get('channels') + self.visa_id = params.get('visa_id') + self.ip_addr = params.get('ip_addr') + self.ip_port = params.get('ip_port') + self.username = params.get('username') + self.password = params.get('password') + self.ts = params.get('ts') + self.data_points = ['TIME'] + self.pf_points = [] + + # create query string for configured channels + query_chan_str = '' + item = 0 + for i in range(1, 5): + chan = self.channels[i] + if chan is not None: + chan_type = chan.get('type') + points = chan.get('points') + if points is not None: + chan_label = chan.get('label') + if chan_type is None: + raise DeviceError('No channel type specified') + if points is None: + raise DeviceError('No points specified') + for p in points: + item += 1 + point_str = '%s_%s' % (chan_type, p) + chan_str = query_points.get(point_str) + query_chan_str += ':NUMERIC:NORMAL:ITEM%d %s,%d;' % (item, chan_str, i) + if chan_label: + point_str = '%s_%s' % (point_str, chan_label) + self.data_points.append(point_str) + query_chan_str += '\n:NUMERIC:NORMAL:VALUE?' + + self.query_str = ':NUMERIC:FORMAT ASCII\nNUMERIC:NORMAL:NUMBER %d\n' % (item) + query_chan_str + # self.ts.log(self.query_str) # plot command string + pf_scan(self.data_points, self.pf_points) + + if self.params.get('comm') == 'Network': + # self.vx = vxi11.Instrument(self.params['ip_addr']) + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_address = (self.ip_addr, self.ip_port) + self.sock.connect(server_address) + self.sock.settimeout(2.0) + + # Enter the username "anoymous" and password "". + # If the WT3000 is not configured correctly, a connection cannot be made. + + # Read the WT3000 device asking for username + self._query(None) + + # Provide the username + resp = self.query(self.username) # Read the WT3000 device asking for password, but ignore response + self.ts.log('WT3000 response: %s' % resp) + + resp = self.query(self.password) # Read the WT3000 device asking for password, but ignore response + self.ts.log('WT3000 response: %s' % resp) # Should print a password OK message + + elif self.params.get('comm') == 'VISA': + try: + # sys.path.append(os.path.normpath(self.visa_path)) + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(params.get('visa_id')) + + # the default pyvisa write termination is '\r\n' which does not work with the SPS + self.conn.write_termination = '\n' + + self.ts.sleep(1) + + except Exception as e: + raise Exception('Cannot open VISA connection to %s\n\t%s' % (params.get('visa_id'), str(e))) + + # clear any error conditions + self.cmd('*CLS') + + def _cmd(self, cmd_str): + """ low-level TCP/IP socket connection to WT3000 """ + try: + if self.conn is None: + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ip_addr, self.ip_port)) + # print 'cmd> %s' % (cmd_str) + + framesize = len(cmd_str) + frame = chr(0x80) + chr(0x00) + chr((framesize >> 8) & 0xFF) + chr(framesize & 0xFF) + cmd_str + self.conn.send(frame) + + except Exception as e: + raise + + def _query(self, cmd_str): + """ low-level query to WT3000 """ + resp = '' + more_data = True + + if cmd_str is not None: + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\r': + more_data = False + break + except Exception as e: + raise DeviceError('Timeout waiting for response') + return resp + + def cmd(self, cmd_str): + if self.params['comm'] == 'Network': + try: + # self.vx.write(cmd_str) + self._cmd(cmd_str) + + except Exception as e: + raise DeviceError('WT3000 communication error: %s' % str(e)) + + elif self.params['comm'] == 'VISA': + try: + # self.ts.log(self.conn.query(cmd_str)) + self.conn.write(cmd_str) + except Exception as e: + raise DeviceError('WT3000 communication error: %s' % str(e)) + + def query(self, cmd_str): + try: + resp = '' + if self.params.get('comm') == 'Network': + # resp = self.vx.ask(cmd_str) + resp = self._query(cmd_str).strip() + elif self.params.get('comm') == 'VISA': + resp = self.conn.query(cmd_str) + except Exception as e: + raise DeviceError('WT3000 communication error: %s' % str(e)) + + return resp + + def open(self): + pass + + def close(self): + try: + # if self.vx is not None: + # self.vx.close() + # self.vx = None + if self.conn is not None: + self.conn.close() + except Exception as e: + pass + finally: + self.conn = None + + def info(self): + return self.query('*IDN?') + + def data_capture(self, enable=True): + self.capture(enable) + + def data_read(self): + q = self.query(self.query_str) + data = [float(i) for i in q.split(',')] + data.insert(0, time.time()) + for p in self.pf_points: + data[p[0]] = pf_adjust_sign(data, *p) + return data + + def capture(self, enable=None): + """ + Enable/disable capture. + """ + if enable is not None: + if enable is True: + self.cmd('STAR') + else: + self.cmd('STOP') + + def trigger(self, value=None): + """ + Create trigger event with provided value. + """ + pass + + COND_RUN = 0x1000 + COND_TRG = 0x0004 + COND_CAP = 0x0001 + + def status(self): + """ + Returns dict with following entries: + 'trigger_wait' - waiting for trigger - True/False + 'capturing' - waveform capture is active - True/False + """ + cond = int(d.query('STAT:COND?')) + result = {'trigger_wait': (cond & COND_TRG), + 'capturing': (cond & COND_CAP), + 'cond': cond} + return result + + def waveform(self): + """ + Return waveform (Waveform) created from last waveform capture. + """ + pass + + def trigger_config(self, params): + """ + slope - (rise, fall, both) + level - (V, I, P) + chan - (chan num) + action - (memory save) + position - (trigger % in capture) + """ + + """ + samples/sec + secs pre/post + + rise/fall + level (V, A) + """ + + pass + +if __name__ == "__main__": + + import time + import ftplib + import pyvisa as visa + + ''' + params = {'ts': None, 'visa_id': "GPIB0::13::INSTR", 'comm': "visa", 'comm': "visa"} + device = Device(params) + device.info() + ''' + visa_device = "GPIB0::13::INSTR" + rm = visa.ResourceManager() + conn = rm.open_resource(visa_device) + + print((conn.query('*IDN?'))) + + ''' + + COND_RUN = 0x1000 + COND_TRG = 0x0004 + COND_CAP = 0x0001 + + COND_RUNNING = (COND_RUN | COND_CAP) + + params = {} + + params['ip_addr'] = '192.168.0.100' + params['channels'] = [None, None, None, None, None] + + ftp = ftplib.FTP('192.168.0.100') + ftp.login() + ftp.cwd('SD-1') + try: + ftp.delete('SVP_WAVEFORM.CSV') + except: + pass + + d = Device(params=params) + print(d.info()) + + + # initialize temp directory + d.cmd('FILE:DRIV SD') + path = d.query('FILE:PATH?') + if path != ':FILE:PATH "Path = SD"': + print 'Drive not found: %s' % 'SD' + try: + d.cmd('FILE:DEL "SVP_WAVEFORM";*WAI') + print 'deleted SVP temp directory' + except: + pass + + print path + if path == ':FILE:PATH "Path = SD/SVPTEMP"': + d.cmd('FILE:DRIV SD') + try: + d.cmd('FILE:DEL "SVPTEMP";*WAI') + except: + pass + print 'deleted SVP temp directory' + d.cmd('FILE:MDIR "SVPTEMP";*WAI') + d.cmd('FILE:CDIR "SVPTEMP"') + path = d.query('FILE:PATH?') + if path != ':FILE:PATH "Path = SD/SVPTEMP"': + print 'Error creating SVP temp directory: %s' % path + + # capture waveform + # POS 50? + d.cmd('TRIG:MODE SING;HYST LOW;LEV 6.00000E-03;SLOP FALL;SOUR P2') + print d.query('TRIG:MODE?') + print d.query('TRIG:SIMP?') + print d.query('ACQ?') + d.cmd('ACQ:CLOC INT; COUN INF; MODE NORM; RLEN 250000') + print d.query('ACQ?') + d.cmd('TIM:SOUR INT; TDIV 500.0E-03') + print d.query('TIM?') + d.cmd(':STAR') + running = True + while running: + cond = int(d.query('STAT:COND?')) + if cond & COND_RUNNING == COND_RUNNING: + print 'still waiting (%s) ...\r' % cond, + time.sleep(1) + else: + running = False + d.cmd(':STOP') + + # save waveform + d.cmd('FILE:SAVE:ANAM OFF;NAME "svp_waveform"') + print 'saving' + d.cmd('FILE:SAVE:ASC:EXEC') + + # transfer waveform + + print d.query('waveform:length?') + print d.query('waveform:format?') + print d.query('waveform:trigger?') + print d.query('WAV:FORM?') + print d.query('WAV:SRAT?') + print d.query('status:condition?') + d.cmd('FILE:DRIV USB,0') + d.cmd('FILE:CDIR "SVPWAV"') + d.cmd('FILE:DEL "SVPWAV"') + print d.query('FILE:PATH?') + d.cmd('FILE:DRIV USB,0') + print d.query('FILE:PATH?') + d.cmd('FILE:DEL "SVPWAV"') + ''' + # d.cmd('FILE:MDIR "SVPWAV"') + + diff --git a/Lib/svpelab/dewenetcontroller/__init__.py b/Lib/svpelab/dewenetcontroller/__init__.py new file mode 100644 index 0000000..90268c2 --- /dev/null +++ b/Lib/svpelab/dewenetcontroller/__init__.py @@ -0,0 +1,47 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" + + +""" DeweNetController + +Package Description + +""" +# pylint disable=F0401 +# flake8: noqa + +__version__ = '1.0.3' + +__all__ = ['dewenetcontroller'] + +from .dewenetcontroller import DeweNetController +from .dewenet_client import DeweNetClientException diff --git a/Lib/svpelab/dewenetcontroller/dewenet_client.py b/Lib/svpelab/dewenetcontroller/dewenet_client.py new file mode 100644 index 0000000..c6f6ddf --- /dev/null +++ b/Lib/svpelab/dewenetcontroller/dewenet_client.py @@ -0,0 +1,956 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" + + + + + + + + + + +"""DeweNetControllerClient module for remote control purposes of the DeweSoft + +This module implements the remote control for the communication with the +DeweSoft measurement software. Therefore the class DeweNetControllerClient is +used. This module also implements the used exceptions. + + +Command reference (DEWESoft NET protocol version 4) from DeweSoft Net Interface +Manual + +Each command has to have New line suffix (0x13 + 0x10). Commands in brackets +can only be sent in control mode. + ++-------------------+----------------------------------------------------------+ +| Command | Description | ++===================+==========================================================+ +| GETVERSION | returns DEWESoft version | ++-------------------+----------------------------------------------------------+ +| GETINTFVERSION | returns DEWESoft NET protocol version | ++-------------------+----------------------------------------------------------+ +| GETDATETIME | returns current time on measurement unit | ++-------------------+----------------------------------------------------------+ +| GETMODE | returns current operation mode (control or view) | ++-------------------+----------------------------------------------------------+ +| SETMODE mode | sets operation mode; | +| +-------------------+--------------------------------------+ +| | mode parameter: | 0 - view mode | +| | +--------------------------------------+ +| | | 1 - control mode | ++-------------------+-----------------------+----------------------------------+ +| SETMASTERMODE mode| sets clock mode of the devices, used for synchronize | +| | several devices at the same time | +| +-------------------+--------------------------------------+ +| | mode parameter: | 0 - standalone (if only one system is| +| | | used | +| | | 1 - clock master system (clock is | +| | | output from this system to the | +| | | slaves - only one!) | +| | | 2 - clock slave mode (clock will be | +| | | received from a master system) | ++-------------------+-------------------+--------------------------------------+ +| SETSAMPLERATE | sets sampling rate | +| samplerate +---------------------+------------------------------------+ +| | samplerate parameter| sample rate in Hz | ++-------------------+---------------------+------------------------------------+ +| GETSAMPLERATE | reads current sample rate | ++-------------------+----------------------------------------------------------+ +| LISTUSEDCHS | lists all used channels | ++-------------------+----------------------------------------------------------+ +| PREPARETRANSFER | sends a list of channels for live capture. Channels can | +| | only be selected from used channel syntax: | +| | | +| | :: | +| | | +| | /stx preparetransfer | +| | ch 0 | +| | . | +| | . | +| | ch x | +| | /etx | +| | | ++-------------------+----------------------------------------------------------+ +| STARTTRANSFER | requests DEWESoft to connect to port 'portno' and feed | +| portno filename | data to client | +| +---------------------+------------------------------------+ +| | portno parameter: | TCP port number on client computer | ++-------------------+---------------------+------------------------------------+ +| STOPTRANSFER | stops transfer | ++-------------------+----------------------------------------------------------+ +| STARTTRIGTRANSFER | requests DEWESoft to connect to port 'portno' and feed | +| portno | last trigger data to client | +| +---------------------+------------------------------------+ +| | portno parameter: | TCP port number on client computer | ++-------------------+---------------------+------------------------------------+ +| STARTACQ | start acquisition - measure (more suitable name would be | +| | STARTMEASURE) | ++-------------------+----------------------------------------------------------+ +| STOP |stop acquisition / leave setup mode and go to start screen| ++-------------------+----------------------------------------------------------+ +| STARTSTORE | starts storing, also starts acquisition if not yet | +| filename | started | ++-------------------+----------------------------------------------------------+ +| SETSTORING status | sets storing on or off on measurement unit | +| +---------------------+------------------------------------+ +| | status parameter: | ON - remote storing on | +| + +------------------------------------+ +| | | OFF - remote storing off | ++-------------------+---------------------+------------------------------------+ +| ENTERSETUP | enter setup mode / start acquisiton in setup mode | ++-------------------+----------------------------------------------------------+ +| ISACQUIRING | returns 'Yes' if acquisition is in progress (measure or | +| | setup), otherwise 'No' | ++-------------------+----------------------------------------------------------+ +| ISSETUPMODE | returns 'Yes' if in setup mode, otherwise 'No' | ++-------------------+----------------------------------------------------------+ +| ISSTORING |returns 'Yes' if in storing is in progress, otherwise 'No'| ++-------------------+----------------------------------------------------------+ +| ISMEASURING | returns 'Yes' if acquisition is in progress (measure), | +| | otherwise 'No' | ++-------------------+----------------------------------------------------------+ +| GETSTATUS |returns DEWESoft status (measure/analyse mode, clock mode)| ++-------------------+----------------------------------------------------------+ +| SETFULLSCREEN | sets or clears full screen mode of DEWESoft | +| status +---------------------+------------------------------------+ +| | status parameter: | 1 - full screen on | +| + +------------------------------------+ +| | | 0 - full screen off | ++-------------------+---------------------+------------------------------------+ +| SETUP CONNECT | sets DEWESoft to full screen setup mode. | +| | Suitable for VNC remote setup of DEWESoft | ++-------------------+----------------------------------------------------------+ +| SETUP DISCONNECT | cancels setup full screen mode | ++-------------------+----------------------------------------------------------+ +| DISPLAY START | sets DEWESoft to full screen display setup mode. | +| | Suitable for VNC remote setup of DEWESoft displays | ++-------------------+----------------------------------------------------------+ +| DISPLAY STOP | cancels display setup mode | ++-------------------+----------------------------------------------------------+ +| LOADSETUP filename| loads a setup; filename parameter: setup file stored on | +| | measurement unit | ++-------------------+----------------------------------------------------------+ +| SAVESETUP filename| saves a setup; filename parameter: setup file to be | +| | stored on measurement unit | ++-------------------+----------------------------------------------------------+ +| NEWSETUP | clears current DEWESoft setup | ++-------------------+----------------------------------------------------------+ +| SETSCREENSIZE | sets DEWESoft window size in pixels | +| screensize +-----------------------+----------------------------------+ +| | screensize parameter: | XsizexYSize - sets window size to| +| | | Xsize x Ysize (i.e. 640x480) | +| +-----------------------+----------------------------------+ +| | | max - maximizes window size | ++-------------------+-----------------------+----------------------------------+ + +TODO implement automatic start of DeweSoft instance + If the DeweSoft instance isn't already started and no process is running, + than automatically start the DeweSoft using an absolute start path. +""" + + +from io import StringIO +import logging +import socket + +from datetime import datetime + +from .dewenet_data import DeweChannelInfo + + +def dt_now(): + """Helper method for getting the timestamp + + Will be necessary for testing + + Returns: + datetime: current time + """ + return datetime.now() + + +class DeweNetClientException(Exception): + """Base exception raised for errors in the DeweNetClient module""" + + def __init__(self, *args, **kwargs): + Exception.__init__(self, *args, **kwargs) + + +class DeweNetControllerClient(object): + """Client for Communication with DeweSoft. + + The DeweNetControllerClient class implements the necessary functionality + for controlling the DeweSoft. Thereby the NET-Plugin for DeweSoft must be + registered and the Slave Mode of the DeweSoft program must be activated + (Settings-Hardware Setup-NET-Computer role in NETwork -> Slave measurement + unit) + + The class uses a TCP-client that connects to an open port of the DeweSoft + (usually 8999) + + Example: + + :: + + deweController = DeweNetControllerClient() + deweController.connect_to_Dewe('127.0.0.1',8999) + + print "GetSampleRate",deweController.dewe_get_samplerate() + print "ISAquiring",deweController.dewe_is_acquiring() + print "GetMode",deweController.dewe_get_mode() + deweController.dewe_load_setupfile( + "C:\\DATA\\Cotevos\\EVTestStand\\EvTestStand.d7s") + deweController.dewe_list_used_channels() + deweController.dewe_start_acquisition() + time.sleep(10) + deweController.dewe_stop() + deweController.close_Dewe_Connection() + + + Attributes: + EXP_INTF_VERSION (int): Definition of implemented protocol + version. If other revisions are used, please check the + communication flow for changes. + + _socket (socket): The TCP client socket for communication with the TCP + Server of the DeweSoft Slave device + + available_channels (dict): After loading a setup file of the DeweSoft + (function dewe_load_setupfile() ) the channels can be read from + DeweSoft by using dewe_list_used_channels(). + This dictionary contains a list of 'DeweChannel' classes with the + name of the channel as key. Therefore different settings of the + channel are stored (see DeweChannel documentation) + """ + + EXP_INTF_VERSION = 31 + """Interface version that is used during development. + + The client is tested against this protocol version. + """ + + def __init__(self, client_socket=None, logger=None): + """Default constructor + + Args: + client_socket (socket.socket, optional): Socket for connecting to + DeweSoft (usually a TCP socket) + logger (logging.logger, optional): Sets the logger + """ + self._logger = logger or logging.getLogger(__name__) + self._socket = client_socket or socket.socket( + socket.AF_INET, socket.SOCK_STREAM) + self.available_channels = dict() + self._logger.debug("Initialize DeweNetController Client") + + def connect_to_dewe(self, dewe_ip='127.0.0.1', dewe_port=8999): + """Connect to the DeweSoft Net interface + + The function must be called after creation of the + DeweNetControllerClient. It will connect to a running instance off + DeweSoft on the Host computer and reads the interface version and the + version of the DeweSoft. + + Args: + dewe_ip (str, optional): IP address of the computer with running + DeweSoft + dewe_port (int, optional): Open port of the DeweSoft client, + usually 8999 + + Returns: + list: dewe_interface_version, dewe_version + dewe_interface_version (int): version of the Dewe-Net interface + dewe_version (str): version of the connected DeweSoft instance + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + self._logger.info("Connect to DeweSoft at {}: {}".format(dewe_ip, + dewe_port)) + dewe_ip = dewe_ip.encode("ascii") + self._socket.connect((dewe_ip, dewe_port)) + + # get first response after successful connection + con_respmsg = self._dewe_read_response()[0] + self._logger.debug("Response: '{}'".format(con_respmsg)) + + if con_respmsg.startswith("+CONNECTED"): + self._logger.info("Connection successfully opened.") + else: + raise DeweNetClientException( + "connect_to_Dewe", + "Unkown response received from DeweSoft : " + con_respmsg) + + dewe_interface_version = self._dewe_read_interface_version() + dewe_version = self._dewe_read_version() + self._logger.info("Interface Version: {} Version: {}".format( + dewe_interface_version, dewe_version)) + return dewe_interface_version, dewe_version + + def _dewe_read_interface_version(self): + """Read the interface version of the connected DeweSoft. + + This helper function reads the interface version of the connected + DeweSoft and stores the value in the attribute + '_dewe_interface_version'. + + Returns: + int: interface version read from DeweSoft + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + response = self._dewe_request_control_message("GETINTFVERSION")[0] + + if response.startswith("+OK"): + intf_version = int(response.replace("+OK ", "")) + + if intf_version != DeweNetControllerClient.EXP_INTF_VERSION: + self._logger.warn( + "Used Interface with Version '{0}'" + " doesn't match expected one '{1}'.".format( + intf_version, + DeweNetControllerClient.EXP_INTF_VERSION)) + else: + self._logger.debug( + "Used Interface with Version '{0}' matches expected one " + "'{1}'.".format( + intf_version, + DeweNetControllerClient.EXP_INTF_VERSION)) + + return intf_version + + else: + raise DeweNetClientException( + "dewe_read_interface_version", + "Error reading interface version: '{}'".format(response)) + + def _dewe_read_version(self): + """Read the version of the connected DeweSoft + + This helper function reads the version of the connected DeweSoft and + stores the value in the attribute '_dewe_version'. + + Returns: + str: Version string read from DeweSoft + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + response = self._dewe_request_control_message("GETVERSION")[0] + + if not response.startswith("+OK"): + raise DeweNetClientException( + "dewe_read_version", + "Error reading version: '{}'".format(response)) + + return response.replace("+OK ", "") + + def disconnect_from_dewe(self): + """Closes the connection to the DeweSoft + + """ + self._logger.info("Close DeweNetControllerClient") + self._socket.close() + + def dewe_get_datetime(self): + """Read the current time on the measurement device + + Returns: + datetime: Current datetime read from DeweSoft + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + response = self._dewe_request_control_message("GETDATETIME")[0] + if not response.startswith("+OK"): + raise DeweNetClientException( + "_dewe_get_dateTime: Can't " + "convert received message to datetime", response) + + response = response.replace("+OK", "").strip() + return datetime.strptime(response, "%d.%m.%Y %H:%M:%S") + + def dewe_set_mode(self, mode=False): + """Sets the operation mode of the DeweSoft + + Args: + mode (bool,optional): Mode of the DeweSoft + False - Set to View Mode + True - Set to Control Mode + + Returns: + bool: True - DeweSoft is in control mode + False - DeweSoft is in view mode + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + comm_mode = 1 if mode else 0 # generate argument for request + + response = self._dewe_request_control_message( + "SETMODE " + str(comm_mode))[0] + + if not response.startswith("+OK"): + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + return mode + + def dewe_get_mode(self): + """Read the current mode of the DeweSoft. + + Returns: + bool: True - DeweSoft is in control mode + False - DeweSoft is in view mode + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + response = self._dewe_request_control_message("GETMODE")[0] + + if response.startswith("+OK"): + response = response.split(" ") + return int(response[2]) == 1 + + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_start_acquisition(self): + """Start the acquisition (measurement) on the DeweSoft + + Returns: + time: current time of measurement start + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + if not self.dewe_get_mode(): + self.dewe_set_mode(True) # Set to control mode + + response = self._dewe_request_control_message("STARTACQ")[0] + if response.startswith("+OK"): + return dt_now() + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_start_store(self, filename): + """Start the storing function and the acquisition (if not already + running) on the DeweSoft + + Args: + filename (str): Filename and path of the storage file on local + DeweSoft + + Returns: + time: current time of measurement start + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + + if not self.dewe_get_mode(): + self.dewe_set_mode(True) # Set to control mode + + response = self._dewe_request_control_message( + "STARTSTORE " + filename)[0] + if response.startswith("+OK"): + return dt_now() + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error starting storage on DeweSoft: '{}'".format(response)) + + def dewe_set_storing(self, storing=True): + """Start storing mode of the DeweSoft + + Sets the Mode for the control option of the DEWE connection + + Args: + storing (bool): False - Not storing + True - Store + + Returns: + bool: mode of storing + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + if not self.dewe_get_mode(): + self.dewe_set_mode(True) # Set to control mode + + comm_storing = "ON" if storing else "OFF" + + response = self._dewe_request_control_message( + "SETSTORING " + comm_storing)[0] + + if response.startswith("+OK"): + return storing + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_stop(self): + """Stop the acquisition (measurement) and/or storing on the DeweSoft + + Returns: + time: current time of measurement start + Raises: + DeweNetClientException: If an error is occured during communication. + """ + if not self.dewe_get_mode(): + self.dewe_set_mode(True) # Set to control mode + + response = self._dewe_request_control_message("STOP")[0] + if response.startswith("+OK"): + return dt_now() + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_is_acquiring(self): + """Get actual state of acquisition + + Returns: + bool: True, if DeweSoft is in acquisition mode, otherwise False + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + return self._dewe_get_bool_message("ISACQUIRING") + + def dewe_is_setup_mode(self): + """Get actual state of setup mode + + Returns: + bool: True, if DeweSoft is in setup mode, otherwise False + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + return self._dewe_get_bool_message("ISSETUPMODE") + + def dewe_is_storing(self): + """Get actual state of storing + + Returns: + bool: True, if DeweSoft is in storing mode, otherwise False + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + return self._dewe_get_bool_message("ISSTORING") + + def dewe_is_measuring(self): + """Get actual state of acquisition + + Returns: + bool: True, if DeweSoft is in acquisition mode, otherwise False + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + return self._dewe_get_bool_message("ISMEASURING") + + def dewe_get_status(self): + """Get actual status of DeweSOft + + Returns: + str: State information of DeweSoft (e.g. Response Mode: Measure, + Acquiring; Clock mode: Standalone) + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + response = self._dewe_request_control_message("GETSTATUS")[0] + if response.startswith("+OK"): + return response.replace("+OK", "").strip() + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_load_setupfile(self, filename): + """Loads a setup file stored on the DeweSoft computer + + Args: + filename (str): Full Filename with path of the setup file to be + loaded + Returns: + str: Response from DeweSoft + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + if not self.dewe_get_mode(): + self.dewe_set_mode(True) # Set to control mode + + response = self._dewe_request_control_message( + "LOADSETUP " + filename)[0] + if response.startswith("+OK"): + return response.replace("+OK", "").strip() + else: + raise DeweNetClientException( + "dewe_set_mode", + "Error setting mode of DeweSoft: '{}'".format(response)) + def dewe_set_samplerate(self, samplefrequency = None): + """Writes the sample rate of the DeweS + oft + + Returns: + int: Sample Rate of DeweSoft in Hz + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + if samplefrequency: + response = self._dewe_request_control_message( + "SETSAMPLERATE " + str(samplefrequency))[0] + #response = self._dewe_request_control_message("GETSAMPLERATE")[0] + if response.startswith("+OK"): + self._logger.info(response) + response = response.replace("+OK", "").strip() + self._logger.info(response) + response = response[response.find('<') + 1:response.find('>')] + self._logger.info(response) + return int(response) + + else: + self._logger.info(response) + raise DeweNetClientException( + "dewe_set_samplerate", + "Can't set samplerate from DeweSoft: '{}'".format(response)) + else: + return None + + + + def dewe_get_samplerate(self): + """Read the actual sample rate of the DeweSoft + + Returns: + int: Sample Rate of DeweSoft in Hz + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + response = self._dewe_request_control_message("GETSAMPLERATE")[0] + if response.startswith("+OK"): + return int(response.replace("+OK", "").strip()) + else: + raise DeweNetClientException( + "dewe_get_samplerate", + "Can't read samplerate from DeweSoft: '{}'".format(response)) + + def dewe_list_used_channels(self): + """Read all available channels from DeweSoft with its parameters + + This function reads all available channels from the DeweSoft and stores + it in the available_channels list. Then it is possible to get these + values for further work. (using client.available_channels.keys()) + """ + response = self._dewe_request_control_message("LISTUSEDCHS") + + for line in response: + element = line.split("\t") + + if len(element) > 22: + channel = DeweChannelInfo(channel_number=element[2], + name=element[3], + unit=element[5], + samplerate_divider=element[6], + measurement_type=element[8], + sample_data_type=element[9], + buffer_size=element[10], + custom_scale=element[11], + custom_offset=element[12], + scale_raw_data=element[13], + offset_raw_data=element[14], + description=element[15], + settings=element[16] + " " + element[19], + range_min=element[17], + range_max=element[18], + value_min=element[21], + value_max=element[22], + value_act=(element[23] + if len(element) > 23 else 0.0)) + self.available_channels[channel.name] = channel + elif len(element) > 18: + + channel = DeweChannelInfo(channel_number=element[2], + name=element[3], + unit=element[5], + samplerate_divider=element[6], + measurement_type=element[8], + sample_data_type=element[9], + buffer_size=element[10], + custom_scale=element[11], + custom_offset=element[12], + scale_raw_data=element[13], + offset_raw_data=element[14], + description=element[15], + settings=element[16] + " " + element[19], + range_min=element[17], + range_max=element[18], + value_min=0.0, + value_max=0.0, + value_act=0.0) + self.available_channels[channel.name] = channel + else: + raise DeweNetClientException( + "Error reading channel", + "Channel {} hasn't enough elements".format( + element[3] if len(element) > 3 else "unknown")) + + def dewe_read_last_values(self): + """Read last values from DeweSoft + + This method uses the client interface to read current values from + DeweSoft. + This method can be used as a fallback solution to read values cyclic. + + Returns: + list: List of tuples containing all DeweSoft channels + tuple: (ch_number,ch_name,value) + ch_number (int): number of DeweSoft channel + ch_name (str): Name of the channel + value (float): Last value of the channel + """ + response = self._dewe_request_control_message("LISTUSEDCHS") + channels = list() + for line in response: + element = line.split("\t") + if len(element) > 23: + channel = (int(element[2]), element[3], float(element[23])) + channels.append(channel) + del channel + return channels + + def dewe_prepare_transfer(self, channel_list): + """Transmit a list of channels, which you want to be automatically + transmitted by DeweSoft. + + This function must be called before the `dewe_start_transfer()` is + called to rightly configure the DeweSoft communication. + + Args: + channel_list (list): List of channels names (order will be taken + into account by transfering data values)This argument must be + a list of string containing the names of the channels + + Example: + [r'Power_AC_Netz/U_rms_L1',r'Power_AC_Netz/U_rms_L2', + r'Power_AC_Netz/U_rms_L3'] + Raises: + DeweNetClientException: If the channels can't be prepared + """ + request = "/stx PREPARETRANSFER\r\n" + for channel in channel_list: + request += "ch {}\r\n".format(self.available_channels[ + channel].channel_number) + request += "/etx\r\n" + + response = self._dewe_request_control_message(request)[0] + + if not response.startswith("+OK"): + self._logger.debug(response) + raise DeweNetClientException( + "dewe_prepare_transfer", + "Can't prepare channels for transfer: '{}'".format(response)) + + def dewe_start_transfer(self, port_number): + """Start the transfer of values from DeweSoft to the + DeweNetControllerServer + + Args: + port_number (int): Port number of the client, which will be used + from the `DeweNetControllerServer` + Raises: + DeweNetClientException: If the transfer can't be started + """ + response = self._dewe_request_control_message( + "STARTTRANSFER {}".format(port_number))[0] + + if not response.startswith("+OK"): + raise DeweNetClientException( + "dewe_start_transfer", + "Error setting mode of DeweSoft: '{}'".format(response)) + + def dewe_init_start_transfer(self, port_number, channel_list): + """Combination of the prepare_transfer and the start transfer command + + Args: + port_number (int): Port number of the client, which will be used + from the `DeweNetControllerServer` + channel_list (list): List of channels names (order will be taken + into account by transfering data values)This argument must be + a list of string containing the names of the channels + Raises: + DeweNetClientException: If the transfer can't be started + """ + self.dewe_prepare_transfer(channel_list) + self.dewe_start_transfer(port_number) + + def dewe_start_trigger_transfer(self, port_number): + """Start the data transfer and get the already last stored values from + DeweSoft + + Args: + port_number (int): Port number of the client, which will be used + from the `DeweNetControllerServer` + + Raises: + DeweNetClientException: If the transfer can't be started + """ + response = self._dewe_request_control_message( + "STARTTRIGTRANSFER " + str(port_number))[0] + + if not response.startswith("+OK"): + raise DeweNetClientException( + "dewe_start_trigger_transfer", + "Can't start trigger transfer: '{}'".format(response)) + + def dewe_stop_transfer(self): + """Stops an actual running transmission from DeweSoft + + Raises: + DeweNetClientException: If the transfer can't be stopped + """ + response = self._dewe_request_control_message("STOPTRANSFER")[0] + + if not response.startswith("+OK"): + raise DeweNetClientException( + "dewe_stop_transfer", + "Error stopping transfer of DeweSoft: '{}'".format(response)) + + def _dewe_request_control_message(self, request): + """Sends a request to the Dewesoft and waits for a response. + + Args: + request (str): Request string of the command for DeweSoft + communication. + Returns: + str: Response message + + Raises: + DeweNetClientException: If an error is occured during communication. + """ + if not request.endswith("\r\n"): + request = request + "\r\n" + + self._socket.sendall(request.encode()) + self._logger.debug("Request: '" + request.replace("\r\n", "") + "'") + + response = self._dewe_read_response() + + if not response: + raise DeweNetClientException("dewe_request_control_message", + "No response received from DeweSoft.") + + self._logger.debug("Response: '{}'".format(response)) + return response + + def _dewe_read_response(self): + """Read the Response of the DeweSoft message + + This function receives a single line response or a multiline response + from DeweSoft + + Returns: + str: Response message striped by end delimiter + """ + response = self._readlines() + + if response[0].startswith("+STX"): + while True: + if response[-1].startswith("+ETX"): + return response[1:-1] + response.extend(self._readlines()) + else: # single line response + return response + + def _readlines(self, delimiter="\r\n"): + """Read lines from socket. + + Args: + delimiter (str): Delimiter of a line + + Returns: + list: A list of lines with removed line delimiter + """ + eol = delimiter[-1:].encode() # last character + + buff = StringIO() + while True: + data = self._socket.recv(1024) + buff.write(data.decode()) + if data.endswith(eol): + break + + return_lines = buff.getvalue().splitlines() + return [string.strip() for string in return_lines] + + def _dewe_get_bool_message(self, request): + """Read a bool value from DeweSoft. + + Args: + request (str): Request that is sent. + + Returns: + bool: Response as bool value + + Raises: + DeweNetClientException: if an error during request occurs. + """ + response = self._dewe_request_control_message(request)[0] + response = str(response) + + if isinstance(response, str) and response.startswith("+OK"): + response = response.split(" ") + return response[1].upper() == "YES" + else: + raise DeweNetClientException( + 'get_bool_message', + "Error reading bool value from DeweSoft: '{}'".format(response)) diff --git a/Lib/svpelab/dewenetcontroller/dewenet_data.py b/Lib/svpelab/dewenetcontroller/dewenet_data.py new file mode 100644 index 0000000..fc98723 --- /dev/null +++ b/Lib/svpelab/dewenetcontroller/dewenet_data.py @@ -0,0 +1,560 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" + + + + + + + + + + + + + +""" Storage Classes for a DeweSoft Channel + +This module contains the classes which are used for storage and organisation of +Dewe Soft channel, which are read by the Net interface. + + +Channel information (From DeweSoft Net Interface Manual) +============================================================== + +Binary data delivered to client are always raw binary data. To get information +about sample data type, scaling and other important info of every channel client +should send 'LISTUSEDCHS' command. Response contains following info for each +channel separated by tab character + ++-------------+-----------------------------------------+---------------------+ +| Data | Description | Data type | ++=============+=========================================+=====================+ +| CH | Fixed string | | ++-------------+-----------------------------------------+---------------------+ +| Number | Consequent channel number | Integer number | ++-------------+-----------------------------------------+---------------------+ +| Name | Channel name | Text | ++-------------+-----------------------------------------+---------------------+ +| Unit | Measure unit | Text | ++-------------+-----------------------------------------+---------------------+ +| Samplerate | Divider for sync channel | Integer number or | +| divider +-----------------------------------------+ text | +| | 'Async' for async channels, | | +| +-----------------------------------------+ | +| | 'SingleValue' for single value channels | | ++-------------+-----------------------------------------+---------------------+ +| Measurement | Defines channel meaning | Integer number | +| type | | | ++-------------+-----------------------------------------+---------------------+ +| Sample data | 0 - 8 bit unsigned integer | | +| type +-----------------------------------------+ | +| | 1 - 8 bit signed integer | | +| +-----------------------------------------+ | +| | 2 - 16 bit signed integer | | +| +-----------------------------------------+ | +| | 3 - 16 bit unsigned integer | | +| +-----------------------------------------+ | +| | 4 - 32 bit signed integer | | +| +-----------------------------------------+ | +| | 5 - Single floating point (32bit) | | +| +-----------------------------------------+ | +| | 6 - 64 bit signed integer | | +| +-----------------------------------------+ | +| | 7 - Double floating point (64 bit) | | ++-------------+-----------------------------------------+---------------------+ +| Buffer size | Buffer size for data | Integer number | ++-------------+-----------------------------------------+---------------------+ +| Custom scale| Custom scale after amplifier | Float number | ++-------------+-----------------------------------------+---------------------+ +|Custom offset| Custom offset after amplifier | Float number | ++-------------+-----------------------------------------+---------------------+ +| Scale raw | Scale for raw data | Float number | +| data | | | ++-------------+-----------------------------------------+---------------------+ +| Offset raw | Offset for raw data | Float number | +| data | | | ++-------------+-----------------------------------------+---------------------+ +| Description | Channel description | Text | ++-------------+-----------------------------------------+---------------------+ +| Settings | Channel settings | Text | ++-------------+-----------------------------------------+---------------------+ +| Range min | Range min in scaled units | Float number | ++-------------+-----------------------------------------+---------------------+ +| Range max | Range max in scaled units | Float number | ++-------------+-----------------------------------------+---------------------+ +| Actual Value| Actual value in scaled unit | Float number | ++-------------+-----------------------------------------+---------------------+ + + +To get real scaled value, client has to apply the following formula: +ScaledValue = ScaleRawData * RawValue + OffsetRawData + + +.. warning:: The received values from `list_used_chs` can be differ in the + length of the given protocol. + +Channels in data packet are delivered in the same order they are included in +'PREPARE TRANSFER' command. + +Binary data format, which will be receied by the server + +Header +---------------------------------------------------- + ++-------+-------+---------------+---------------------------------------------+ +|Offset |Length |Data type | Description | +|(bytes)|(bytes)| | Comment | ++=======+=======+===============+=============================================+ +| 0 | 8 | |Start packet string | +| | | | 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 | ++-------+-------+---------------+---------------------------------------------+ +| 8 | 4 |Integer 32 bit |Packet size | +| | | |Size in bytes without stop and start string | ++-------+-------+---------------+---------------------------------------------+ +| 12 | 4 |Integer 32 bit |Packet type | +| | | |Always 0 for data packets | ++-------+-------+---------------+---------------------------------------------+ +| 16 | 4 |Integer 32 bit |Samples in packet | +| | | |Number of synchronous samples per ch. | ++-------+-------+---------------+---------------------------------------------+ +| 20 | 8 |Integer 64 bit |Samples acquired so far | ++-------+-------+---------------+---------------------------------------------+ +| 28 | 8 |Double floating|Absolute/relative time | +| | |point |Number of days since 12/30/1899 | +| | | |Number of days since start of acq. | ++-------+-------+---------------+---------------------------------------------+ + +Off = 36 bytes, + + +Repeat for each channel +------------------------------------------------------ + +* If Channel is asynchronous ++--------------+--------------+----------------+------------------------------+ +| Offset | Length | Data type | Description | +| (bytes) | (bytes) | | Comment | ++==============+==============+================+==============================+ +| Off | 4 | 4 | Number of samples | +| | | | = X | ++--------------+--------------+----------------+------------------------------+ +| Off + 4 |X * SampleSize|Sample data type| Data samples | ++--------------+--------------+----------------+------------------------------+ +| Off + 4 + | X * 8 | Integer 64 bit | Timestamp samples | +|X * SampleSize| | | Timestamps for samples | +| | | | since start of acquisition | ++--------------+--------------+----------------+------------------------------+ +Off = Off + 4 + X * (SampleSize + 8) + + +* If Channel is synchronous ++--------------+---------------+----------------+-----------------------------+ +| Offset | Length | Data type | Description | +| (bytes) | (bytes) | | Comment | ++==============+===============+================+=============================+ +| Off | 4 | 4 | Number of samples | +| | | | = X = SamplesInPacket div | +| | | | Channel SR divider | ++--------------+---------------+----------------+-----------------------------+ +| Off + 4 |X * SampleSize |Sample data type| Data samples | ++--------------+---------------+----------------+-----------------------------+ +Off = Off + 4 + X * SampleSize + + +* If Channel is single value ++--------------+--------------+-----------------+-----------------------------+ +| Offset | Length | Data type | Description | +| (bytes) | (bytes) | | Comment | ++==============+==============+=================+=============================+ +| Off | 4 | 4 | Number of samples | +| | | | Always 1 | ++--------------+--------------+-----------------+-----------------------------+ +| Off + 4 | 8 | Double floating | Data sample | +| | | point | | ++--------------+--------------+-----------------+-----------------------------+ +Off = Off + 12 + + +End repeat +-------------------------------------------------------- ++--------------+--------------+-----------------+-----------------------------+ +| Offset | Length | Data type | Description | +| (bytes) | (bytes) | | Comment | ++==============+==============+=================+=============================+ +| Off | 8 | | Stop packet string | +| | | | 0x07 0x06 0x05 0x04 0x03 | +| | | | 0x02 0x01 0x00 | ++--------------+--------------+-----------------+-----------------------------+ + +""" + +import threading + + +def _local_handler(name, value, timestamp): + """A local handler implementation as backup solution. + + This method will be used if no update handler will be set. + + Args: + name (str): Name of the channel + value (float): Value of hte measured sample + timestamp (float): Relative timestamp of the value in seconds since + start of acquisition + """ + pass + + +class DeweChannel: + """ Representation of a DeweSoft channel. + + This class is used for storage and representation of a DeweSoft channel. + + Attributes: + channel_info (DeweChannelInfo): Channel information read from DeweSoft + last_value (float): Last received raw value of the channel + last_timestamp (float): Last received relative timestamp of the channel + update_handler (function): Reference to the handler function + + _values_lock (RLock): + + """ + + def __init__(self, channel_info, update_handler=None): + """ Constructor. + + Args: + channel_info (DeweChannelInfo): Static information about the + DeweSoft channel_info + + update_handler (function): Reference to update handler function. + + The function must have three arguments: + name (str): Name of the channel + index (int): last index from DeweSoft of the received value. + Can be used to calculate the timestamp of the value + using the timestamp of the measurement start. + value (float): scaled value of the last received point + + Example of the handler function: + def update_value(name, index, value): + print("Update Handler called:",name, index, value) + """ + self.channel_info = channel_info + self.last_value = None + self.last_timestamp = None + + self._values_lock = threading.RLock() + self.update_handler = update_handler or _local_handler + + def __str__(self, *args, **kwargs): + + ret_str = "Channel {}: ".format(self.channel_info.name) + + if self.last_value: + ret_str += "{} {} at index {}".format(self.last_value, + self.channel_info.unit, + self.last_timestamp) + else: + ret_str += "No value available" + return ret_str + + def __repr__(self, *args, **kwargs): + return "{} (Channel)".format(self.channel_info.name) + + def get_info_as_dict(self): + """Get all references as dict + + Get all stored information variables of the channel as an dictionary + + Returns: + dict: Dict of all stored attributes for easier handling + + """ + last_timestamp, last_value = self.get_last_value() + info = { + 'last_value': last_value, + 'last_timestamp': last_timestamp + } + info.update(self.channel_info.get_info_as_dict()) + return info + + def set_value(self, raw_value, timestamp): + """Set a new value + + Sets a new value (threadsafe) of the channel with its index. After that + it will call all stored update handler by the new value + + Args: + raw_value (float): value of the data point (type is listed in + channel info) + timestamp (float): index of the data point + """ + + with self._values_lock: + self.last_timestamp = timestamp + self.last_value = raw_value + + if self.update_handler: + last_value = self._calc_value_raw(raw_value) + self.update_handler(self.channel_info.name, last_value, timestamp) + + def get_last_value(self): + """ Read the last value of the channel + + This function reads (threadsafe) the last stored value of the channel + + Returns: + list: output list containing two elements + float: calculated value of the last receive measurement value + float: timestamp in seconds since start of acquisition + """ + with self._values_lock: + if self.last_value is None: + raise ValueError('No value available for {0}'.format(self.channel_info.name)) + + return self._calc_value_raw(self.last_value), self.last_timestamp + + def _calc_value_raw(self, raw_value): + """ Calculate the scaled value of the channel + + Returns calculates (scaled) value of the Channel given by its position + + Args: + raw_value (number): raw value that should be converted. + + Returns: + Float: calculated scaled value of the channel's raw value + + """ + + calcvalue = raw_value * self.channel_info.scale_raw_data * \ + self.channel_info.custom_scale + \ + self.channel_info.offset_raw_data + self.channel_info.custom_offset + return calcvalue + + +class DeweChannelInfo: + ''' Information header of a channel of DeweSoft + + This class contains the header information of a DeweSoft channel. + These values are transmitted by the DeweSoft using the 'listusedchs' + command. Fur detailed information see DeweSoft Net interface manual + + Attributes: + see module description + + channel_number (int): + name (str): + unit (str): + samplerate_divider (int,str) + measurement_type (int): + sample_data_type (int): + buffer_size (int): + custom_scale (float): + custom_offset (flaot): + scale_raw_data (float): + offset_raw_data (float): + settings (str): + range_min (float): + range_max (float): + value_min (float): + value_max (float): + value_act (float): + ''' + + def __init__(self, channel_number, name, unit, samplerate_divider, + measurement_type, sample_data_type, buffer_size, + custom_scale, custom_offset, scale_raw_data, offset_raw_data, + description, settings, range_min, range_max, value_min, + value_max, value_act): + ''' Constructor + + Args: + Information, which are transmitted by the DeweSoft 'listusedchs' + command + ''' + self.channel_number = int(channel_number) # int + self.name = str(name) # string + self.unit = str(unit) if unit != "-" else "" # string + if samplerate_divider.isdigit(): + self.samplerate_divider = int(samplerate_divider) # int,string + else: + self.samplerate_divider = str(samplerate_divider).upper() + + self.measurement_type = int(measurement_type) # int + self.sample_data_type = int(sample_data_type) # int + self.buffer_size = int(buffer_size) # int + self.custom_scale = DeweChannelInfo.convert_str_to_float(custom_scale) + self.custom_offset = DeweChannelInfo.convert_str_to_float( + custom_offset) + self.scale_raw_data = DeweChannelInfo.convert_str_to_float( + scale_raw_data) + self.offset_raw_data = DeweChannelInfo.convert_str_to_float( + offset_raw_data) + self.description = str(description) + self.settings = str(settings) + self.range_min = DeweChannelInfo.convert_str_to_float(range_min) + self.range_max = DeweChannelInfo.convert_str_to_float(range_max) + self.value_min = DeweChannelInfo.convert_str_to_float(value_min) + self.value_max = DeweChannelInfo.convert_str_to_float(value_max) + self.value_act = DeweChannelInfo.convert_str_to_float(value_act) + + @property + def type(self): + """Get the type of the channel as str. + + Returns: + str: "sync" for a synchronous channel, + "async" for an asynchronous channel, + "single" for a single ValueError + """ + if isinstance(self.samplerate_divider, int): + return "sync" + elif (isinstance(self.samplerate_divider, str) and + self.samplerate_divider == "ASYNC"): + return "async" + elif (isinstance(self.samplerate_divider, str) and + self.samplerate_divider == "SINGLEVALUE"): + return "single" + + @staticmethod + def convert_str_to_float(string_value): + """Convert a string value into a float. + + The method will also accept float values that are divided by a colon. + + Args: + string_value (str): String containing float value to be converted. + + Returns: + float: converted value from string + """ + + if isinstance(string_value, str): + string_value = string_value.replace(",", ".") + if isinstance(string_value, str): + string_value = string_value.replace(",", ".") + return float(string_value) # float + + def __str__(self, *args, **kwargs): + string = "DeweCh {} ({}): \r\n".format(self.channel_number, self.name) + string += "\tUnit: {}\r\n".format(self.unit) + string += "\tSamplerate Divider: {}\r\n".format( + self.samplerate_divider) + string += "\tMeasurement Type: {}\r\n".format(self.measurement_type) + string += "\tSample Data Type: {}\r\n".format(self.sample_data_type) + string += "\tBuffer Size: {}\r\n".format(self.buffer_size) + string += "\tCustom Scale: {}\r\n".format(self.custom_scale) + string += "\tCustom Offset: {}\r\n".format(self.custom_offset) + string += "\tScale Raw Data: {}\r\n".format(self.scale_raw_data) + string += "\tOffset Raw Data: {}\r\n".format(self.offset_raw_data) + string += "\tDescription: {}\r\n".format(self.description) + string += "\tSettings: {}\r\n".format(self.settings) + string += "\tRange min: {}\r\n".format(self.range_min) + string += "\tRange max: {}\r\n".format(self.range_max) + string += "\tValue min: {}\r\n".format(self.value_min) + string += "\tValue max: {}\r\n".format(self.value_max) + string += "\tActual Value: {}\r\n".format(self.value_act) + return string + + def __repr__(self, *args, **kwargs): + return self.name + " (ChannelInfo)" + + def get_info_as_dict(self): + """ Get all references as dict. + + Get all stored information variables of the channel as an dictionary. + + Returns: + dict: Dict of all stored attributes + """ + info = { + 'name': self.name, + 'unit': self.unit, + 'samplerate_divider': self.samplerate_divider, + 'measurement_type': self.measurement_type, + 'sample_data_type': self.sample_data_type, + 'buffer_size': self.buffer_size, + 'custom_scale': self.custom_scale, + 'custom_offset': self.custom_offset, + 'scale_raw_data': self.scale_raw_data, + 'offset_raw_data': self.offset_raw_data, + 'value_act': self.value_act, + 'settings': self.settings, + 'channel_number': self.channel_number, + 'range_min': self.range_min, + 'range_max': self.range_max, + 'value_min': self.value_min, + 'value_max': self.value_max + } + + return info + + _value_converter = { + 'size': {0: 1, 1: 1, 2: 2, 3: 3, 4: 4, 5: 4, 6: 8, 7: 8}, + 'decoder': {0: 'B', 1: 'b', 2: 'h', 3: 'H', + 4: 'i', 5: 'f', 6: 'q', 7: 'd'} + } + + # Converter dictionary for DeweSoft's data types. + + # The converter dictionary will contains following entries: + + # size: The number of bytes that are used to store the specific DeweSoft's + # data value. It will be used by the TCP server to receive the + # specific number of byter + # decoder: The formatter string for the struct.unpack function to interpret + # the received bytes during the encoding of the raw values. + + def get_value_size(self): + """ Read size of value. + + This function calculates the used size of the channel's value in bytes. + + Returns: + int: Size of the value calculated in number of bytes + """ + return DeweChannelInfo._value_converter['size'][self.sample_data_type] + + def get_value_format(self): + """ Get the value formatter. + + Get the formatter for the struct command of the stored channel + + Returns: + str: formatter string for using in the struct conversion + + """ + decoder = DeweChannelInfo._value_converter['decoder'] + return decoder[self.sample_data_type] diff --git a/Lib/svpelab/dewenetcontroller/dewenet_server.py b/Lib/svpelab/dewenetcontroller/dewenet_server.py new file mode 100644 index 0000000..37ab409 --- /dev/null +++ b/Lib/svpelab/dewenetcontroller/dewenet_server.py @@ -0,0 +1,391 @@ +""" +Copyright (c) 2018, Austrian Institute of Technology +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Austrian Institute of Technology nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" + + + + + + +""" Server for the DeweNetController + +This module contains the server, which is necessary for real time communication +with the DeweSoft Net interface. + +The DeweSoft will send its measured values to this server. +""" + +import socket +import struct +import codecs + +import threading +import logging + + +class DeweNetControllerServer(threading.Thread): + """TCP Server for communication with the DeweSoft Net interface. + + The server will receive actual measurement values from the DeweSoft unit. + Therefore a real time communication with actual data transport can be built + up. + + Usage: The server must be initialized by the controller. Therefore it needs + a list of channels (order is related), which is transmitted to the DeweSoft + system by the DeweNetControllerClient before. + + The DeweSoft sends packets with actual measurement data to the opened + server: The list_of_channel and its order is required to translate the + received packet to the actual data. + + Attributes: + START_OF_MESSAGE (bytes): Start bytes of packet + END_OF_MESSAGE (bytes): End bytes of packet + + _logger (logging.logger): Logger of the class + _server_ip (str): IP address of the server + _tcp_port (int): TCP port of the server + _socket: server socket of the server + _list_of_channels (list): List of DeweChannel + _keep_running (bool): running flag for the server thread + read_only_single_values: read only the last value of the incoming + packet. + samplerate (int): Samplerate of the current measurement + _last_chunk (bytes): last received chunk. This state variable will be + used to temporarily store the last half received message. + """ + + START_OF_MESSAGE = codecs.decode('0001020304050607', 'hex_codec') + END_OF_MESSAGE = codecs.decode('0706050403020100', 'hex_codec') + + def __init__(self, list_of_channels, server_ip="", + tcp_port=9000, read_only_single_values=True, + server_socket=None, logger=None): + """ Constructor + + Args: + list_of_channels (list): list of strings containing the list of + channels that will be received from DeweSoft. This list is set + during the client's prepareTransfer. + server_ip (str): IP address of the TCP server Default: "" + tcp_port (int): TCP port of the server Default: 9000 + read_only_single_values (bool): read only last value for + each channel of the incoming data packet. + server_socket (socket.socket): Socket for the TCP server + logger (logging.logger): Logger of the class + + """ + threading.Thread.__init__(self) + self.daemon = True + self._logger = logger or logging.getLogger(__name__) + + self._tcp_port = tcp_port + self._server_ip = server_ip + self._socket = server_socket or socket.socket(socket.AF_INET, + socket.SOCK_STREAM) + + self._list_of_channels = list(list_of_channels) + self._keep_running = True + + self.read_only_single_values = read_only_single_values + self.samplerate = 1 + + self._last_chunk = b'' + + def run(self): + """ Run method of the server thread + + It will opened the server socket at the given port and it will + wait for incoming packet. + """ + threading.Thread.run(self) + self._logger.debug("Start run IP: {}, Port {}".format(self._server_ip, + self._tcp_port)) + self._socket.bind((self._server_ip, self._tcp_port)) + self._socket.listen(1) + + connection, client = self._socket.accept() + self._socket.settimeout(5.0) + self._logger.info("Client connected: " + str(client)) + + while self._keep_running: + try: + self._handle_message(connection) + except (KeyboardInterrupt, RuntimeError) as ex: + self._logger.warn("Stopping server.", ex) + self._keep_running = False + + def close_server(self): + """ Close the server thread + """ + self._logger.info("Close DeweNetControllerServer") + self._keep_running = False + + def _handle_message(self, connection): + """ Message parser for incoming packets + + This helper function will parse the incoming message block and convert + the measurement data to the dedicated channel storage. + + See DeweSoft-NET manual (or DeweChannel module description) for further + description of the incoming packet + + Args: + connection: connection that is used to receive data from the socket. + """ + messages = self._read_messages(connection) + + if self.read_only_single_values: + messages = messages[-1:] + + for message in messages: + self._parse_message(message) + + def _read_messages(self, connection): + """Read messages from the socket. + + Args: + connection: connection that is used to receive data from the socket. + + Returns: + list: List of messages that are received. The messages are stored + as bytes. + """ + self._logger.debug("Wait for data") + + chunk = [self._last_chunk] + + while True: + data = connection.recv(4096) + chunk.append(data) + if data.find(DeweNetControllerServer.END_OF_MESSAGE) != -1: + break + + chunk = b''.join(chunk) + + messages, self._last_chunk = _split_messages( + chunk, + DeweNetControllerServer.START_OF_MESSAGE, + DeweNetControllerServer.END_OF_MESSAGE) + + return messages + + def _parse_message(self, chunk): + """Parse a received message. + + Args: + chunk (bytes): received data that represents the message in bytes. + """ + + try: + chunk, header = Header.from_bytes(chunk) + chunk = self._read_channels(header, chunk) + # log message + if self._logger.isEnabledFor(logging.DEBUG): + outstr = "Received Packet: " + outstr += header.log_format() + self._logger.debug(outstr) + + except struct.error as ex: + self._logger.warn("Error during parsing message", ex) + return + + def _read_channels(self, header, chunk): + """Read channel informations from packet + + Args: + header (Header): Parsed header of the received message. + chunk (bytes): the packet bytes + Returns: + bytes: reduced chunk with removed channel bytes + """ + # parse and handle channels in the received packet + for channel in self._list_of_channels: + chinfo = channel.channel_info + # get data format and data size from the channel info + value_size_byte = chinfo.get_value_size() + value_formatter = chinfo.get_value_format() + + # read samples counter for the next channel + chunk, samples_nr = _read_number_of_samples(chunk) + + # Read only the last values of the received packet + # or read all values of the packet + if samples_nr > 0 and self.read_only_single_values: + range_begin = samples_nr - 1 + else: + range_begin = 0 + + # Read list of values + for i in range(range_begin, samples_nr): + + begin_chunk_index = i * value_size_byte + end_chunk_index = begin_chunk_index + value_size_byte + sample_value = struct.unpack_from( + "<" + value_formatter, + memoryview(chunk[begin_chunk_index:end_chunk_index]))[0] + + # read timestamp + if chinfo.type == "sync": + timestamp_sample_index = header.samples_acquired_so_far + \ + (i * chinfo.samplerate_divider) + + timestamp_sample = \ + float(timestamp_sample_index) / float(self.samplerate) + + elif chinfo.type == "async": + delta_index_time_value = samples_nr * value_size_byte + begin_time_index = i * 8 + delta_index_time_value + end_time_index = begin_time_index + 8 + + timestamp_sample = struct.unpack_from( + " 0: + chunk = chunk[first_som:] + + last_eom = chunk.rfind(eom) + if last_eom == -1: + return [], [chunk] + elif last_eom <= len(chunk) - len(eom): + end_part = chunk[last_eom+len(eom):] + chunk = chunk[:last_eom+len(eom)] + else: + raise Exception("Split Messages: Len of chunk is too low") + + messages = chunk.split(som) + messages = [msg[:msg.rfind(eom)] for msg in messages if msg] + + return messages, end_part + +def _read_number_of_samples(chunk): + """Read the number from samples of a channel + Args: + chunk (bytes): the packet bytes + Returns: + bytes: reduced chunk with removed channel bytes + """ + # pylint: disable=R0201 + samples_nr = (struct.unpack_from(" 0: - for d in data: - resp += d - if d == '\n': - more_data = False - break - else: - raise gridsim.GridSimError('Timeout waiting for response') - except gridsim.GridSimError: - raise - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response - More data problem') - - return resp - - def cmd_tcp(self, cmd_str): - try: - if self.conn is None: - self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) - self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.conn.settimeout(self.timeout) - self.conn.connect((self.ipaddr, self.ipport)) - - # print 'cmd> %s' % (cmd_str) - self.conn.send(cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query_tcp(self, cmd_str): - resp = '' - more_data = True - - self._cmd(cmd_str) - - while more_data: - try: - data = self.conn.recv(self.buffer_size) - if len(data) > 0: - for d in data: - resp += d - if d == '\n': #\r - more_data = False - break - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response') - - return resp - - def cmd(self, cmd_str): - self.cmd_str = cmd_str - try: - self._cmd(cmd_str) - resp = self._query('SYSTem:ERRor?\n') #\r - - if len(resp) > 0: - if resp[0] != '0': - raise gridsim.GridSimError(resp + ' ' + self.cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query(self, cmd_str): - try: - resp = self._query(cmd_str).strip() - except Exception, e: - raise gridsim.GridSimError(str(e)) - - return resp - - def info(self): - return self.query('*IDN?\n') - - def config_phase_angles(self): - if self.phases_param == 1: - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') - self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') - elif self.phases_param == 3: - # set the phase angles for the 3 phases - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') - self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') - self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') - self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') - self.cmd('inst:coup none;:inst:nsel 3;:func sin\n') - else: - raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.phases_param)) - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - self.ts.log('Grid simulator model: %s' % self.info().strip()) - - # put simulator in regenerative mode - state = self.regen() - if state != gridsim.REGEN_ON: - if self.relay() == gridsim.RELAY_CLOSED: - self.relay(state=gridsim.RELAY_OPEN) - state = self.regen(gridsim.REGEN_ON) - self.ts.log('Grid sim regenerative mode is: %s' % state) - - # set frequency - self.freq(self.freq_param) - - # set the phase angles for the active phases - self.config_phase_angles() - - # set voltage range - v_max = self.v_max_param - v1, v2, v3 = self.voltage_max() - if v1 != v_max or v2 != v_max or v3 != v_max: - self.voltage_max(voltage=(v_max, v_max, v_max)) - v1, v2, v3 = self.voltage_max() - self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - # set nominal voltage - v_nom = self.v_nom_param - v1, v2, v3 = self.voltage() - if v1 != v_nom or v2 != v_nom or v3 != v_nom: - self.voltage(voltage=(v_nom, v_nom, v_nom)) - v1, v2, v3 = self.voltage() - self.ts.log('Grid sim nominal voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - # set max current if it's not already at gridsim_Imax - i_max = self.i_max_param - current = self.current() - if current != i_max: - self.current(i_max) - current = self.current() - self.ts.log('Grid sim max current: %s Amps' % current) - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - try: - self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, - timeout=self.timeout, writeTimeout=self.write_timeout) - time.sleep(2) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.conn: - self.conn.close() - - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - if current is not None: - self.cmd('inst:coup all;:curr %0.2f\n' % current) - curr_str = self.query('inst:nsel 1;:curr?\n') - return float(curr_str[:-1]) - - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max current. - """ - if current is not None: - self.cmd('inst:coup all;:curr %0.2f\n' % current) - curr_str = self.query('inst:nsel 1;:curr? max\n') - return float(curr_str[:-1]) - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - self.cmd('freq %0.2f\n' % freq) - freq = self.query('freq?\n') - return freq - - def profile_load(self, profile_name=None, v_step=100, f_step=100, t_step=None, profile=None): - v_nom = self.v_nom_param - freq_nom = self.freq_param - - if profile is None: - if profile_name is None: - raise gridsim.GridSimError('Profile not specified.') - - if profile_name == 'Manual': # Manual reserved for not running a profile. - self.ts.log_warning('Manual reserved for not running a profile') - return - - # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step - if profile_name is 'Transient_Step': - if t_step is None: - raise gridsim.GridSimError('Transient profile did not have a duration.') - else: - # (time offset in seconds, % nominal voltage, % nominal frequency) - profile = [(0, v_step, f_step), (t_step, v_step, f_step), (t_step, 100, 100)] - - else: - # get the profile from grid_profiles - profile = grid_profiles.profiles.get(profile_name) - if profile is None: - raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) - - dwell_list = '' - v1_list = '' - v2_list = '' - v3_list = '' - v_slew_list = '' - freq_list = '' - freq_slew_list = '' - func_list = '' - rep_list = '' - for i in range(1, len(profile)): - v1 = float(profile[i - 1][1]) - v2 = float(profile[i - 1][2]) - v3 = float(profile[i - 1][3]) - freq = float(profile[i - 1][4]) - t_delta = float(profile[i][0]) - float(profile[i - 1][0]) - v_delta = max(abs(float(profile[i][1]) - v1), abs(float(profile[i][2]) - v2), - abs(float(profile[i][3]) - v3)) - freq_delta = abs(float(profile[i][4]) - freq) - - v_slew = 'MAX' - freq_slew = 'MAX' - # if t_delta == 0: - # t_delta = 0.001 - if t_delta > 0: - if i > 1: - dwell_list += ',' - v1_list += ',' - v2_list += ',' - v3_list += ',' - v_slew_list += ',' - freq_list += ',' - freq_slew_list += ',' - func_list += ',' - rep_list += ',' - if v_delta > 0: - v_slew = '%0.3f' % (((v_delta/t_delta)/100.) * float(v_nom)) - v1 = float(profile[i][1]) # look at next voltage - v2 = float(profile[i][2]) - v3 = float(profile[i][3]) - if freq_delta > 0: - freq_slew = '%0.3f' % (((freq_delta/t_delta)/100.) * float(freq_nom)) - freq = float(profile[i][4]) # look at next frequency - dwell_list += '%0.3f' % t_delta - v1_list += '%0.3f' % ((v1/100.) * float(v_nom)) - v2_list += '%0.3f' % ((v2/100.) * float(v_nom)) - v3_list += '%0.3f' % ((v3/100.) * float(v_nom)) - v_slew_list += v_slew - freq_list += '%0.3f' % ((freq/100.) * float(freq_nom)) - freq_slew_list += freq_slew - func_list += 'SINE' - rep_list += '0' - - cmd_list = [] - cmd_list.append('trig:tran:sour imm\n') - cmd_list.append('list:step auto\n') - cmd_list.append('abort\n') - cmd_list.append('abort;:inst:coup none;:list:coun 1;:freq:mode list;:freq:slew:mode list\n') - cmd_list.append(':inst:nsel 1;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append(':inst:nsel 2;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append(':inst:nsel 3;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append('inst:coup all\n') - cmd_list.append(':list:dwel %s\n' % dwell_list) - cmd_list.append(':list:freq %s\n' % freq_list) - cmd_list.append(':list:freq:slew %s\n' % freq_slew_list) - cmd_list.append(':inst:nsel 1;:list:volt %s\n' % v1_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':inst:nsel 2;:list:volt %s\n' % v2_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':inst:nsel 3;:list:volt %s\n' % v3_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':list:rep %s\n' % rep_list) - cmd_list.append('*esr?\n') - cmd_list.append('trig:sync:sour imm\n') - cmd_list.append(':init\n') - - self.profile = cmd_list - - self.ts.log(cmd_list) - - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - for entry in self.profile: - self.cmd(entry) - - def profile_stop(self): - """ - Stop the running profile. - """ - self.cmd('abort\n') - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - """ - if state == gridsim.REGEN_ON: - self.cmd('REGenerate:STATe ON\n') - self.query('*esr?\n') - self.cmd('INST:COUP ALL\n') - self.query('*esr?\n') - self.cmd('INST:COUP none;:inst:nsel 1;\n') - elif state == gridsim.REGEN_OFF: - self.cmd('REGenerate:STATe OFF\n') - self.query('*esr?\n') - self.cmd('INST:COUP ALL\n') - self.query('*esr?\n') - self.cmd('INST:COUP none;:inst:nsel 1;\n') - elif state is None: - current_state = self.query('REGenerate:STATe?\n') - ### translate state - else: - raise gridsim.GridSimError('Unknown regen state: %s', state) - return state - - def relay(self, state=None): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - if state is not None: - if state == gridsim.RELAY_OPEN: - self.cmd('abort;:outp off\n') - elif state == gridsim.RELAY_CLOSED: - self.cmd('abort;:outp on\n') - else: - raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) - else: - relay = self.query('outp?\n').strip() - # self.ts.log(relay) - if relay == '0': - state = gridsim.RELAY_OPEN - elif relay == '1': - state = gridsim.RELAY_CLOSED - else: - state = gridsim.RELAY_UNKNOWN - return state - - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if voltage is not None: - # set output voltage on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(voltage) is not list and type(voltage) is not tuple: - self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage) - else: - self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage[0]) # use the first value in the 3 phase list - v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac?\n') - v2 = self.query('inst:nsel 2;:volt:ac?\n') - v3 = self.query('inst:nsel 3;:volt:ac?\n') - return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - if voltage is not None: - voltage = max(voltage) # voltage is a triplet but Ametek only takes one value - if voltage == 150 or voltage == 300 or voltage == 600: - self.cmd('volt:rang %0.0f\n' % voltage) - else: - raise gridsim.GridSimError('Invalid Max Voltage %s, must be 150, 300 or 600 V.' % str(voltage)) - v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac? max\n') - v2 = self.query('inst:nsel 2;:volt:ac? max\n') - v3 = self.query('inst:nsel 3;:volt:ac? max\n') - return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v_nom_param - -if __name__ == "__main__": - pass \ No newline at end of file +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import time +import socket +import serial +from . import grid_profiles +from . import gridsim + +ametek_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Ametek' +} + +def gridsim_info(): + return ametek_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = ametek_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=277.2) + info.param(pname('v_max'), label='Max Voltage', default=300.0) + info.param(pname('i_max'), label='Max Current', default=100.0) + info.param(pname('freq'), label='Frequency', default=60.0) + info.param(pname('comm'), label='Communications Interface', default='Serial', values=['Serial', 'TCP/IP']) + info.param(pname('serial_port'), label='Serial Port', + active=pname('comm'), active_value=['Serial'], default='com1') + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['TCP/IP'], default='192.168.1.10') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['TCP/IP'], default=5025) + + +GROUP_NAME = 'ametek' + + +class GridSim(gridsim.GridSim): + """ + Ametek grid simulation implementation. + + Valid parameters: + mode - 'Ametek' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + serial_port + baudrate + timeout + write_timeout + ip_addr + ip_port + """ + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.buffer_size = 1024 + self.conn = None + + self.phases_param = self._param_value('phases') + self.v_nom_param = float(self._param_value('v_nom')) + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.comm = self._param_value('comm') + self.serial_port = self._param_value('serial_port') + self.ipaddr = self._param_value('ip_addr') + self.ipport = self._param_value('ip_port') + self.baudrate = 115200 + self.timeout = 5 + self.write_timeout = 2 + self.cmd_str = '' + self._cmd = None + self._query = None + self.profile_name = ts.param_value('profile.profile_name') + + if self.comm == 'Serial': + self.open() # open communications + self._cmd = self.cmd_serial + self._query = self.query_serial + elif self.comm == 'TCP/IP': + self._cmd = self.cmd_tcp + self._query = self.query_tcp + + self.cmd('*CLS\n') + # self.cmd('*RST\n') # Reset the entire system + self.profile_stop() + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() + + state = self.relay() + if state != gridsim.RELAY_CLOSED: + if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + self.ts.log('Turning on grid simulator.') + self.relay(state=gridsim.RELAY_CLOSED) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def cmd_serial(self, cmd_str): + self.cmd_str = cmd_str + try: + if self.conn is None: + raise gridsim.GridSimError('Communications port not open') + + self.conn.flushInput() + self.conn.write(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_serial(self, cmd_str): + resp = '' + more_data = True + + self.cmd_serial(cmd_str) + + while more_data: + try: + count = self.conn.inWaiting() + if count < 1: + count = 1 + data = self.conn.read(count) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': + more_data = False + break + else: + raise gridsim.GridSimError('Timeout waiting for response') + except gridsim.GridSimError: + raise + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response - More data problem') + + return resp + + def cmd_tcp(self, cmd_str): + try: + if self.conn is None: + self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_tcp(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': #\r + more_data = False + break + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + self.cmd_str = cmd_str + try: + self._cmd(cmd_str) + resp = self._query('SYSTem:ERRor?\n') #\r + + if len(resp) > 0: + if resp[0] != '0': + raise gridsim.GridSimError(resp + ' ' + self.cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise gridsim.GridSimError(str(e)) + + return resp + + def info(self): + return self.query('*IDN?\n') + + def config_phase_angles(self, config=False): + if config: + if self.phases_param == 1: + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') + elif self.phases_param == 3: + # set the phase angles for the 3 phases + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 3;:func sin\n') + elif self.phases_param == 2: + # set the phase angles for the 2 phases + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 180.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 3;:func sin\n') + else: + raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.phases_param)) + + ph1 = float(self.query('inst:coup none;:inst:nsel 1;:phas?\n')) + ph2 = float(self.query('inst:coup none;:inst:nsel 2;:phas?\n')) + ph3 = float(self.query('inst:coup none;:inst:nsel 3;:phas?\n')) + return ph1, ph2, ph3 + + def config_asymmetric_phase_angles(self, mag=None, angle=None): + """ + :param mag: list of voltages for the imbalanced test, e.g., [277.2, 277.2, 277.2] + :param angle: list of phase angles for the imbalanced test, e.g., [0, 120, -120] + + :returns: voltage list and phase list + """ + voltages = [] + phases = [] + + if mag is not None: + if type(mag) is not list: + raise gridsim.GridSimError('Waveform magnitudes were not provided as list. "mag" type: %s' % type(mag)) + + if angle is not None: + if type(angle) is list: + if angle[2] < 0: # make positive for Ametek + angle[2] += 360. + self.cmd('inst:coup none;:inst:nsel 1;:phas %0.1f;:volt:ac %0.1f;' + ':inst:coup none;:inst:nsel 2;:phas %0.1f;:volt:ac %0.1f;' + ':inst:coup none;:inst:nsel 3;:phas %0.1f;:volt:ac %0.1f\n' % (angle[0], mag[0], angle[1], + mag[1], angle[2], mag[2])) + + # get phase and voltage measurements to return + phases = self.config_phase_angles() + voltages = self.voltage() + else: + raise gridsim.GridSimError('Waveform angles were not provided as list.') + + return voltages, phases + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('Grid simulator model: %s' % self.info().strip()) + + # put simulator in regenerative mode + state = self.regen() + if state != gridsim.REGEN_ON: + if self.relay() == gridsim.RELAY_CLOSED: + self.relay(state=gridsim.RELAY_OPEN) + state = self.regen(gridsim.REGEN_ON) + self.ts.log('Grid sim regenerative mode is: %s' % state) + + # set frequency + self.freq(self.freq_param) + + # set the phase angles for the active phases + ph1, ph2, ph3 = self.config_phase_angles(config=True) + self.ts.log('Grid sim phase angles are: phase1 = %s, phase2 = %s, phase3 = %s' % (ph1, ph2, ph3)) + + # set voltage range + v_max = self.v_max_param + v1, v2, v3 = self.voltage_max() + if v1 != v_max or v2 != v_max or v3 != v_max: + self.voltage_max(voltage=(v_max, v_max, v_max)) + v1, v2, v3 = self.voltage_max() + self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) + + # set nominal voltage + v_nom = self.v_nom_param + v1, v2, v3 = self.voltage() + if v1 != v_nom or v2 != v_nom or v3 != v_nom: + self.voltage(voltage=(v_nom, v_nom, v_nom)) + v1, v2, v3 = self.voltage() + self.ts.log('Grid sim nominal voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) + + # set max current if it's not already at gridsim_Imax + i_max = self.i_max_param + current = self.current() + if current != i_max: + self.current(i_max) + current = self.current() + self.ts.log('Grid sim max current: %s Amps' % current) + + def open(self): + """ + Open the communications resources associated with the grid simulator. + """ + try: + self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, + timeout=self.timeout, writeTimeout=self.write_timeout) + time.sleep(2) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + if self.conn: + self.conn.close() + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + if current is not None: + self.cmd('inst:coup all;:curr %0.2f\n' % current) + curr_str = self.query('inst:nsel 1;:curr?\n') + return float(curr_str[:-1]) + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + """ + if current is not None: + self.cmd('inst:coup all;:curr %0.2f\n' % current) + curr_str = self.query('inst:nsel 1;:curr? max\n') + return float(curr_str[:-1]) + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if freq is not None: + self.cmd('freq %0.2f\n' % freq) + freq = self.query('freq?\n') + return freq + + def profile_load(self, profile_name=None, v_step=100, f_step=100, t_step=None, profile=None): + v_nom = self.v_nom_param + freq_nom = self.freq_param + + if profile is None: + if profile_name is None: + raise gridsim.GridSimError('Profile not specified.') + + if profile_name == 'Manual': # Manual reserved for not running a profile. + self.ts.log_warning('Manual reserved for not running a profile') + return + + # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step + if profile_name is 'Transient_Step': + if t_step is None: + raise gridsim.GridSimError('Transient profile did not have a duration.') + else: + # (time offset in seconds, % nominal voltage, % nominal frequency) + profile = [(0, v_step, f_step), (t_step, v_step, f_step), (t_step, 100, 100)] + + else: + # get the profile from grid_profiles + profile = grid_profiles.profiles.get(profile_name) + if profile is None: + raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) + + dwell_list = '' + v1_list = '' + v2_list = '' + v3_list = '' + v_slew_list = '' + freq_list = '' + freq_slew_list = '' + func_list = '' + rep_list = '' + for i in range(1, len(profile)): + v1 = float(profile[i - 1][1]) + v2 = float(profile[i - 1][2]) + v3 = float(profile[i - 1][3]) + freq = float(profile[i - 1][4]) + t_delta = float(profile[i][0]) - float(profile[i - 1][0]) + v_delta = max(abs(float(profile[i][1]) - v1), abs(float(profile[i][2]) - v2), + abs(float(profile[i][3]) - v3)) + freq_delta = abs(float(profile[i][4]) - freq) + + v_slew = 'MAX' + freq_slew = 'MAX' + # if t_delta == 0: + # t_delta = 0.001 + if t_delta > 0: + if i > 1: + dwell_list += ',' + v1_list += ',' + v2_list += ',' + v3_list += ',' + v_slew_list += ',' + freq_list += ',' + freq_slew_list += ',' + func_list += ',' + rep_list += ',' + if v_delta > 0: + v_slew = '%0.3f' % (((v_delta/t_delta)/100.) * float(v_nom)) + v1 = float(profile[i][1]) # look at next voltage + v2 = float(profile[i][2]) + v3 = float(profile[i][3]) + if freq_delta > 0: + freq_slew = '%0.3f' % (((freq_delta/t_delta)/100.) * float(freq_nom)) + freq = float(profile[i][4]) # look at next frequency + dwell_list += '%0.3f' % t_delta + v1_list += '%0.3f' % ((v1/100.) * float(v_nom)) + v2_list += '%0.3f' % ((v2/100.) * float(v_nom)) + v3_list += '%0.3f' % ((v3/100.) * float(v_nom)) + v_slew_list += v_slew + freq_list += '%0.3f' % ((freq/100.) * float(freq_nom)) + freq_slew_list += freq_slew + func_list += 'SINE' + rep_list += '0' + + cmd_list = [] + cmd_list.append('trig:tran:sour imm\n') + cmd_list.append('list:step auto\n') + cmd_list.append('abort\n') + cmd_list.append('abort;:inst:coup none;:list:coun 1;:freq:mode list;:freq:slew:mode list\n') + cmd_list.append(':inst:nsel 1;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append(':inst:nsel 2;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append(':inst:nsel 3;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append('inst:coup all\n') + cmd_list.append(':list:dwel %s\n' % dwell_list) + cmd_list.append(':list:freq %s\n' % freq_list) + cmd_list.append(':list:freq:slew %s\n' % freq_slew_list) + cmd_list.append(':inst:nsel 1;:list:volt %s\n' % v1_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':inst:nsel 2;:list:volt %s\n' % v2_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':inst:nsel 3;:list:volt %s\n' % v3_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':list:rep %s\n' % rep_list) + cmd_list.append('*esr?\n') + cmd_list.append('trig:sync:sour imm\n') + cmd_list.append(':init\n') + + self.profile = cmd_list + + self.ts.log(cmd_list) + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.profile is not None: + for entry in self.profile: + self.cmd(entry) + + def profile_stop(self): + """ + Stop the running profile. + """ + self.cmd('abort\n') + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + """ + if state == gridsim.REGEN_ON: + self.cmd('REGenerate:STATe ON\n') + self.query('*esr?\n') + self.cmd('INST:COUP ALL\n') + self.query('*esr?\n') + self.cmd('INST:COUP none;:inst:nsel 1;\n') + elif state == gridsim.REGEN_OFF: + self.cmd('REGenerate:STATe OFF\n') + self.query('*esr?\n') + self.cmd('INST:COUP ALL\n') + self.query('*esr?\n') + self.cmd('INST:COUP none;:inst:nsel 1;\n') + elif state is None: + current_state = self.query('REGenerate:STATe?\n') + ### translate state + else: + raise gridsim.GridSimError('Unknown regen state: %s', state) + return state + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + if state is not None: + if state == gridsim.RELAY_OPEN: + self.cmd('abort;:outp off\n') + elif state == gridsim.RELAY_CLOSED: + self.cmd('abort;:outp on\n') + else: + raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) + else: + relay = self.query('outp?\n').strip() + # self.ts.log(relay) + if relay == '0': + state = gridsim.RELAY_OPEN + elif relay == '1': + state = gridsim.RELAY_CLOSED + else: + state = gridsim.RELAY_UNKNOWN + return state + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if voltage is not None: + # set output voltage on all phases + # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) + if type(voltage) is not list and type(voltage) is not tuple: + self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage) + else: + self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage[0]) # use the first value in the 3 phase list + v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac?\n') + v2 = self.query('inst:nsel 2;:volt:ac?\n') + v3 = self.query('inst:nsel 3;:volt:ac?\n') + return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + """ + if voltage is not None: + voltage = max(voltage) # voltage is a triplet but Ametek only takes one value + if voltage == 150 or voltage == 300 or voltage == 600: + self.cmd('volt:rang %0.0f\n' % voltage) + else: + raise gridsim.GridSimError('Invalid Max Voltage %s, must be 150, 300 or 600 V.' % str(voltage)) + v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac? max\n') + v2 = self.query('inst:nsel 2;:volt:ac? max\n') + v3 = self.query('inst:nsel 3;:volt:ac? max\n') + return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + def i_max(self): + return self.i_max_param + + def v_max(self): + return self.v_max_param + + def v_nom(self): + return self.v_nom_param + + # Measurements from the grid simulator. + # MEASure triggers the acquisition of new measurement data before returning a reading. + # FETCh returns a reading computed from previously acquired data. + def meas_current(self, ph_list=(1, 2, 3)): + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 1\n') + i1 = self.query('meas:curr:ac?\n') + i1 = float(i1[:-1]) + else: + i1 = None + if 2 in ph_list: + self.cmd('inst:coup none;:inst:nsel 2\n') + i2 = self.query('meas:curr:ac?\n') + i2 = float(i2[:-1]) + else: + i2 = None + if 3 in ph_list: + self.cmd('inst:coup none;:inst:nsel 3\n') + i3 = self.query('meas:curr:ac?\n') + i3 = float(i3[:-1]) + else: + i3 = None + return i1, i2, i3 + + def meas_voltage(self, ph_list=(1, 2, 3)): + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 1\n') + v1 = self.query('meas:volt:ac?\n') + v1 = float(v1[:-1]) + else: + v1 = None + if 2 in ph_list: + self.cmd('inst:coup none;:inst:nsel 2\n') + v2 = self.query('meas:volt:ac?\n') + v2 = float(v2[:-1]) + else: + v2 = None + if 3 in ph_list: + self.cmd('inst:coup none;:inst:nsel 3\n') + v3 = self.query('meas:volt:ac?\n') + v3 = float(v3[:-1]) + else: + v3 = None + return v1, v2, v3 + + def meas_freq(self): + freq = self.query('meas:FREQ?\n') + return freq + + def meas_power(self, ph_list=(1, 2, 3)): + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 1\n') + p1 = self.query('meas:pow:ac?\n') + p1 = float(p1[:-1])*1000. + else: + p1 = None + if 2 in ph_list: + self.cmd('inst:coup none;:inst:nsel 2\n') + p2 = self.query('meas:pow:ac?\n') + p2 = float(p2[:-1])*1000. + else: + p2 = None + if 3 in ph_list: + self.cmd('inst:coup none;:inst:nsel 3\n') + p3 = self.query('meas:pow:ac?\n') + p3 = float(p3[:-1])*1000. + else: + p3 = None + return p1, p2, p3 + + def meas_va(self, ph_list=(1, 2, 3)): + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 1\n') + va1 = self.query('meas:pow:ac:app?\n') + va1 = float(va1[:-1])*1000. + else: + va1 = None + if 2 in ph_list: + self.cmd('inst:coup none;:inst:nsel 2\n') + va2 = self.query('meas:pow:ac:app?\n') + va2 = float(va2[:-1])*1000. + else: + va2 = None + if 3 in ph_list: + self.cmd('inst:coup none;:inst:nsel 3\n') + va3 = self.query('meas:pow:ac:app?\n') + va3 = float(va3[:-1])*1000. + else: + va3 = None + return va1, va2, va3 + + def meas_pf(self, ph_list=(1, 2, 3)): + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 1\n') + pf1 = self.query('meas:pow:pfac?\n') + pf1 = float(pf1[:-1]) + else: + pf1 = None + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 2\n') + pf2 = self.query('meas:pow:pfac?\n') + pf2 = float(pf2[:-1]) + else: + pf2 = None + if 1 in ph_list: + self.cmd('inst:coup none;:inst:nsel 3\n') + pf3 = self.query('meas:pow:pfac?\n') + pf3 = float(pf3[:-1]) + else: + pf3 = None + return pf1, pf2, pf3 + + def fetch_current(self): + self.cmd('inst:coup none;:inst:nsel 1\n') + i1 = self.query('fetc:curr:ac?\n') + self.cmd('inst:coup none;:inst:nsel 2\n') + i2 = self.query('fetc:curr:ac?\n') + self.cmd('inst:coup none;:inst:nsel 3\n') + i3 = self.query('fetc:curr:ac?\n') + return float(i1[:-1]), float(i2[:-1]), float(i3[:-1]) + + def fetch_voltage(self): + self.cmd('inst:coup none;:inst:nsel 1\n') + v1 = self.query('fetc:volt:ac?\n') + self.cmd('inst:coup none;:inst:nsel 2\n') + v2 = self.query('fetc:volt:ac?\n') + self.cmd('inst:coup none;:inst:nsel 3\n') + v3 = self.query('fetc:volt:ac?\n') + return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + def fetch_freq(self): + freq = self.query('fetc:FREQ?\n') + return freq + + def fetch_power(self): + self.cmd('inst:coup none;:inst:nsel 1\n') + p1 = self.query('fetc:pow:ac?\n') + self.cmd('inst:coup none;:inst:nsel 2\n') + p2 = self.query('fetc:pow:ac?\n') + self.cmd('inst:coup none;:inst:nsel 3\n') + p3 = self.query('fetc:pow:ac?\n') + return float(p1[:-1])*1000., float(p2[:-1])*1000., float(p3[:-1])*1000. # convert to watts + + def fetch_va(self): + self.cmd('inst:coup none;:inst:nsel 1\n') + va1 = self.query('fetc:pow:ac:app?\n') + self.cmd('inst:coup none;:inst:nsel 2\n') + va2 = self.query('fetc:pow:ac:app?\n') + self.cmd('inst:coup none;:inst:nsel 3\n') + va3 = self.query('fetc:pow:ac:app?\n') + return float(va1[:-1])*1000., float(va2[:-1])*1000., float(va3[:-1])*1000. # convert to VA + + def fetch_pf(self): + self.cmd('inst:coup none;:inst:nsel 1\n') + pf1 = self.query('fetc:pow:pfac?\n') + self.cmd('inst:coup none;:inst:nsel 2\n') + pf2 = self.query('fetc:pow:pfac?\n') + self.cmd('inst:coup none;:inst:nsel 3\n') + pf3 = self.query('fetc:pow:pfac?\n') + return float(pf1[:-1]), float(pf2[:-1]), float(pf3[:-1]) + +if __name__ == "__main__": + + grid = GridSim(ts=None, group_name=None) + + grid.config_asymmetric_phase_angles(mag=[276., 277., 278.], angle=[0., 121., 243.]) + + print(grid.meas_current()) + print(grid.meas_voltage()) + print(grid.meas_freq()) + print(grid.meas_power()) + print(grid.meas_va()) + print(grid.meas_pf()) diff --git a/Lib/svpelab/gridsim_chroma.py b/Lib/svpelab/gridsim_chroma.py index 6f9f239..b4177ec 100644 --- a/Lib/svpelab/gridsim_chroma.py +++ b/Lib/svpelab/gridsim_chroma.py @@ -1,364 +1,363 @@ -""" -Copyright (c) 2017, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os -import gridsim -import grid_profiles -import chroma_61845 - -chroma_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Chroma' -} - -def gridsim_info(): - return chroma_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = chroma_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('phases'), label='Phases', default=1, values=[1,2,3]) - info.param(pname('v_range'), label='Max voltage for all phases', default=300, values=[150,300]) - info.param(pname('v_max'), label='Max Voltage', default=300.0) - info.param(pname('i_max'), label='Max Current', default=75.0) - info.param(pname('freq'), label='Frequency', default=60.0) - info.param(pname('comm'), label='Communications Interface', default='VISA', values=['VISA']) - info.param(pname('visa_device'), label='VISA Device String', active=pname('comm'), - active_value=['VISA'], default='USB0::0x0A69::0x086C::662040000329::0::INSTR') - info.param(pname('visa_path'), label='VISA Path', active=pname('comm'), - active_value=['VISA'], default='C:/Program Files (x86)/IVI Foundation/VISA/WinNT/agvisa/agbin/visa32.dll') - -GROUP_NAME = 'chroma' - -class GridSim(gridsim.GridSim): - """ - Chroma grid simulation implementation. - - Valid parameters: - mode - 'Chroma' - v_nom - v_max - i_max - freq - profile_name - GPIB Address - Visa Path - - """ - def __init__(self, ts, group_name): - - gridsim.GridSim.__init__(self, ts, group_name) - self.conn = None - self.phases = ts._param_value('phases') - self.v_range_param = ts._param_value('v_range') - self.v_max_param = ts._param_value('v_max') - self.i_max_param = ts._param_value('i_max') - self.freq_param = ts._param_value('freq') - self.comm = ts._param_value('comm') - self.visa_device = ts._param_value('visa_device') - self.visa_path = ts._param_value('visa_path') - - self.cmd_str = '' - self._cmd = None - self._query = None - self.dev = chroma_61845.ChromaGridSim(visa_device=self.visa_device, - visa_path=self.visa_path) - self.dev.open() - self.dev.config() - self.profile_name = ts.param_value('profile.profile_name') - - state = self.relay() - output_state = self.output() - - if state != gridsim.RELAY_CLOSED or output_state != gridsim.OUTPUT_ON: - self.ts.log('Turning on grid simulator.') - self.output(state=gridsim.OUTPUT_ON) - self.relay(state=gridsim.RELAY_CLOSED) - - self.config() - - def cmd(self, cmd_str): - self.cmd_str = cmd_str - try: - self.dev.cmd(cmd_str) - resp = self.dev.query('SYSTem:ERRor?\n') #\r - if len(resp) > 0: - if resp[0] != '0': - raise gridsim.GridSimError(resp + ' ' + self.cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query(self, cmd_str): - try: - resp = self.dev.query(cmd_str).strip() - except Exception, e: - raise gridsim.GridSimError(str(e)) - return resp - - def info(self): - return self.query('*IDN?\n') - - def config_phase_angles(self): - self.dev.config_phase_angles(self.phases) - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - self.ts.log('Grid simulator model: %s' % self.info().strip()) - - # put simulator in regenerative mode - state = self.regen() - if state != gridsim.REGEN_ON: - state = self.regen(gridsim.REGEN_ON) - self.ts.log('Grid sim regenerative mode is: %s' % state) - - #Device Specific Configuration - self.dev.config() - - # set the phase angles. - self.config_phase_angles() - - # set voltage range - self.dev.voltage_range(self.v_range_param) - - v_max = self.v_max_param - v1, v2, v3 = self.voltage_max() - if v1 != v_max or v2 != v_max or v3 != v_max: - self.voltage_max(voltage=(v_max, v_max, v_max)) - v1, v2, v3 = self.voltage_max() - self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - # set max current if it's not already at gridsim_Imax - i_max = self.i_max_param - current = self.current() - if current != i_max: - self.current(i_max) - current = self.current() - self.ts.log('Grid sim max current: %s Amps' % current) - - def open(self): - """ - Open the communications resources associated with the device. - """ - try: - self.dev.open() - - except Exception, e: - raise gridsim.GridSimError('Cannot open VISA connection to %s' % (self.visa_device)) - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.dev is not None: - self.dev.close() - - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - return self.dev.current(current) - - def current_max(self, current=None): - return self.current(current) - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - Chroma has CW or IMMediate options for the frequency. Need to figure out what these are. - """ - return self.dev.freq(freq) - - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - if profile_name is None: - raise gridsim.GridSimError('Profile not specified.') - - if profile_name == 'Manual': # Manual reserved for not running a profile. - self.ts.log_warning('Manual reserved for not running a profile') - return - - v_nom = self.v_nom_param - freq_nom = self.freq_param - - # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step - if profile_name is 'Transient_Step': - if t_step is None: - raise gridsim.GridSimError('Transient profile did not have a duration.') - else: - # (time offset in seconds, % nominal voltage, % nominal frequency) - profile = [(0, v_step, f_step),(t_step, v_step, f_step),(t_step, 100, 100)] - - else: - # get the profile from grid_profiles - profile = grid_profiles.profiles.get(profile_name) - if profile is None: - raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) - - dwell_list = '' - v_start_list = '' - v_end_list = '' - freq_start_list = '' - freq_end_list = '' - func_list = '' - shape_list= '' - - for i in range(1, len(profile)-1): - v_start = float(profile[i - 1][1]) - v_end = float (profile[i]) - freq_start = float(profile[i - 1][2]) - freq_end = float(profile[i][2]) - dwelli = float(profile[i][0]) - float(profile[i - 1][0]) - dwelli = dwelli * 1000 #Chroma takes time in mS - - if i > 1: - dwell_list += ',' - v_start_list += ',' - v_end_list += ',' - freq_start_list += ',' - freq_end_list += ',' - shape_list += ',' - - v_start_list += v_start - v_end_list += v_end - freq_start_list += freq_start - freq_end_list += freq_end - dwell_list += dwelli - shape_list += 'A' - - #Attempt to move all SCPI commands to separate file for testing and extensibility. - cmd_list = self.dev.profile_load(dwell_list = dwell_list, - freq_start_list = freq_start_list, - freq_end_list = freq_end_list, - v_start_list=v_start_list, - v_end_list = v_end_list, - shape_list = shape_list) - self.profile = cmd_list - - # Loads the profile to instrument and starts. Assumes the output relay is already closed. - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - for entry in self.profile: - self.dev.cmd(entry) - self.dev.relay(gridsim.RELAY_CLOSED) - - def profile_stop(self): - """ - Stop the running profile. - """ - self.dev.profile_stop() - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - Chroma has no option: Always On - """ - return gridsim.REGEN_ON - - def relay(self, state=None): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - return self.dev.relay(state) - - def output(self,state=None): - return self.dev.output(state) - - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - return self.dev.voltage(voltage) - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - return self.dev.voltage_max(voltage) - - def voltage_slew(self,slew): - return self.dev.voltage_slew(slew) - - def freq_slew(self,slew): - return self.dev.voltage_slew(slew) - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v_nom_param - -if __name__ == "__main__": - import script - - d = {'gridsim.chroma.phases':'3', - 'gridsim.chroma.v_nom':'120.0', - 'gridsim.chroma.visa_path':'C:/Program Files (x86)/IVI Foundation/VISA/WinNT/agvisa/agbin/visa32.dll', - 'gridsim.chroma.visa_device':'USB0::0x0A69::0x086C::662040000329::0::INSTR'} - - - ''' - ts._param_value('phases') - self.v_nom_param = ts._param_value('v_nom') - self.v_max_param = ts._param_value('v_max') - self.i_max_param = ts._param_value('i_max') - self.freq_param = ts._param_value('freq') - self.comm = ts._param_value('comm') - self.visa_device = ts._param_value('visa_device') - self.visa_path = ts._param_value('visa_path') - ''' - - ts = script.Script(params = d) - - GridSim(ts) - GridSim.config() - +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import gridsim +from . import grid_profiles +from . import chroma_61845 + +chroma_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Chroma' +} + +def gridsim_info(): + return chroma_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = chroma_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1,2,3]) + info.param(pname('v_range'), label='Max voltage for all phases', default=300, values=[150,300]) + info.param(pname('v_max'), label='Max Voltage', default=300.0) + info.param(pname('i_max'), label='Max Current', default=75.0) + info.param(pname('freq'), label='Frequency', default=60.0) + info.param(pname('comm'), label='Communications Interface', default='VISA', values=['VISA']) + info.param(pname('visa_device'), label='VISA Device String', active=pname('comm'), + active_value=['VISA'], default='USB0::0x0A69::0x086C::662040000329::0::INSTR') + info.param(pname('visa_path'), label='VISA Path', active=pname('comm'), + active_value=['VISA'], default='C:/Program Files (x86)/IVI Foundation/VISA/WinNT/agvisa/agbin/visa32.dll') + +GROUP_NAME = 'chroma' + +class GridSim(gridsim.GridSim): + """ + Chroma grid simulation implementation. + + Valid parameters: + mode - 'Chroma' + v_nom + v_max + i_max + freq + profile_name + GPIB Address + Visa Path + + """ + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.conn = None + self.phases = ts._param_value('phases') + self.v_range_param = ts._param_value('v_range') + self.v_max_param = ts._param_value('v_max') + self.i_max_param = ts._param_value('i_max') + self.freq_param = ts._param_value('freq') + self.comm = ts._param_value('comm') + self.visa_device = ts._param_value('visa_device') + self.visa_path = ts._param_value('visa_path') + + self.cmd_str = '' + self._cmd = None + self._query = None + self.dev = chroma_61845.ChromaGridSim(visa_device=self.visa_device, + visa_path=self.visa_path) + self.dev.open() + self.dev.config() + self.profile_name = ts.param_value('profile.profile_name') + + state = self.relay() + output_state = self.output() + + if state != gridsim.RELAY_CLOSED or output_state != gridsim.OUTPUT_ON: + self.ts.log('Turning on grid simulator.') + self.output(state=gridsim.OUTPUT_ON) + self.relay(state=gridsim.RELAY_CLOSED) + + self.config() + + def cmd(self, cmd_str): + self.cmd_str = cmd_str + try: + self.dev.cmd(cmd_str) + resp = self.dev.query('SYSTem:ERRor?\n') #\r + if len(resp) > 0: + if resp[0] != '0': + raise gridsim.GridSimError(resp + ' ' + self.cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query(self, cmd_str): + try: + resp = self.dev.query(cmd_str).strip() + except Exception as e: + raise gridsim.GridSimError(str(e)) + return resp + + def info(self): + return self.query('*IDN?\n') + + def config_phase_angles(self): + self.dev.config_phase_angles(self.phases) + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('Grid simulator model: %s' % self.info().strip()) + + # put simulator in regenerative mode + state = self.regen() + if state != gridsim.REGEN_ON: + state = self.regen(gridsim.REGEN_ON) + self.ts.log('Grid sim regenerative mode is: %s' % state) + + #Device Specific Configuration + self.dev.config() + + # set the phase angles. + self.config_phase_angles() + + # set voltage range + self.dev.voltage_range(self.v_range_param) + + v_max = self.v_max_param + v1, v2, v3 = self.voltage_max() + if v1 != v_max or v2 != v_max or v3 != v_max: + self.voltage_max(voltage=(v_max, v_max, v_max)) + v1, v2, v3 = self.voltage_max() + self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) + + # set max current if it's not already at gridsim_Imax + i_max = self.i_max_param + current = self.current() + if current != i_max: + self.current(i_max) + current = self.current() + self.ts.log('Grid sim max current: %s Amps' % current) + + def open(self): + """ + Open the communications resources associated with the device. + """ + try: + self.dev.open() + + except Exception as e: + raise gridsim.GridSimError('Cannot open VISA connection to %s' % (self.visa_device)) + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + if self.dev is not None: + self.dev.close() + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + return self.dev.current(current) + + def current_max(self, current=None): + return self.current(current) + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + Chroma has CW or IMMediate options for the frequency. Need to figure out what these are. + """ + return self.dev.freq(freq) + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + if profile_name is None: + raise gridsim.GridSimError('Profile not specified.') + + if profile_name == 'Manual': # Manual reserved for not running a profile. + self.ts.log_warning('Manual reserved for not running a profile') + return + + v_nom = self.v_nom_param + freq_nom = self.freq_param + + # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step + if profile_name is 'Transient_Step': + if t_step is None: + raise gridsim.GridSimError('Transient profile did not have a duration.') + else: + # (time offset in seconds, % nominal voltage, % nominal frequency) + profile = [(0, v_step, f_step),(t_step, v_step, f_step),(t_step, 100, 100)] + + else: + # get the profile from grid_profiles + profile = grid_profiles.profiles.get(profile_name) + if profile is None: + raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) + + dwell_list = '' + v_start_list = '' + v_end_list = '' + freq_start_list = '' + freq_end_list = '' + func_list = '' + shape_list= '' + + for i in range(1, len(profile)-1): + v_start = float(profile[i - 1][1]) + v_end = float (profile[i]) + freq_start = float(profile[i - 1][2]) + freq_end = float(profile[i][2]) + dwelli = float(profile[i][0]) - float(profile[i - 1][0]) + dwelli = dwelli * 1000 #Chroma takes time in mS + + if i > 1: + dwell_list += ',' + v_start_list += ',' + v_end_list += ',' + freq_start_list += ',' + freq_end_list += ',' + shape_list += ',' + + v_start_list += v_start + v_end_list += v_end + freq_start_list += freq_start + freq_end_list += freq_end + dwell_list += dwelli + shape_list += 'A' + + #Attempt to move all SCPI commands to separate file for testing and extensibility. + cmd_list = self.dev.profile_load(dwell_list = dwell_list, + freq_start_list = freq_start_list, + freq_end_list = freq_end_list, + v_start_list=v_start_list, + v_end_list = v_end_list, + shape_list = shape_list) + self.profile = cmd_list + + # Loads the profile to instrument and starts. Assumes the output relay is already closed. + def profile_start(self): + """ + Start the loaded profile. + """ + if self.profile is not None: + for entry in self.profile: + self.dev.cmd(entry) + self.dev.relay(gridsim.RELAY_CLOSED) + + def profile_stop(self): + """ + Stop the running profile. + """ + self.dev.profile_stop() + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + Chroma has no option: Always On + """ + return gridsim.REGEN_ON + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + return self.dev.relay(state) + + def output(self,state=None): + return self.dev.output(state) + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + return self.dev.voltage(voltage) + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + """ + return self.dev.voltage_max(voltage) + + def voltage_slew(self,slew): + return self.dev.voltage_slew(slew) + + def freq_slew(self,slew): + return self.dev.voltage_slew(slew) + + def i_max(self): + return self.i_max_param + + def v_max(self): + return self.v_max_param + + def v_nom(self): + return self.v_nom_param + +if __name__ == "__main__": + import script + + d = {'gridsim.chroma.phases':'3', + 'gridsim.chroma.v_nom':'120.0', + 'gridsim.chroma.visa_path':'C:/Program Files (x86)/IVI Foundation/VISA/WinNT/agvisa/agbin/visa32.dll', + 'gridsim.chroma.visa_device':'USB0::0x0A69::0x086C::662040000329::0::INSTR'} + + + ''' + ts._param_value('phases') + self.v_nom_param = ts._param_value('v_nom') + self.v_max_param = ts._param_value('v_max') + self.i_max_param = ts._param_value('i_max') + self.freq_param = ts._param_value('freq') + self.comm = ts._param_value('comm') + self.visa_device = ts._param_value('visa_device') + self.visa_path = ts._param_value('visa_path') + ''' + + ts = script.Script(params = d) + + GridSim(ts) + GridSim.config() + pass \ No newline at end of file diff --git a/Lib/svpelab/gridsim_elgar704.py b/Lib/svpelab/gridsim_elgar704.py index d1045a2..c4a124c 100644 --- a/Lib/svpelab/gridsim_elgar704.py +++ b/Lib/svpelab/gridsim_elgar704.py @@ -1,444 +1,454 @@ - -import os -import grid_profiles -import gridsim - -elgar_info = {'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Elgar704' -} - -def gridsim_info(): - return elgar_info - -""" -This function set the parameter to be viewed in the sunspec SVP -""" -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = elgar_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) - info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) - info.param(pname('v_nom'), label='Nominal voltage for all phases', default=120.0) - info.param(pname('v_max'), label='Max Voltage', default=200.0) - info.param(pname('i_max'), label='Max Current', default=10.0) - info.param(pname('freq'), label='Frequency', default=60.0) - info.param(pname('comm'), label='Communications Interface', default='VISA',values=['GPIB','VISA']) - info.param(pname('gpib_device'), label='GPIB address', active=pname('comm'), active_value=['GPIB'], default='GPIB0::17::INSTR') - info.param(pname('visa_device'), label='VISA address', active=pname('comm'),active_value=['VISA'], default='GPIB0::17::INSTR') - - -GROUP_NAME = 'elgar' - -class GridSim(gridsim.GridSim): - """ - Elgar grid simulation implementation. - - Valid parameters: - mode - 'Elgar' - auto_config - ['Enabled', 'Disabled'] - v_nom - v_max - i_max - freq - profile_name - timeout - write_timeout - """ - def __init__(self, ts, group_name): - ts.log('Grid sim init') - # Resource Manager for VISA - self.rm = None - # Connection to instrument for VISA-GPIB - self.conn = None - gridsim.GridSim.__init__(self, ts, group_name) - - self.v_nom_param = self._param_value('v_nom') - self.v_max_param = self._param_value('v_max') - self.i_max_param = self._param_value('i_max') - self.freq_param = self._param_value('freq') - self.phases = self._param_value('phases') - - self.profile_name = ts.param_value('profile.profile_name') - self.comm = self._param_value('comm') - self.gpib_bus_address = self._param_value('gpib_bus_address') - self.gpib_board = self._param_value('gpib_board') - self.visa_device = self._param_value('visa_device') - self.cmd_str = '' - self.cmd_str = '' - self._cmd = None - self._query = None - # open communications, not the relay and stop profile - self.open() - - - if self.auto_config == 'Enabled': - ts.log('Configuring the Grid Simulator.') - self.config() - - # Configure grid simulator at beginning of test = auto_config - # Follow the Power ON/OFF sequence (p.3-4 Manual Addendum) - # Config implemented with ABLE command - # if self.auto_config == 'Enabled': - # ts.log('Configuring the Grid Simulator.') - # self.config() - # - # state = self.relay() - # if ts.confirm('Please turn ON the output by pressing on (Output ON/OFF) push button on the Grid simulator') is False: - # raise gridsim.GridSimError('Aborted grid simulation') - # else: - # TODO : Here is where we can add the AC switch control - # self.ts.log('Grid is energize.') - # if state != gridsim.RELAY_CLOSED: - # if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: - # raise gridsim.GridSimError('Aborted grid simulation') - # else: - # self.ts.log('Turning on grid simulator.') - # self.relay(state=gridsim.RELAY_CLOSED) - - - if self.profile_name is not None and self.profile_name != 'Manual': - self.profile_load(self.v_nom_param, self.freq_param, self.profile_name) - - def _param_value(self, name): - return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) - - - def info(self): - # Search for ABLE equivalent - Not tested - - info_txt = 'Elgar 704 Grid simulator' - return info_txt - - # Missing the method regen() to be implemented - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - - # self.ts.log('Grid simulator model: %s' % self.info().strip()) - self.ts.log('CanmetEnergy Grid simulator') - - # put simulator in regenerative mode - - # state = self.regen() - # if state != gridsim.REGEN_ON: - # state = self.regen(gridsim.REGEN_ON) - # # self.ts.log('Grid sim regenerative mode is: %s' % state) - self.ts.log('Grid sim regenerative mode is not yet implemented for ELGAR704') - phases = self.phases - # set the phase angles for the 3 phases - self.config_phase_angles(phases) - - # set voltage range - self.ts.log('Grid sim can`t set voltage range') - # v_max = self.v_max_param - # self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v_max, v_max, v_max)) - # - # v1, v2, v3 = self.voltage_max() - # if v1 != v_max or v2 != v_max or v3 != v_max: - # self.voltage_max(voltage=(v_max, v_max, v_max)) - # v1, v2, v3 = self.voltage_max() - # self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - - # set nominal voltage - - self.ts.log('Grid sim nominal voltage settings: v1 = {}, v2 = {}, v3 = {}'.format(self.v_nom_param, self.v_nom_param, self.v_nom_param)) - # v_nom = self.v1_nom_param - # v1, v2, v3 = self.voltage() - # if v1 != v_nom or v2 != v_nom or v3 != v_nom: - # if phases == 1 : - self.voltage(voltage=(self.v_nom_param, self.v_nom_param, self.v_nom_param)) - # v1, v2, v3 = self.voltage() - - # set the frequency - self.ts.log('Frequency set to {} Hz'.format(self.freq_param)) - self.freq(self.freq_param) - - - # set max current if it's not already at gridsim_Imax - i_max = self.i_max_param - self.ts.log('Grid sim current limit settings : {} A'.format(self.i_max_param)) - - # i1, i2, i3 = self.current() - - # if i1 != i_max or i2 != i_max or i3 != i_max: - self.current_max(current=(i_max, i_max, i_max)) - # i1,i2,i3 = self.current() - - - self.ts.log('Grid sim configured') - - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - self.ts.log('Gridsim Open') - try: - if self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + - 'Please use VISA which supports also GPIB devices') - elif self.comm == 'VISA': - try: - # sys.path.append(os.path.normpath(self.visa_path)) - import visa - self.rm = visa.ResourceManager() - self.conn = self.rm.open_resource(self.visa_device) - self.ts.log('Gridsim Visa config') - # TODO : Add the connection for AWG430 - # the default pyvisa write termination is '\r\n' work with the ELGAR704 (p.3-2 Manual Addendum) - #self.conn.write_termination = '\r\n' - - self.ts.sleep(1) - - except Exception, e: - raise gridsim.GridSimError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) - - else: - raise ValueError('Unknown communication type %s. Use GPIB or VISA' % self.comm) - - self.ts.sleep(2) - - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def cmd(self, cmd_str): - try: - self.conn.write(cmd_str) - except Exception, e: - raise - - def query(self, cmd_str): - self.cmd(cmd_str) - resp = self.conn.read() - return resp - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.comm == 'Serial': - self.conn.close() - elif self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet.') - elif self.comm == 'VISA': - try: - if self.rm is not None: - if self.conn is not None: - self.conn.close() - self.rm.close() - - self.ts.sleep(1) - except Exception, e: - raise gridsim.GridSimError(str(e)) - else: - raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) - - # ABLE command add it - def config_phase_angles(self,pang =None): - if pang == 1: - self.ts.log_debug('Configuring system for single phase.') - # phase 1 always 'preconfigured' at 0 phase angle - self.cmd('PANGA 0') - # self.form(1) - UNSUPPORTED - elif pang== 2: - # set the phase angles for split phase - self.ts.log_debug('Configuring system for split phase on Phases A & B.') - self.cmd('PANGB 180.0') - # self.form(2) - UNSUPPORTED - elif pang== 3: - # set the phase angles for the 3 phases - self.ts.log_debug('Configuring system for three phase.') - self.cmd('PANGB 120.0') - self.cmd('PANGB 240.0') - # self.form(3) - UNNECESSARY BECAUSE IT IS THE DEFAULT - else: - raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.pang)) - - - # ABLE command add it - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if voltage is not None: - # set output voltage on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(voltage) is not list and type(voltage) is not tuple: - - self.cmd('VOLTA {}'.format(voltage[0])) - self.cmd('VOLTB {}'.format(voltage[1])) - self.cmd('VOLTC {}'.format(voltage[2])) - else: - self.cmd('VOLTS %0.1f' % voltage[0]) # use the first value in the 3 phase list - - return - - # ABLE command add it - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - if voltage is not None: - voltage = max(voltage) # voltage is a triplet but Elgar only takes one value - # TODO : Check if it matches with ELGAR 704 - if voltage == 132 : - self.cmd('VOLTS %0.0f' % voltage) - else: - raise gridsim.GridSimError('Invalid Max Voltage %s V, must be 132 V.' % str(voltage)) - v1 = 120.0 - v2 = 120.0 - v3 = 120.0 - # TODO : See why TST VA,VB,VC don't work - # v1 = self.query('TST VA') - # v2 = self.query('TST VB') - # v3 = self.query('TST VC') - return - - # ABLE command add it - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - if current is not None: - # set output current limit on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(current) is not list and type(current) is not tuple: - self.cmd('CURLA {}'.format(current[0])) - self.cmd('CURLB {}'.format(current[1])) - self.cmd('CURLC {}'.format(current[2])) - else: - self.cmd('CURLS {}'.format(current[0])) # use the first value in the 3 phase list - # i1 = self.query('TST IA') - # i2 = self.query('TST IB') - # i3 = self.query('TST IC') - return - - # ABLE command add it - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max voltage. - """ - if current is not None: - # set output current limit on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(current) is not list and type(current) is not tuple: - self.cmd('CURLA {}'.format(current[0])) - self.cmd('CURLB {}'.format(current[1])) - self.cmd('CURLC {}'.format(current[2])) - else: - self.cmd('CURLS {}'.format(current[0])) # use the first value in the 3 phase list - # i1 = self.query('TST IA') - # i2 = self.query('TST IB') - # i3 = self.query('TST IC') - return - - # ABLE command add it - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - self.cmd('FREQ {}'.format(freq)) - # freq = self.query('TST FR') - return freq - - # Not implemented yet - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - - return - - # Not implemented yet - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - for entry in self.profile: - self.cmd(entry) - - # Not implemented yet - def profile_stop(self): - """ - Stop the running profile. - """ - self.cmd('abort') - - # Not implemented yet - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - All this was implemented for the AMETEK not the ELGAR - """ - # TODO : Check if we can implement a REGEN function for the elgar 704 - - return state - - # ABLE command add it but need to test TST CLS - # TODO : Add a function to test the state of the relay - def relay_close(self): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - # This command doesn't affect the output, need to implement remote control AC switch - self.cmd('CLS') - self.ts.log('Closed Relay') - - def relay_open(self): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - # This command doesn't affect the output, need to implement remote control AC switch - self.cmd('OPN') - self.ts.log('Opened Relay') - - def distortion(self, state=None): - """ - This command listed in paragraphs are used to program an 8% distortion - """ - # if state is not None: - if state == 'ON': - self.cmd('DIST0') - elif state == gridsim.RELAY_CLOSED: - self.cmd('DISTO1') - else: - raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) - self.ts.log_warning('This equipment does not have a regenerative mode.') - state == gridsim.REGEN_OFF - return state - - def aberration(self, freq=None, voltage=None, cycles=None): - - # keep frequency between 50 and 70 Hz even though the maximum are 45 and 1000 Hz - if freq is not none and voltage is not none and cycles: - self.cmd('ABBR W {}, V {}, F {}'.format(cycles, voltage, freq)) - else: - raise gridsim.GridSimError('Invalid parameters for aberration function') - - return 0 - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v1_nom_param - - -if __name__ == "__main__": - pass \ No newline at end of file + +import os +from . import grid_profiles +from . import gridsim +from . import wavegen +from . import switch +import collections + +elgar_info = {'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Elgar704' +} + +def gridsim_info(): + return elgar_info + +""" +This function set the parameter to be viewed in the sunspec SVP +""" +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = elgar_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=120.0) + info.param(pname('v_max'), label='Max Voltage', default=200.0) + info.param(pname('i_max'), label='Max Current', default=10.0) + info.param(pname('freq'), label='Frequency', default=60.0) + info.param(pname('comm'), label='Communications Interface', default='VISA',values=['GPIB','VISA','WAVEGEN']) + info.param(pname('gpib_device'), label='GPIB address', active=pname('comm'), active_value=['GPIB'], default='GPIB0::17::INSTR') + info.param(pname('visa_device'), label='VISA address', active=pname('comm'),active_value=['VISA'], default='GPIB0::17::INSTR') + wavegen.params(info, group_name=group_name, active=pname('comm'), active_value=['WAVEGEN']) + switch.params(info, group_name=group_name, active=gname('mode'), active_value=mode) + +GROUP_NAME = 'elgar' + +class GridSim(gridsim.GridSim): + """ + Elgar grid simulation implementation. + + Valid parameters: + mode - 'Elgar' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + timeout + write_timeout + """ + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + ts.log('Grid sim init') + self.rm = None + self.conn = None + self.v_nom_param = self._param_value('v_nom') + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.phases = self._param_value('phases') + self.profile_name = ts.param_value('profile.profile_name') + self.comm = self._param_value('comm') + self.gpib_bus_address = self._param_value('gpib_bus_address') + self.gpib_board = self._param_value('gpib_board') + self.visa_device = self._param_value('visa_device') + self.cmd_str = '' + self.cmd_str = '' + self.wg = wavegen.wavegen_init(ts, group_name=group_name) + + #self.sw = switch.switch_init(ts, group_name=group_name) + self._cmd = None + self._query = None + + # open communications, not the relay and stop profile + self.open() + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() + + if self.profile_name is not None and self.profile_name != 'Manual': + self.profile_load(self.v_nom_param, self.freq_param, self.profile_name) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def info(self): + if self.comm == 'VISA': + info_txt = 'Grid simulator using Elgar 704 interface' + elif self.comm == 'WAVEGEN': + info_txt = self.wg.info() + return info_txt + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('CanmetEnergy Grid simulator') + self.ts.log("Grid simulator don't have REGEN mode") + + # TODO : It can be set for HIL wavegen + # set voltage range + self.ts.log('Grid sim can`t set voltage range') + + phases = self.phases + # set the phase angles for the 3 phases + self.phases_angles(phases) + + # set nominal voltage according to phase + + if phases == 1: + volt_config = [self.v_nom_param,0.0,0.0] + self.ts.log('Grid sim nominal voltage settings: v1 = {}'.format(volt_config[0])) + self.voltage(voltage=volt_config) + elif phases == 2: + volt_config = [self.v_nom_param, self.v_nom_param, 0.0] + self.ts.log('Grid sim nominal voltage settings: v1 = {}, v2 = {}'.format(volt_config[0],volt_config[1])) + self.voltage(voltage=volt_config) + elif phases == 3: + volt_config = [self.v_nom_param, self.v_nom_param, self.v_nom_param] + self.ts.log('Grid sim nominal voltage settings: v1 = {}, v2 = {}, v3 = {}'.format(volt_config[0],volt_config[1],volt_config[2])) + self.voltage(voltage=volt_config) + else: + raise gridsim.GridSimError('Unsupported phase parameter: %s' % phases) + + # set the frequency + self.ts.log('Frequency set to {} Hz'.format(self.freq_param)) + self.freq(self.freq_param) + + # set max current if it's not already at gridsim_Imax + i_max = self.i_max_param + self.ts.log('Grid sim current limit settings : {} A'.format(self.i_max_param)) + + # if i1 != i_max or i2 != i_max or i3 != i_max: + self.current_max(current=(i_max, i_max, i_max)) + # i1,i2,i3 = self.current() + + + self.relay_close() + + self.ts.log('Grid sim configured') + + + + + + def open(self): + """ + Open the communications resources associated with the grid simulator. + """ + self.ts.log('Gridsim Open') + try: + if self.comm == 'GPIB': + raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + + 'Please use VISA which supports also GPIB devices') + elif self.comm == 'VISA': + try: + # sys.path.append(os.path.normpath(self.visa_path)) + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(self.visa_device) + self.ts.log('Gridsim Visa config') + + except Exception as e: + raise gridsim.GridSimError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) + elif self.comm == 'WAVEGEN': + try: + self.wg.open() + except Exception as e: + raise gridsim.GridSimError('Cannot open Wavegen connection : \n\t%s' % (str(e))) + else: + raise ValueError('Unknown communication type %s. Use GPIB or VISA' % self.comm) + + + + self.ts.sleep(2) + + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def cmd(self, cmd_str): + try: + self.conn.write(cmd_str) + except Exception as e: + raise + + def query(self, cmd_str): + self.cmd(cmd_str) + resp = self.conn.read() + return resp + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + self.voltage(voltage=(120.0, 120.0, 120.0)) + # self.relay_open() + #self.voltage () + if self.comm == 'Serial': + self.conn.close() + elif self.comm == 'GPIB': + raise NotImplementedError('The driver for plain GPIB is not implemented yet.') + elif self.comm == 'VISA': + try: + if self.rm is not None: + if self.conn is not None: + self.conn.close() + # self.rm.close() + + self.ts.sleep(1) + except Exception as e: + raise gridsim.GridSimError(str(e)) + elif self.comm == 'WAVEGEN': + try: + self.wg.close() + except Exception as e: + raise gridsim.GridSimError('Cannot close Wavegen connection : \n\t%s' % (str(e))) + else: + raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) + + # ABLE command add it + def phases_angles(self, pang =None, params=None): + if self.comm == 'VISA': + if pang == 1: + self.ts.log_debug('Configuring system for single phase.') + # phase 1 always 'preconfigured' at 0 phase angle + self.cmd('PANGA 0') + # self.form(1) - UNSUPPORTED + elif pang== 2: + # set the phase angles for split phase + self.ts.log_debug('Configuring system for split phase on Phases A & B.') + self.cmd('PANGB 180.0') + # self.form(2) - UNSUPPORTED + elif pang== 3: + # set the phase angles for the 3 phases + self.ts.log_debug('Configuring system for three phase.') + self.cmd('PANGB 120.0') + self.cmd('PANGB 240.0') + # self.form(3) - UNNECESSARY BECAUSE IT IS THE DEFAULT + elif self.comm == 'WAVEGEN': + if pang is not None: + if pang == 1: + self.wg.phase(channel=1, phase=0) + elif pang == 2: + self.wg.phase(channel=1, phase=0) + self.wg.phase(channel=2, phase=180) + elif pang == 3: + self.wg.phase(channel=1, phase=0) + self.wg.phase(channel=2, phase=120) + self.wg.phase(channel=3, phase=240) + else: + raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.pang)) + + + # ABLE command add it + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if self.comm == 'VISA': + if voltage is not None: + if type(voltage) is not list and type(voltage) is not tuple: + self.cmd('VOLTS {}' % voltage[0]) # use the first value in the 3 phase list + else: + self.cmd('VOLTA {}'.format(voltage[0])) + self.cmd('VOLTB {}'.format(voltage[1])) + self.cmd('VOLTC {}'.format(voltage[2])) + elif self.comm == 'WAVEGEN': + if voltage is not None and voltage is dict: + for phase,magnitude in params.items(): + self.wg.voltage(channel=phase, voltage=magnitude) + else: + if type(voltage) is not list and type(voltage) is not tuple: + self.wg.voltage(channel =1, voltage=voltage) + self.wg.voltage(channel =2, voltage=voltage) + self.wg.voltage(channel =3, voltage=voltage) + else: + self.wg.voltage(channel=1, voltage=voltage[0]) + self.wg.voltage(channel=2, voltage=voltage[1]) + self.wg.voltage(channel=3, voltage=voltage[2]) + return voltage + + + # ABLE command add it + def voltage_max(self, voltage=None): + + if voltage is not None: + voltage = max(voltage) # voltage is a triplet but Elgar only takes one value + if voltage == 130: + self.cmd('VOLTS %0.0f' % voltage) + else: + raise gridsim.GridSimError('Invalid Max Voltage %s V, must be 132 V.' % str(voltage)) + + return + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + self.ts.log_debug('Unsupported by Elgar 704') + + return + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max voltage. + """ + if self.comm == 'VISA': + if current is not None: + # set output current limit on all phases + # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) + if type(current) is not list and type(current) is not tuple: + self.cmd('CURLA {}'.format(current[0])) + self.cmd('CURLB {}'.format(current[1])) + self.cmd('CURLC {}'.format(current[2])) + else: + self.cmd('CURLS {}'.format(current[0])) # use the first value in the 3 phase list + return current + if self.comm == 'WAVEGEN': + return current + + def config_asymmetric_phase_angles(self, mag=None, angle=None): + """ + :param mag: list of voltages for the imbalanced test, e.g., [277.2, 277.2, 277.2] + :param angle: list of phase angles for the imbalanced test, e.g., [0, 120, -120] + :returns: voltage list and phase list + """ + if self.phases == 3: + if self.comm == 'WAVEGEN': + self.wg.config_asymmetric_phase_angles(mag=mag, angle=angle) + else: + raise gridsim.GridSimError('Invalid phase configuration for config_asymmetric_phase_angles() function. Should be configured as three-phase system (Phase = "%s)"', self.phases) + + + return None, None + # ABLE command add it + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if self.comm == 'VISA': + if freq is not None: + self.cmd('FREQ {}'.format(freq)) + # freq = self.query('TST FR') + return freq + if self.comm == 'WAVEGEN': + self.wg.frequency(freq) + + + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + if self.comm == 'VISA': + return profile_name + if self.comm == 'WAVEGEN': + return profile_name + + + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.comm == 'WAVEGEN': + self.wg.start() + + + # Not implemented yet + def profile_stop(self): + """ + Stop the running profile. + """ + self.cmd('abort') + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + All this was implemented for the AMETEK not the ELGAR + """ + self.ts.log_debug('Invalid function the grid simulator does not have regeneration capabilities') + return state + + def relay_close(self): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + # TODO : Add the function of the AC switch driver + if self.comm == 'VISA': + self.cmd('CLS') + self.ts.log('Closed Relay') + elif self.comm == 'WAVEGEN': + if self.phases == 1: + self.wg.chan_state(chans=[True, False, False]) + elif self.phases == 2: + self.wg.chan_state(chans=[True, True, False]) + elif self.phases == 3: + self.wg.chan_state(chans=[True, True, True]) + + def relay_open(self): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + # TODO : Add the function of the AC switch driver + self.cmd('OPN') + self.ts.log('Opened Relay') + + def distortion(self, state=None): + """ + This command listed in paragraphs are used to program an 8% distortion + """ + if state == True: + self.cmd('DIST0') + elif state == False: + self.cmd('DISTO1') + else: + raise gridsim.GridSimError('Invalid relay state. State = "%s" . Try True or False', state) + return state + + def aberration(self, freq=None, voltage=None, cycles=None): + """ + This command is only for creating a voltage or frequency ride-through + """ + if freq is not None and voltage is not None and cycles is not None: + if freq >= 45 or freq <= 1000 or voltage >= 0 or voltage <= 200 or cycles >= 1 or cycles <= 999 : + self.cmd('ABBRS {}, V {}, F {}'.format(cycles, voltage, freq)) + else : + raise gridsim.GridSimError('Invalid parameters for aberration function') + else: + raise gridsim.GridSimError('Invalid parameters for aberration function') + return + + def i_max(self): + return self.i_max_param + + def v_max(self): + return self.v_max_param + + def v_nom(self): + return self.v1_nom_param + + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/gridsim_elgar704_3.py b/Lib/svpelab/gridsim_elgar704_3.py deleted file mode 100644 index 4475ea1..0000000 --- a/Lib/svpelab/gridsim_elgar704_3.py +++ /dev/null @@ -1,409 +0,0 @@ - -import os -import gridsim - -elgar_info = {'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Elgar704_3' -} - -def gridsim_info(): - return elgar_info - -""" -This function set the parameter to be viewed in the sunspec SVP -""" -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = elgar_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) - info.param(pname('v1_nom'), label='EUT nominal voltage for phase A', default=120.0) - info.param(pname('v2_nom'), label='EUT nominal voltage for phase B', default=120.0) - info.param(pname('v3_nom'), label='EUT nominal voltage for phase C', default=120.0) - info.param(pname('i1_curl'), label='Grid current limiter for phase A', default=120.0) - info.param(pname('i2_curl'), label='Grid current limiter for phase B', default=120.0) - info.param(pname('i3_curl'), label='Grid current limiter for phase C', default=120.0) - - info.param(pname('freq'), label='Frequency', default=60.0) - info.param(pname('comm'), label='Communications Interface', default='VISA',values=['GPIB','VISA']) - info.param(pname('gpib_device'), label='GPIB address', active=pname('comm'), active_value=['GPIB'], default='GPIB0::17::INSTR') - info.param(pname('visa_device'), label='VISA address', active=pname('comm'),active_value=['VISA'], default='GPIB0::17::INSTR') - info.param(pname('comm_wave'), label='Analog Communications Interface', default='VISA', values=['GPIB', 'VISA']) - info.param(pname('visa_device_wave'), label='VISA address', active=pname('comm_wave'),active_value=['VISA'],default='GPIB0::2::INSTR') - -GROUP_NAME = 'elgar_3' - -class GridSim(gridsim.GridSim): - - def __init__(self, ts, group_name): - ts.log('Grid sim init') - # Resource Manager for VISA - self.rm = None - # Connection to instrument for VISA-GPIB - self.conn = None - gridsim.GridSim.__init__(self, ts, group_name) - self.phases_param = ts._param_value('phases') - self.v1_nom_param = ts._param_value('v1_nom') - self.v2_nom_param = ts._param_value('v2_nom') - self.v3_nom_param = ts._param_value('v3_nom') - self.i1_curl = ts._param_value('i1_curl') - self.i2_curl = ts._param_value('i2_curl') - self.i3_curl = ts._param_value('i3_curl') - self.freq_param = ts._param_value('freq') - self.profile_name = ts.param_value('profile.profile_name') - self.comm = ts._param_value('comm') - self.gpib_bus_address = ts._param_value('gpib_bus_address') - self.gpib_board = ts._param_value('gpib_board') - self.visa_device = ts._param_value('visa_device') - self.cmd_str = '' - - # open communications, not the relay and stop profile - self.open() - - self.profile_stop() - - # Configure grid simulator at beginning of test = auto_config - # Follow the Power ON/OFF sequence (p.3-4 Manual Addendum) - # Config implemented with ABLE command - # if self.auto_config == 'Enabled': - # ts.log('Configuring the Grid Simulator.') - # self.config() - # - # state = self.relay() - # if ts.confirm('Please turn ON the output by pressing on (Output ON/OFF) push button on the Grid simulator') is False: - # raise gridsim.GridSimError('Aborted grid simulation') - # else: - # TODO : Here is where we can add the AC switch control - # self.ts.log('Grid is energize.') - # if state != gridsim.RELAY_CLOSED: - # if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: - # raise gridsim.GridSimError('Aborted grid simulation') - # else: - # self.ts.log('Turning on grid simulator.') - # self.relay(state=gridsim.RELAY_CLOSED) - - if self.profile_name is not None and self.profile_name != 'Manual': - self.profile_load(self.v1_nom_param, self.freq_param, self.profile_name) - - - - - # Search for ABLE equivalent - Not tested - - def info(self): - # self.ts.log('CanmetEnergy Grid simulator' ) - return - - # Missing the method regen() to be implemented - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - - # self.ts.log('Grid simulator model: %s' % self.info().strip()) - self.ts.log('CanmetEnergy Grid simulator') - - # put simulator in regenerative mode - - # state = self.regen() - # if state != gridsim.REGEN_ON: - # state = self.regen(gridsim.REGEN_ON) - # # self.ts.log('Grid sim regenerative mode is: %s' % state) - self.ts.log('Grid sim regenerative mode is not yet implemented for ELGAR704') - - # set the phase angles for the 3 phases - self.config_phase_angles() - - # set voltage range - self.ts.log('Grid sim can`t set voltage range') - # v_max = self.v_max_param - # self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v_max, v_max, v_max)) - # - # v1, v2, v3 = self.voltage_max() - # if v1 != v_max or v2 != v_max or v3 != v_max: - # self.voltage_max(voltage=(v_max, v_max, v_max)) - # v1, v2, v3 = self.voltage_max() - # self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - - # set nominal voltage - - self.ts.log('Grid sim nominal voltage settings: v1 = {}, v2 = {}, v3 = {}'.format(self.v1_nom_param, self.v2_nom_param, self.v3_nom_param)) - # v_nom = self.v1_nom_param - # v1, v2, v3 = self.voltage() - # if v1 != v_nom or v2 != v_nom or v3 != v_nom: - self.voltage(voltage=(self.v1_nom_param, self.v2_nom_param, self.v3_nom_param)) - # v1, v2, v3 = self.voltage() - - # set the frequency - self.ts.log('Frequency set to {} Hz'.format(self.freq_param)) - self.freq(self.freq_param) - - - # set max current if it's not already at gridsim_Imax - self.ts.log('Grid sim current limit settings : curl_1 : ') - # i_max = self.i_max_param - # i1, i2, i3 = self.current() - ''' ### - if i1 != i_max or i2 != i_max or i3 != i_max: - self.current(current=(i_max, i_max, i_max)) - i1,i2,i3 = self.current() - ''' - - self.ts.log('Grid sim configured') - - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - self.ts.log('Gridsim Open') - try: - if self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + - 'Please use VISA which supports also GPIB devices') - elif self.comm == 'VISA': - try: - # sys.path.append(os.path.normpath(self.visa_path)) - import visa - self.rm = visa.ResourceManager() - self.conn = self.rm.open_resource(self.visa_device) - self.ts.log('Gridsim Visa config') - # TODO : Add the connection for AWG430 - # the default pyvisa write termination is '\r\n' work with the ELGAR704 (p.3-2 Manual Addendum) - #self.conn.write_termination = '\r\n' - - self.ts.sleep(1) - - except Exception, e: - raise gridsim.GridSimError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) - - else: - raise ValueError('Unknown communication type %s. Use GPIB or VISA' % self.comm) - - self.ts.sleep(2) - - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def cmd(self, cmd_str): - try: - self.conn.write(cmd_str) - except Exception, e: - raise - - def query(self, cmd_str): - self.cmd(cmd_str) - resp = self.conn.read() - return resp - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.comm == 'Serial': - self.conn.close() - elif self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet.') - elif self.comm == 'VISA': - try: - if self.rm is not None: - if self.conn is not None: - self.conn.close() - self.rm.close() - - self.ts.sleep(1) - except Exception, e: - raise gridsim.GridSimError(str(e)) - else: - raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) - - # ABLE command add it - def config_phase_angles(self,pang =None): - if self.phases_param == 1: - self.ts.log_debug('Configuring system for single phase.') - # phase 1 always 'preconfigured' at 0 phase angle - self.cmd('PANGA 0') - # self.form(1) - UNSUPPORTED - elif self.phases_param == 2: - # set the phase angles for split phase - self.ts.log_debug('Configuring system for split phase on Phases A & B.') - self.cmd('PANGB 180.0') - # self.form(2) - UNSUPPORTED - elif self.phases_param == 3: - # set the phase angles for the 3 phases - self.ts.log_debug('Configuring system for three phase.') - self.cmd('PANGB 120.0') - self.cmd('PANGB 240.0') - # self.form(3) - UNNECESSARY BECAUSE IT IS THE DEFAULT - else: - raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.phases_param)) - - - # ABLE command add it - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if voltage is not None: - # set output voltage on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(voltage) is not list and type(voltage) is not tuple: - self.cmd('VOLTA {}'.format(voltage[0])) - self.cmd('VOLTB {}'.format(voltage[1])) - self.cmd('VOLTC {}'.format(voltage[2])) - else: - self.cmd('VOLTS %0.1f' % voltage[0]) # use the first value in the 3 phase list - - # TODO : See why TST VA,VB,VC don't work - # v1 = self.query('TST VA') - # v2 = self.query('TST VB') - # v3 = self.query('TST VC') - # return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - return - - # ABLE command add it - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - if voltage is not None: - voltage = max(voltage) # voltage is a triplet but Elgar only takes one value - # TODO : Check if it matches with ELGAR 704 - if voltage == 132 : - self.cmd('VOLTS %0.0f' % voltage) - else: - raise gridsim.GridSimError('Invalid Max Voltage %s V, must be 132 V.' % str(voltage)) - v1 = 120.0 - v2 = 120.0 - v3 = 120.0 - # TODO : See why TST VA,VB,VC don't work - # v1 = self.query('TST VA') - # v2 = self.query('TST VB') - # v3 = self.query('TST VC') - return - - # ABLE command add it - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - if current is not None: - # set output current limit on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(current) is not list and type(current) is not tuple: - self.cmd('CURLA {}'.format(current[0])) - self.cmd('CURLB {}'.format(current[1])) - self.cmd('CURLC {}'.format(current[2])) - else: - self.cmd('CURLS {}'.format(current[0])) # use the first value in the 3 phase list - # i1 = self.query('TST IA') - # i2 = self.query('TST IB') - # i3 = self.query('TST IC') - return - - # ABLE command add it - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max voltage. - """ - if current is not None: - current = max(current) # current is a triplet but Elgar only takes one value - # TODO : Check if it matches with ELGAR 704 - if current == 10 : - self.cmd('CURLS %0.0f' % current) - - else: - raise gridsim.GridSimError('Invalid Max Voltage %s V, must be 132 V.' % str(current)) - i1 = self.query('TST IA') - i2 = self.query('TST IB') - i3 = self.query('TST IC') - return float(i1[:-1]), float(i2[:-1]), float(i3[:-1]) - - # ABLE command add it - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - self.cmd('FREQ {}'.format(freq)) - # freq = self.query('TST FR') - return freq - - # Not implemented yet - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - - return - - # Not implemented yet - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - for entry in self.profile: - self.cmd(entry) - - # Not implemented yet - def profile_stop(self): - """ - Stop the running profile. - """ - self.cmd('abort') - - # Not implemented yet - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - All this was implemented for the AMETEK not the ELGAR - """ - # TODO : Check if we can implement a REGEN function for the elgar 704 - - return state - - # ABLE command add it but need to test TST CLS - # TODO : Add a function to test the state of the relay - def relay_close(self): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - # This command doesn't affect the output, need to implement remote control AC switch - self.cmd('CLS') - self.ts.log('Closed Relay') - - def relay_open(self): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - # This command doesn't affect the output, need to implement remote control AC switch - self.cmd('OPN') - self.ts.log('Opened Relay') - - def distorsion(self): - pass - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v1_nom_param - - -if __name__ == "__main__": - pass \ No newline at end of file diff --git a/Lib/svpelab/gridsim_manual.py b/Lib/svpelab/gridsim_manual.py index 8ec8009..7251d13 100644 --- a/Lib/svpelab/gridsim_manual.py +++ b/Lib/svpelab/gridsim_manual.py @@ -32,7 +32,8 @@ import os -import gridsim +from . import wavegen +from . import gridsim manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -42,19 +43,53 @@ def gridsim_info(): return manual_info + def params(info, group_name): gname = lambda name: group_name + '.' + name pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name mode = manual_info['mode'] info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, + glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=120.0) + info.param(pname('v_max'), label='Max Voltage', default=200.0) + info.param(pname('i_max'), label='Max Current', default=10.0) + info.param(pname('freq'), label='Frequency', default=60.0) + info.param(pname('comm'), label='Communications Interface', default='VISA', values=['GPIB', 'VISA', 'WAVEGEN']) + info.param(pname('gpib_device'), label='GPIB address', active=pname('comm'), active_value=['GPIB'], + default='GPIB0::17::INSTR') + info.param(pname('visa_device'), label='VISA address', active=pname('comm'), active_value=['VISA'], + default='GPIB0::17::INSTR') + GROUP_NAME = 'manual' class GridSim(gridsim.GridSim): - def __init__(self, ts, group_name, params=None): - gridsim.GridSim.__init__(self, ts, group_name, params) + def __init__(self, ts, group_name, params=None, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, params, support_interfaces=support_interfaces) if ts.confirm('Please run the grid simulator profile.') is False: raise gridsim.GridSimError('Aborted grid simulation') + + ts.log('Grid sim init') + self.v_nom_param = self._param_value('v_nom') + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.phases = self._param_value('phases') + self.profile_name = ts.param_value('profile.profile_name') + self.comm = self._param_value('comm') + self.gpib_bus_address = self._param_value('gpib_bus_address') + self.gpib_board = self._param_value('gpib_board') + self.visa_device = self._param_value('visa_device') + self.cmd_str = '' + self.cmd_str = '' + + self._cmd = None + self._query = None + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) \ No newline at end of file diff --git a/Lib/svpelab/gridsim_opal.py b/Lib/svpelab/gridsim_opal.py new file mode 100644 index 0000000..f15ca06 --- /dev/null +++ b/Lib/svpelab/gridsim_opal.py @@ -0,0 +1,401 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" +import os +from . import gridsim +import math as m +import pandas as pd +opal_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Opal' +} + + +def gridsim_info(): + return opal_info + + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = opal_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=120.0) + info.param(pname('freq'), label='Frequency', default=60.0) + + info.param(pname('v_max'), label='Max Voltage', default=300.0) + info.param(pname('f_max'), label='Max Frequency', default=70.0) + info.param(pname('f_min'), label='Min Frequency', default=45.0) + + +GROUP_NAME = 'opal' + + +class GridSim(gridsim.GridSim): + """ + Opal grid simulation implementation. + + Valid parameters: + mode - 'Opal' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + """ + + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + + self.ts = ts + self.p_nom = self._param_value('p_nom') + self.v_nom = self._param_value('v_nom') + self.v = self.v_nom + + # for asymmetric voltage tests + self.v1 = self.v_nom + self.v2 = self.v_nom + self.v3 = self.v_nom + + self.f_nom = self._param_value('freq') + self.phases = self._param_value('phases') + + self.v_max = self._param_value('v_max') + self.f_max = self._param_value('f_max') + self.f_min = self._param_value('f_min') + + # Hil configuration + if self.hil is None: + gridsim.GridSimError('GridSim config requires a Opal HIL object') + else: + self.ts.log_debug(f'Configuring gridsim with Opal hil parameters...using {self.hil.info()}') + self.ts.log_debug(f'hil object : {self.hil}') + self.model_name = self.hil.rt_lab_model + self.rt_lab_model_dir = self.hil.rt_lab_model_dir + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def gridsim_info(self): + return opal_info + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('Configuring phase angles, frequencies, and voltages for gridsim') + self.config_phase_angles() + self.freq(freq=self.f_nom) + self.voltage(voltage=self.v_nom) + + # Saturation at the waveform level + self.frequency_max(frequency=self.f_max) + self.frequency_min(frequency=self.f_min) + self.voltage_max(voltage=self.v_max) + self.voltage_min(voltage=0.0) + + def config_phase_angles(self): + + """ + Set the phase angles for the simulation + + :return: None + """ + + parameters = [] + # set the phase angles for the 3 phases + if self.phases == 1: + parameters.append(("PHASE_PHA", 0)) + + elif self.phases == 2: + parameters.append(("PHASE_PHA", 0)) + parameters.append(("PHASE_PHB", 180)) + + elif self.phases == 3: + parameters.append(("PHASE_PHA", 0)) + parameters.append(("PHASE_PHB", -120)) + parameters.append(("PHASE_PHC", 120)) + + else: + raise gridsim.GridSimError('Unsupported phase parameter: %s' % self.phases) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["PHASE_PHA", "PHASE_PHB", "PHASE_PHC"]) + + return parameters + + def config_asymmetric_phase_angles(self, mag=None, angle=None): + """ + :param mag: list of voltages for the imbalanced test, e.g., [277.2, 277.2, 277.2] + :param angle: list of phase angles for the imbalanced test, e.g., [0, 120, -120] + :returns: voltage list and phase list + """ + parameters = [] + i = 0 + # TODO : To be replace by sending a matlab variable list, else this code might create problems (current on neutral, etc.) + for volt_block in self.voltage_block_list: + # self.ts.log_debug('self.model_name = %s' % (self.model_name)) + # self.ts.log_debug('volt_block = %s' % (volt_block)) + parameters.append((self.model_name + '/SM_Source/SVP Commands/' + volt_block + '/Value', mag[i])) + i = i + 1 + # Phase A Switching times and Phase Angles + parameters.append((self.model_name + '/SM_Source/SVP Commands/phase_ph_a/Value', angle[0])) + # Phase B Switching times and Phase Angles + parameters.append((self.model_name + '/SM_Source/SVP Commands/phase_ph_b/Value', angle[1])) + # Phase C Switching times and Phase Angles + parameters.append((self.model_name + '/SM_Source/SVP Commands/phase_ph_c/Value', angle[2])) + + + self.hil.set_parameters(parameters) + + return None, None + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + return self.v / self.p_nom + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + """ + return self.v / self.p_nom + + def freq(self, freq): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + + :param freq: float value of frequency (to set freq), None to read freq + :return: frequency + """ + if freq is not None: + parameters = [] + parameters.append(("FREQUENCY", freq)) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["FREQUENCY"]) + return parameters + else: + pass + + def profile_load(self, profile_name=None, v_step=100, f_step=100, t_step=None, profile=None): + pass + + def profile_start(self): + """ + Start the loaded profile. + """ + pass + + def profile_stop(self): + """ + Stop the running profile. + """ + pass + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + pass + + def rocof(self, param=None): + """ + Set the rate of change of frequency (ROCOF) if provided. If none provided, obtains the ROCOF. + :param : "ROCOF_ENABLE" is to enable (1) or disable (0). Default value 0 + :param : "ROCOF_VALUE" is for ROCOF in Hz/s. Default value 3 + :param : "ROCOF_INIT" is for ROCOF initialisation value. Default value 60 + """ + + if param is not None: + parameters = [] + parameters.append(("ROCOF_ENABLE", param["ROCOF_ENABLE"])) + parameters.append(("ROCOF_VALUE", param["ROCOF_VALUE"])) + parameters.append(("ROCOF_INIT", param["ROCOF_INIT"])) + + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["ROCOF_ENABLE", "ROCOF_VALUE", "ROCOF_INIT"]) + return parameters + + def rocom(self, param=None): + """ + Set the rate of change of magnitude (ROCOM) if provided. If none provided, obtains the ROCOM. + :param : "ROCOM_ENABLE" is to enable (1) or disable (0). Default value 0 + :param : "ROCOM_VALUE" is for ROCOF in V/s. Default value 3 + :param : "ROCOM_INIT" is for ROCOF initialisation value. Default value 60 + :param : "ROCOM_START_TIME" is for ROCOF initialisation value. Default value 60 + :param : "ROCOM_END_TIME" is for ROCOF initialisation value. Default value 60 + """ + + if param is not None: + parameters = [] + parameters.append(("ROCOF_ENABLE", param["ROCOF_ENABLE"])) + parameters.append(("ROCOF_VALUE", param["ROCOF_VALUE"])) + parameters.append(("ROCOF_INIT", param["ROCOF_INIT"])) + parameters.append(("ROCOM_START_TIME", param["ROCOM_START_TIME"])) + parameters.append(("ROCOM_END_TIME", param["ROCOM_END_TIME"])) + + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["ROCOF_ENABLE", "ROCOF_VALUE", "ROCOF_INIT", "ROCOM_START_TIME", + "ROCOM_END_TIME"]) + return parameters + + def voltage(self, voltage=None): + """ + Set the value for voltage if provided. If none provided, obtains the value for voltage. + + :param voltage: tuple of floats for voltages (to set voltage), None to read voltage + :return: tuple of voltages + """ + parameters = [] + + if voltage is not None and type(voltage) is not list: + # single value case (not tuple voltages) + voltage /= self.v_nom + parameters = [] + # set the phase angles for the 3 phases + if self.phases == 1: + parameters.append(("VOLT_PHA", voltage)) + parameters.append(("VOLT_PHB", 0)) + parameters.append(("VOLT_PHC", 0)) + + elif self.phases == 2: + parameters.append(("VOLT_PHA", voltage)) + parameters.append(("VOLT_PHB", voltage)) + parameters.append(("VOLT_PHC", 0)) + + elif self.phases == 3: + parameters.append(("VOLT_PHA", voltage)) + parameters.append(("VOLT_PHB", voltage)) + parameters.append(("VOLT_PHC", voltage)) + + + else: + raise gridsim.GridSimError('Unsupported voltage parameter: %s' % voltage) + elif voltage is not None and type(voltage) is list and len(voltage) == 3: + # This consider the list contains three elements + # set the phase angles for the 3 phases + parameters.append(("VOLT_PHA", voltage[0])) + parameters.append(("VOLT_PHB", voltage[1])) + parameters.append(("VOLT_PHC", voltage[2])) + + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["VOLT_PHA", "VOLT_PHB", "VOLT_PHC"]) + + + return parameters + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + """ + parameters = [] + parameters.append(("VOLTAGE_MAX_LIMIT", voltage / self.v_nom)) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["VOLTAGE_MAX_LIMIT"]) + + return parameters + + def voltage_min(self, voltage=None): + """ + Set the value for min voltage if provided. If none provided, obtains + the value for min voltage. + """ + parameters = [] + # This is hard coded because the Grid simulator should be permitted to go as low as "0" + parameters.append(("VOLTAGE_MIN_LIMIT", 0.0)) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["VOLTAGE_MIN_LIMIT"]) + + return parameters + + def frequency_max(self, frequency=None): + """ + Set the value for max frequency if provided. If none provided, obtains + the value for max frequency. + """ + parameters = [] + parameters.append(("FREQUENCY_MAX_LIMIT", frequency)) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["FREQUENCY_MAX_LIMIT"]) + + return parameters + + def frequency_min(self, frequency=None): + """ + Set the value for min frequency if provided. If none provided, obtains + the value for min frequency. + """ + parameters = [] + parameters.append(("FREQUENCY_MIN_LIMIT", frequency)) + self.hil.set_matlab_variables(parameters) + parameters = [] + parameters = self.hil.get_matlab_variables(["FREQUENCY_MIN_LIMIT"]) + + return parameters + + def i_max(self): + return self.v / self.p_nom + + def v_nom(self): + return self.v_nom + + def meas_voltage(self, ph_list=(1, 2, 3)): + return self.v1, self.v2, self.v3 + + def meas_current(self, ph_list=(1, 2, 3)): + # for use during anti-islanding testing to determine the current to the utility + return None, None, None + + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/gridsim_pacific.py b/Lib/svpelab/gridsim_pacific.py index f08381e..f0433a0 100644 --- a/Lib/svpelab/gridsim_pacific.py +++ b/Lib/svpelab/gridsim_pacific.py @@ -1,741 +1,794 @@ -""" -Copyright (c) 2017, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os -import time -import socket -import re - -import serial - -import grid_profiles -import gridsim - -pacific_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Pacific' -} - -def gridsim_info(): - return pacific_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = pacific_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) - info.param(pname('v_nom'), label='Nominal voltage for all phases', default=240.0) - info.param(pname('v_max'), label='Max Voltage', default=300.0) - info.param(pname('i_max'), label='Max Current', default=100.0) - info.param(pname('freq'), label='Frequency', default=60.0) - info.param(pname('comm'), label='Communications Interface', default='TCP/IP', values=['Serial', 'TCP/IP']) - info.param(pname('serial_port'), label='Serial Port', - active=pname('comm'), active_value=['Serial'], default='com1') - info.param(pname('ip_addr'), label='IP Address', - active=pname('comm'), active_value=['TCP/IP'], default='192.168.0.171') - info.param(pname('ip_port'), label='IP Port', - active=pname('comm'), active_value=['TCP/IP'], default=1234) - -GROUP_NAME = 'pacific' - - -class GridSim(gridsim.GridSim): - """ - Pacific grid simulation implementation. - - Valid parameters: - mode - 'Pacific' - auto_config - ['Enabled', 'Disabled'] - v_nom - v_max - i_max - freq - profile_name - serial_port - baudrate - timeout - write_timeout - ip_addr - ip_port - """ - def __init__(self, ts, group_name): - self.buffer_size = 1024 - self.conn = None - - gridsim.GridSim.__init__(self, ts, group_name) - - self.phases_param = self._param_value('phases') - self.v_nom_param = self._param_value('v_nom') - self.v_max_param = self._param_value('v_max') - self.i_max_param = self._param_value('i_max') - self.freq_param = self._param_value('freq') - self.comm = self._param_value('comm') - self.serial_port = self._param_value('serial_port') - self.ipaddr = self._param_value('ip_addr') - self.ipport = self._param_value('ip_port') - self.baudrate = 115200 - self.timeout = 5 - self.write_timeout = 2 - self.cmd_str = '' - self._cmd = None - self._query = None - self.profile_name = ts.param_value('profile.profile_name') - - if self.comm == 'Serial': - self.open() # open communications - self._cmd = self.cmd_serial - self._query = self.query_serial - elif self.comm == 'TCP/IP': - self._cmd = self.cmd_tcp - self._query = self.query_tcp - - if self.auto_config == 'Enabled': - ts.log('Configuring the Grid Simulator.') - self.config() - - state = self.relay() # will always return 'unknown' because this isn't available - if state != gridsim.RELAY_CLOSED: - if self.ts.confirm('Would you like to ENERGIZE the system?') is False: - gridsim.GridSimError('Grid simulation was not started.') - else: - self.ts.log('Turning on grid simulator output.') - self.relay(state=gridsim.RELAY_CLOSED) - - def _param_value(self, name): - return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - - self.ts.log('Grid simulator model: %s' % self.info().strip()) - self.ts.log('Grid sim regenerative mode is not available - ensure there is a properly sized resistive load.') - - # The Pacific Grid simulator can be configured with either programs or with direct commands. - # Here we take the conservative approach of creating and executing a program and also sending direct commands. - self.cmd('*CLS\n') # Clear error/event queue - self.ts.log('Device info: %s' % self.info()) - - self.ts.log('Configuring the default settings into Program 0...') - self.program(prog=0, config=True) - - self.ts.log('Configuring the operational program...') - self.program(prog=1, config=True) - self.ts.log('New settings: %s' % self.program(prog=1)) - - # set voltage max - v_max = self.v_max_param - self.ts.log('Setting maximum voltage to %0.2f V.' % v_max) - self.voltage_max(voltage=(v_max, v_max, v_max)) - - # Note, max voltage must be set prior to program execution. - self.ts.log('Executing program.') - self.execute_program(prog=1) # program 0 is default - - ''' Completed above - # Direct commands to the equipment - # set the transformer coupling and transformer ratio - self.ts.log('Setting the coupling to "transformer" and the turns ratio to 2.88') - self.coupling(coupling='XFMR') - self.xfmr_ratio(ratio=2.88) - - # set max current - self.ts.log('Setting grid sim max current to %s Amps' % self.i_max_param) - self.current_max(self.i_max_param) - - # set the number of phases [This is completed in program() - must be 3 phase for this equipment anyway...] - # self.ts.log('Adjusting the number of phases for the output.') - # self.form(form=self.phases_param) - - # set the phase angles for the active phases - self.ts.log('Configuring phase angles.') - self.config_phase_angles() - - # set frequency - self.ts.log('Setting nominal frequency to %0.2f Hz.' % self.freq_param) - self.freq(self.freq_param) - - # set nominal voltage - v_nom = self.v_nom_param - self.voltage(voltage=(v_nom, v_nom, v_nom)) - self.ts.log('Grid sim nominal voltage settings: v1 = %s V, v2 = %s V, v3 = %s V' % (v_nom, v_nom, v_nom)) - ''' - - def query_program(self, prog=1): - """Gets program data.""" - self.select_program(prog=prog) - return self.query(':PROG:DEFine?\n') - - def select_program(self, prog=1): - """ - Selects Program prog for loading. prog in range 0 to 99 - Note program 0 is the manual operation and should not be used. - """ - if 0 <= int(prog) < 100: - self.cmd(':PROG:NAME %d\n' % int(prog)) - else: - self.ts.log_warning('Program number is not between 0 and 99 inclusive. No program was loaded.') - - def program(self, prog=1, config=None): - """Defines Program if config = True. If config = False, query program""" - - data_str = self.query_program(prog=prog) - # Example data string: - # 'FORM,3,COUPL,DIRECT,XFMRRATIO,2.88,FREQ,60,VOLT1,120,VOLT2,120,VOLT3,120,CURR:LIM,3998, - # PHAS2,120,PHAS3,240,WAVEFORM1,1,WAVEFORM2,1,WAVEFORM3,1,EVENTS,1' - - # self.ts.log_debug('Inspecting program #%d' % prog) - # self.ts.log_debug(data_str) - - if config is not True: # query program - - settings = re.findall(r'[-+]?\d*\.\d+|\d+', data_str) - # ['3', '2.88', '60', '1', '120', '2', '120', '3', '120', '3998', '2', '120', '3', '240', '1', '1', '2', '1', '3', '1', '1'] - # 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 - - if data_str.find('DIRECT') > 0: - coupling = 'DIRECT' - elif data_str.find('XFMR,') > 0: - coupling = 'XFMR' - else: - coupling = 'UNKNOWN' - self.ts.log_warning('Could not find the coupling type from Program 0 (Manual Settings).') - - manual_settings = {'form': int(settings[0]), 'xfmrratio': float(settings[1]), 'freq': float(settings[2]), - 'v1': float(settings[4]), 'v2': float(settings[6]), 'v3': float(settings[8]), - 'i_lim': float(settings[9]), - 'phase1': 0.0, 'phase2': float(settings[11]), 'phase3': float(settings[13]), - 'wave1': int(settings[15]), 'wave2': int(settings[17]), 'wave3': int(settings[19]), - 'events': int(settings[20]), 'coupling': coupling} - return manual_settings - else: - self.ts.log_debug('Deleting program #%d, and uploading new parameters...' % prog) - self.cmd(':PROG:DEL\n') # delete program - if self.phases_param == 1: - self.ts.log_debug('Single phase not available for this equipment.') - self.cmd(':PROG:DEFine FORM,1,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT,' - + str(self.v_nom_param) + ',CURR:LIM,' + str(self.i_max_param) + ',WAVEFORM,1\n') - elif self.phases_param == 2: - self.ts.log_debug('Split phase is created with a 3 phase system with Phase B 180 deg from Phase A.') - # self.cmd(':PROG:DEFine FORM,2,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT,' - # + str(self.v_nom_param) + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,180,' - # 'WAVEFORM,1\n') - self.cmd(':PROG:DEFine FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT1,' - + str(self.v_nom_param) + ',VOLT2,' + str(self.v_nom_param) + ',VOLT3,' + str(0.0) - + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,180,PHAS3,240,WAVEFORM1,1,' - 'WAVEFORM2,1,WAVEFORM3,1\n') - elif self.phases_param == 3: - self.cmd(':PROG:DEFine FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT1,' - + str(self.v_nom_param) + ',VOLT2,' + str(self.v_nom_param) + ',VOLT3,' + str(self.v_nom_param) - + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,120,PHAS3,240,WAVEFORM1,1,' - 'WAVEFORM2,1,WAVEFORM3,1\n') - - def execute_program(self, prog=1): - """ Execute program""" - self.cmd(':PROG:EXEC %d\n' % int(prog)) - - def execute_trans_program(self, prog=1): - """ Execute transient portion of given program, use with start_profile() """ - self.cmd(':PROG:EXEC:TRANS %d\n' % int(prog)) - - def cmd_serial(self, cmd_str): - self.cmd_str = cmd_str - try: - if self.conn is None: - raise gridsim.GridSimError('Communications port not open') - - self.conn.flushInput() - self.conn.write(cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query_serial(self, cmd_str): - resp = '' - more_data = True - - self.cmd_serial(cmd_str) - - while more_data: - try: - count = self.conn.inWaiting() - if count < 1: - count = 1 - data = self.conn.read(count) - if len(data) > 0: - for d in data: - resp += d - if d == '\n': - more_data = False - break - else: - raise gridsim.GridSimError('Timeout waiting for response') - except gridsim.GridSimError: - raise - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response - More data problem') - - return resp - - def cmd_tcp(self, cmd_str): - try: - if self.conn is None: - self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) - self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.conn.settimeout(self.timeout) - self.conn.connect((self.ipaddr, self.ipport)) - - # print 'cmd> %s' % (cmd_str) - self.conn.send(cmd_str) - self.ts.sleep(1) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query_tcp(self, cmd_str): - resp = '' - more_data = True - - self._cmd(cmd_str) - - while more_data: - try: - data = self.conn.recv(self.buffer_size) - if len(data) > 0: - for d in data: - resp += d - if d == '\n': #\r - more_data = False - break - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response') - - return resp - - def cmd(self, cmd_str): - self.cmd_str = cmd_str - try: - self._cmd(cmd_str) - resp = self._query('SYSTem:ERRor?\n') #\r - - if len(resp) > 0: - if resp[0] != '0': - raise gridsim.GridSimError(resp + ' ' + self.cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query(self, cmd_str): - try: - resp = self._query(cmd_str).strip() - except Exception, e: - raise gridsim.GridSimError(str(e)) - - return resp - - def info(self): - return self.query('*IDN?\n') - - def reset(self): - self.cmd('*RST\n') - - def waveform(self, wave_num=None): - if wave_num is not None: - self.cmd(':SOURce:WAVEFORM,%d\n' % wave_num) - # wave numbers stored in program 0 - prog_settings = self.program(prog=1) - return prog_settings['wave1'], prog_settings['wave2'], prog_settings['wave3'] - - def config_phase_angles(self, read=False): - if read is True: - # phase angles stored in program 0 - prog_settings = self.program(prog=0) - return prog_settings['phase1'], prog_settings['phase2'], prog_settings['phase3'] - else: - if self.phases_param == 1: - self.ts.log_debug('Configuring system for single phase.') - # phase 1 always 'preconfigured' at 0 phase angle - self.cmd(':SOURce:WAVEFORM,1\n') - # self.form(1) - UNSUPPORTED - elif self.phases_param == 2: - # set the phase angles for split phase - self.ts.log_debug('Configuring system for split phase on Phases A & B.') - self.cmd(':SOURce:PHASe2,180.0\n') - # self.form(2) - UNSUPPORTED - elif self.phases_param == 3: - # set the phase angles for the 3 phases - self.ts.log_debug('Configuring system for three phase.') - self.cmd(':SOURce:PHASe2,120.0\n') - self.cmd(':SOURce:PHASe2,240.0\n') - # self.form(3) - UNNECESSARY BECAUSE IT IS THE DEFAULT - else: - raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.phases_param)) - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - try: - self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, - timeout=self.timeout, writeTimeout=self.write_timeout) - time.sleep(2) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.conn: - self.ts.log('Closing connection to grid simulator.') - self.conn.close() - - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - if current is not None: - self.ts.log_warning('Cannot set the current of the grid simulator.') - # there is no capability to set the current - return 0. - else: - i1_str = self.query('meas:curr1?\n') - i2_str = self.query('meas:curr2?\n') - i3_str = self.query('meas:curr3?\n') - return float(i1_str[:-1])+float(i2_str[:-1])+float(i3_str[:-1])/3 - - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max current. - """ - if current is not None: - self.cmd(':SOURce:curr:lim %0.2f\n' % current) - # max current stored in program 0 - prog_settings = self.program(prog=1) - return prog_settings['i_lim'] - - def form(self, form=None): - # sets the number of phases used by the equipment - # 1 = single phase, 2 = split phase, and 3 = 3 phase - if form is not None: - self.cmd(':SOURce:FORM %d\n' % form) - # form stored in program 0 - prog_settings = self.program(prog=1) - return prog_settings['form'] - - def coupling(self, coupling=None): - # sets the equipment coupling - # 'DIRECT' = direct coupling, 'XFMR' = transformer coupling - if coupling is not None: - self.cmd(':SOURce:coupling %s\n' % coupling) - prog_settings = self.program(prog=1) - return prog_settings['coupling'] - - def xfmr_ratio(self, ratio=None): - # sets the transformer ratio as ratio:1 (range of ratio is 0.1 to 2.5) - if ratio is not None: - #self.cmd('xfmrratio,%0.1f\n' % ratio) - self.ts.log_warning('Transformer ratio cannot be set through communications, because it is set with ' - 'DIP switches in the UPC.') - prog_settings = self.program(prog=1) - return prog_settings['xfmrratio'] - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - self.cmd(':FREQ %0.2f\n' % freq) - # freq = self.query(':MEAS:FREQ?\n') - prog_settings = self.program(prog=1) - return prog_settings['freq'] - - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - """ - Creates a profile for a given program. An example execution sequence is: - - :PROG:NAME 3;:PROG:DEF? - - :PROG:NAME 0;*STB? - - :PROG:NAME 3;:PROG:DEL;*STB? - - :PROG:NAME 3;:PROG:DEF FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,60.000000,VOLT1,120.000000,VOLT2,115.000000, - VOLT3,115.000000,CURR:LIM,40.000000,CURR:PROT:LEV,40.000000,CURR:PROT:TOUT,1,PHAS2,120,PHAS3,240,WAVEFORM1,1, - WAVEFORM2,1,WAVEFORM3,1,EVENTS,1,AUTORMS,1;*STB? - - :PROG:DEF SEG,1,FSEG,58.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, - TSEG,0.100000,SEG,2,FSEG,62.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1, - WFSEG3,1,TSEG,0.300000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1, - WFSEG2,1,WFSEG3,1,TSEG,0.100000,LAST;*STB? - - :PROG:EXEC?;:PROG:CRC? - - :PROG:NAME 3;:PROG:EXEC;:OUTP?;:PROG:EXEC?;*OPC;*STB?;:STAT:OPER:COND?;*OPC? - - :PROG:NAME 0;:PROG:DEF? - """ - - if profile_name is None: - raise gridsim.GridSimError('Profile not specified.') - - if profile_name == 'Manual': # Manual reserved for not running a profile. - self.ts.log_warning('Manual reserved for not running a profile') - return - - v_nom = self.v_nom_param - freq_nom = self.freq_param - - # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step - if profile_name is 'Transient_Step': - if t_step is None: - raise gridsim.GridSimError('Transient profile did not have a duration.') - else: - # (time offset in seconds, % nominal voltage, % nominal frequency) - profile = [(0, v_step, f_step), (t_step, v_step, f_step), (t_step, 100, 100)] - - else: - # get the profile from grid_profiles - profile = grid_profiles.profiles.get(profile_name) - if profile is None: - raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) - - # prepare the program for default operation after execution - self.select_program(prog=1) # select program 1 - self.program(prog=1, config=True) # define program 1 - - cmd_list = ':PROG:DEF ' - for i in range(1, len(profile)): - freq = (float(profile[i - 1][2])/100.) * float(freq_nom) - volt = (float(profile[i][1])/100.) * float(v_nom) - t_delta = float(profile[i][0]) - float(profile[i - 1][0]) - cmd_list += 'SEG,%d' % i + ',' # segment number - cmd_list += 'FSEG,%0.6f' % freq + ',' # segment frequency - cmd_list += 'VSEG1,%0.6f' % volt + ',' # segment voltage - cmd_list += 'VSEG2,%0.6f' % volt + ',' # segment voltage - cmd_list += 'VSEG3,' + str(0.000000) + ',' # segment voltage - cmd_list += 'WFSEG1,1,' # waveform, phase 1 - cmd_list += 'WFSEG2,1,' # waveform, phase 2 - cmd_list += 'WFSEG3,1,' # waveform, phase 3 - cmd_list += 'TSEG,%0.6f' % t_delta + ',' # execution time (sec) to reach objective f,v (0=1 cycle) - cmd_list += 'LAST\n' # sets selected segment to be the last segment of selected program - - self.ts.log_debug('cmd_list:') - self.ts.log_debug('%s' % cmd_list) - self.profile = cmd_list - - # Put the profile in the program (...turns out this is unnecessary) - # prog_str = self.query_program(prog=1).strip() - # self.ts.log_debug('Program string from query: %s' % prog_str) - # self.ts.log_debug('prog_str.find(SEG) = %d' % prog_str.find('SEG')) - # if prog_str.find('SEG') > 0: - # self.ts.log('Program already has a profile. Reloading program...') - # head, sep, tail = prog_str.partition('EVENTS,') - # prog_str = head + sep + tail[0] - # prog_str += ',' + self.profile - # self.ts.log_debug('Program string with profile: %s' % prog_str) - - # Examples: - # :PROG:DEF SEG,1,FSEG,58.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, - # TSEG,0.100000,SEG,2,FSEG,62.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1, - # WFSEG3,1,TSEG,0.300000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1, - # WFSEG2,1,WFSEG3,1,TSEG,0.100000,LAST - - # :PROG:DEF SEG,1,FSEG,60.000000,VSEG1,96.000000,VSEG2,96.000000,VSEG3,96.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, - # TSEG,0.000200,SEG,2,FSEG,60.000000,VSEG1,96.000000,VSEG2,96.000000,VSEG3,96.000000,WFSEG1,1,WFSEG2,1, - # WFSEG3,1,TSEG,0.500000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,120.000000,VSEG3,120.000000,WFSEG1,1, - # WFSEG2,1,WFSEG3,1,TSEG,0.000200,LAST - - self.cmd(':PROG:NAME 1\n') - self.cmd(self.profile) - self.ts.log_debug('Returned program string: %s' % self.query_program(prog=1)) - - # Example returned program string: - # FORM,3,COUPL,DIRECT,XFMRRATIO,2.00,FREQ,60.000000, - # VOLT1,120.000000,VOLT2,120.000000,VOLT3,120.000000,CURR:LIM,40.000000,CURR:PROT:LEV,40.000000, - # CURR:PROT:TOUT,1,PHAS2,120,PHAS3,240,WAVEFORM1,1,WAVEFORM2,1,WAVEFORM3,1,EVENTS,1,AUTORMS,1, - # NSEGS,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,120.000000,VSEG3,120.000000,WFSEG1,1,WFSEG2,1, - # WFSEG3,1,TSEG,0.000200,LAST - - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - # self.execute_trans_program() - - self.cmd('*OPC;*TRG\n') # execute transient program (same as ':PROG:EXECute:TRANS\n') - # Executes pre-processed Transient portion of selected Program. Pre-processing is performed bne - # executing a program. Transient terminates upon receipt of any data byte (DAB) from the IEEE-488 Bus, - # Device Clear, or when the LAST segment of the last EVENT is executed. Steady-state values are then - # restored. Immediately follow this command (in the same program message with *OPC to detect the - # termination of the Transient events. An SQR will occur when the Transient is completed (if the ESB - # bit is set in the SRE and the opc bit is set in the ESE. *OPC? may also be used in the same manner. - - def profile_stop(self): - """ - Stop the running profile. - """ - # no such command - pass - - def op_complete(self): - return self.query('*OPC?\n') == 1 - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - """ - self.ts.log_warning('This equipment does not have a regenerative mode.') - state == gridsim.REGEN_OFF - return state - - def relay(self, state=None): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, returns unknown relay state. - - Note: in the case of the Pacific there is no relay to be actuated, but rather the output is turned on or off - """ - if state is not None: - if state == gridsim.RELAY_OPEN: - self.cmd(':OUTput OFF\n') - elif state == gridsim.RELAY_CLOSED: - self.cmd(':OUTput ON\n') - else: - raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) - else: - state = self.query(':OUTP?\n').strip() - if state == 1: - state = gridsim.RELAY_CLOSED - elif state == 0: - state = gridsim.RELAY_OPEN - else: - state = gridsim.RELAY_UNKNOWN - return state - - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if self.coupling() == 'DIRECT': - # Based on the transformer ratio, we need to reduce the voltage command by this amount - # (only if the device is in direct mode. - xfmrratio = self.xfmr_ratio() - else: - xfmrratio = 1 - - if voltage is not None: - # set output voltage on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(voltage) is not list and type(voltage) is not tuple: - #self.cmd(':SOURce:VOLTage1 %0.1f\n' % (voltage/xfmrratio)) - self.cmd(':SOURce:VOLTage1 %0.1f;VOLTage2 %0.1f;VOLTage3 %0.1f;\n' % - (voltage/xfmrratio, voltage/xfmrratio, voltage/xfmrratio)) - else: - self.cmd(':SOURce:VOLTage1%0.1f;VOLTage2 %0.1f;VOLTage3 %0.1f;\n' % - (voltage[0]/xfmrratio, voltage[1]/xfmrratio, voltage[2]/xfmrratio)) - # v1 = self.query(':MEAS:VOLTage1?\n') - # v2 = self.query(':MEAS:VOLTage2?\n') - # v3 = self.query(':MEAS:VOLTage3?\n') - # return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - - # Voltage settings are stored in the program - prog_settings = self.program(prog=1) - return prog_settings['v1']*xfmrratio, prog_settings['v2']*xfmrratio, prog_settings['v3']*xfmrratio - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage - """ - if self.coupling() == 'DIRECT': - # Based on the transformer ratio, we need to reduce the voltage command by this amount - # (only if the device is in direct mode. - xfmrratio = self.xfmr_ratio() - else: - xfmrratio = 1 - - if voltage is not None: - try: # first assume the voltage is a tuple - voltage = max(voltage)/xfmrratio # voltage is a tuple but Pacific only takes one value - except TypeError: - voltage = voltage/xfmrratio # voltage is a single value - self.cmd(':SOUR:volt:lim:max %0.0f\n' % voltage) - - # v = self.query(':SOUR:volt:lim:max?\n') # Does not work. - return self.v_max_param, self.v_max_param, self.v_max_param - - def voltage_min(self, voltage=None): - """ - Set the value for min voltage - """ - if self.coupling() == 'DIRECT': - # Based on the transformer ratio, we need to reduce the voltage command by this amount - # (only if the device is in direct mode. - xfmrratio = self.xfmr_ratio() - else: - xfmrratio = 1 - - if voltage is not None: - voltage = max(voltage)/xfmrratio # voltage is a triplet but Pacific only takes one value - self.cmd(':SOUR:volt:lim:min %0.0f\n' % voltage) - # v = self.query(':SOUR:volt:lim:min?\n') # Does not work. - return 0., 0., 0. - - def freq_max(self, freq=None): - """ - Set the value for max freq - """ - if freq is not None: - self.cmd(':SOUR:FREQ:LIM:MAX %0.0f\n' % freq) - return self.query(':SOUR:FREQ:LIM:MAX?\n') - - def freq_min(self, freq=None): - """ - Set the value for min freq - """ - if freq is not None: - self.cmd(':SOUR:FREQ:LIM:MIN %0.0f\n' % freq) - return self.query(':SOUR:FREQ:LIM:MIN?\n') - -if __name__ == "__main__": - pass - +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import time +import socket +import re + +import serial +import pyvisa as visa + +from . import grid_profiles +from . import gridsim + +pacific_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Pacific' +} + +def gridsim_info(): + return pacific_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = pacific_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=240.0) + info.param(pname('v_max'), label='Max Voltage', default=300.0) + info.param(pname('i_max'), label='Max Current', default=100.0) + info.param(pname('freq'), label='Frequency', default=60.0) + info.param(pname('comm'), label='Communications Interface', default='REMOTE IP-GPIB', values=['Serial', 'TCP/IP','REMOTE IP-GPIB']) + info.param(pname('serial_port'), label='Serial Port', + active=pname('comm'), active_value=['Serial'], default='com1') + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['TCP/IP'], default='192.168.0.171') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['TCP/IP'], default=1234) + info.param(pname('remote_ip_addr'), label='REMOTE IP Address', + active=pname('comm'), active_value=['REMOTE IP-GPIB'], default='192.168.120.32') + info.param(pname('gpib_addr'), label='GPIB Address', + active=pname('comm'), active_value=['REMOTE IP-GPIB'], default=2) +GROUP_NAME = 'pacific' + + +class GridSim(gridsim.GridSim): + """ + Pacific grid simulation implementation. + + Valid parameters: + mode - 'Pacific' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + serial_port + baudrate + timeout + write_timeout + ip_addr + ip_port + """ + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.buffer_size = 1024 + self.conn = None + + self.phases_param = self._param_value('phases') + self.v_nom_param = self._param_value('v_nom') + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.comm = self._param_value('comm') + self.serial_port = self._param_value('serial_port') + self.ipaddr = self._param_value('ip_addr') + self.ipport = self._param_value('ip_port') + self.remote_ipaddr = self._param_value('remote_ip_addr') + self.gpib_addr = self._param_value('gpib_addr') + self.baudrate = 115200 + self.timeout = 5 + self.write_timeout = 2 + self.cmd_str = '' + self._cmd = None + self._query = None + self.profile_name = ts.param_value('profile.profile_name') + + if self.comm == 'Serial': + self.open() # open communications + self._cmd = self.cmd_serial + self._query = self.query_serial + elif self.comm == 'TCP/IP': + self._cmd = self.cmd_tcp + self._query = self.query_tcp + elif self.comm == 'REMOTE IP-GPIB': + self._cmd = self.cmd_remote_tcp + self._query = self.query_remote_tcp + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() + + state = self.relay() # will always return 'unknown' because this isn't available + if state != gridsim.RELAY_CLOSED: + if self.ts.confirm('Would you like to ENERGIZE the system?') is False: + gridsim.GridSimError('Grid simulation was not started.') + else: + self.ts.log('Turning on grid simulator output.') + self.relay(state) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + + self.ts.log('Grid simulator model: %s' % self.info().strip()) + self.ts.log('Grid sim regenerative mode is not available - ensure there is a properly sized resistive load.') + + # The Pacific Grid simulator can be configured with either programs or with direct commands. + # Here we take the conservative approach of creating and executing a program and also sending direct commands. + self.cmd('*CLS\n') # Clear error/event queue + self.ts.log('Device info: %s' % self.info()) + + self.ts.log('Configuring the default settings into Program 0...') + self.program(prog=0, config=True) + + self.ts.log('Configuring the operational program...') + self.program(prog=1, config=True) + self.ts.log('New settings: %s' % self.program(prog=1)) + + # set voltage max + v_max = self.v_max_param + self.ts.log('Setting maximum voltage to %0.2f V.' % v_max) + self.voltage_max(voltage=(v_max, v_max, v_max)) + + # Note, max voltage must be set prior to program execution. + self.ts.log('Executing program.') + self.execute_program(prog=1) # program 0 is default + + ''' Completed above + # Direct commands to the equipment + # set the transformer coupling and transformer ratio + self.ts.log('Setting the coupling to "transformer" and the turns ratio to 2.88') + self.coupling(coupling='XFMR') + self.xfmr_ratio(ratio=2.88) + + # set max current + self.ts.log('Setting grid sim max current to %s Amps' % self.i_max_param) + self.current_max(self.i_max_param) + + # set the number of phases [This is completed in program() - must be 3 phase for this equipment anyway...] + # self.ts.log('Adjusting the number of phases for the output.') + # self.form(form=self.phases_param) + + # set the phase angles for the active phases + self.ts.log('Configuring phase angles.') + self.config_phase_angles() + + # set frequency + self.ts.log('Setting nominal frequency to %0.2f Hz.' % self.freq_param) + self.freq(self.freq_param) + + # set nominal voltage + v_nom = self.v_nom_param + self.voltage(voltage=(v_nom, v_nom, v_nom)) + self.ts.log('Grid sim nominal voltage settings: v1 = %s V, v2 = %s V, v3 = %s V' % (v_nom, v_nom, v_nom)) + ''' + + def query_program(self, prog=1): + """Gets program data.""" + self.select_program(prog=prog) + return self.query(':PROG:DEFine?\n') + + def select_program(self, prog=1): + """ + Selects Program prog for loading. prog in range 0 to 99 + Note program 0 is the manual operation and should not be used. + """ + if 0 <= int(prog) < 100: + self.cmd(':PROG:NAME %d\n' % int(prog)) + else: + self.ts.log_warning('Program number is not between 0 and 99 inclusive. No program was loaded.') + + def program(self, prog=1, config=None): + """Defines Program if config = True. If config = False, query program""" + + data_str = self.query_program(prog=prog) + # Example data string: + # 'FORM,3,COUPL,DIRECT,XFMRRATIO,2.88,FREQ,60,VOLT1,120,VOLT2,120,VOLT3,120,CURR:LIM,3998, + # PHAS2,120,PHAS3,240,WAVEFORM1,1,WAVEFORM2,1,WAVEFORM3,1,EVENTS,1' + + # self.ts.log_debug('Inspecting program #%d' % prog) + # self.ts.log_debug(data_str) + + if config is not True: # query program + + settings = re.findall(r'[-+]?\d*\.\d+|\d+', data_str) + # ['3', '2.88', '60', '1', '120', '2', '120', '3', '120', '3998', '2', '120', '3', '240', '1', '1', '2', '1', '3', '1', '1'] + # 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 + + if data_str.find('DIRECT') > 0: + coupling = 'DIRECT' + elif data_str.find('XFMR,') > 0: + coupling = 'XFMR' + else: + coupling = 'UNKNOWN' + self.ts.log_warning('Could not find the coupling type from Program 0 (Manual Settings).') + + manual_settings = {'form': int(settings[0]), 'xfmrratio': float(settings[1]), 'freq': float(settings[2]), + 'v1': float(settings[4]), 'v2': float(settings[6]), 'v3': float(settings[8]), + 'i_lim': float(settings[9]), + 'phase1': 0.0, 'phase2': float(settings[11]), 'phase3': float(settings[13]), + 'wave1': int(settings[15]), 'wave2': int(settings[17]), 'wave3': int(settings[19]), + 'events': int(settings[20]), 'coupling': coupling} + return manual_settings + else: + self.ts.log_debug('Deleting program #%d, and uploading new parameters...' % prog) + self.cmd(':PROG:DEL\n') # delete program + if self.phases_param == 1: + self.ts.log_debug('Single phase not available for this equipment.') + self.cmd(':PROG:DEFine FORM,1,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT,' + + str(self.v_nom_param) + ',CURR:LIM,' + str(self.i_max_param) + ',WAVEFORM,1\n') + elif self.phases_param == 2: + self.ts.log_debug('Split phase is created with a 3 phase system with Phase B 180 deg from Phase A.') + # self.cmd(':PROG:DEFine FORM,2,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT,' + # + str(self.v_nom_param) + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,180,' + # 'WAVEFORM,1\n') + self.cmd(':PROG:DEFine FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT1,' + + str(self.v_nom_param) + ',VOLT2,' + str(self.v_nom_param) + ',VOLT3,' + str(0.0) + + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,180,PHAS3,240,WAVEFORM1,1,' + 'WAVEFORM2,1,WAVEFORM3,1\n') + elif self.phases_param == 3: + self.cmd(':PROG:DEFine FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,' + str(self.freq_param) + ',VOLT1,' + + str(self.v_nom_param) + ',VOLT2,' + str(self.v_nom_param) + ',VOLT3,' + str(self.v_nom_param) + + ',CURR:LIM,' + str(self.i_max_param) + ',PHAS2,120,PHAS3,240,WAVEFORM1,1,' + 'WAVEFORM2,1,WAVEFORM3,1\n') + + def execute_program(self, prog=1): + """ Execute program""" + self.cmd(':PROG:EXEC %d\n' % int(prog)) + + def execute_trans_program(self, prog=1): + """ Execute transient portion of given program, use with start_profile() """ + self.cmd(':PROG:EXEC:TRANS %d\n' % int(prog)) + + def cmd_serial(self, cmd_str): + self.cmd_str = cmd_str + try: + if self.conn is None: + raise gridsim.GridSimError('Communications port not open') + + self.conn.flushInput() + self.conn.write(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_serial(self, cmd_str): + resp = '' + more_data = True + + self.cmd_serial(cmd_str) + + while more_data: + try: + count = self.conn.inWaiting() + if count < 1: + count = 1 + data = self.conn.read(count) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': + more_data = False + break + else: + raise gridsim.GridSimError('Timeout waiting for response') + except gridsim.GridSimError: + raise + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response - More data problem') + + return resp + + def cmd_tcp(self, cmd_str): + try: + if self.conn is None: + self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + self.ts.sleep(1) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_tcp(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': #\r + more_data = False + break + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response') + + return resp + + def cmd_remote_tcp(self, cmd_str): + try: + if self.conn is None: + self.ts.log('remote_ipaddr = %s gpib_addr = %s' % (self.remote_ipaddr, self.gpib_addr)) + rm = visa.ResourceManager('@py') + rsc = "TCPIP::" + str(self.remote_ipaddr) + "::gpib0," + str(self.gpib_addr) + "::INSTR" + self.conn = rm.open_resource(str(rsc)) + print(("Success when opening remote GPIB resource " + str(rsc))) + self.conn.write('*IDN?') + time.sleep(2) + self.conn.read() + # print 'cmd> %s' % (cmd_str) + self.conn.write(cmd_str) + self.ts.sleep(1) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_remote_tcp(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.read() + if len(data) > 0: + for d in data: + resp += d + if d == '\n': #\r + more_data = False + break + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + self.cmd_str = cmd_str + try: + self._cmd(cmd_str) + resp = self._query('SYSTem:ERRor?\n') #\r + + if len(resp) > 0: + if resp[0] != '0': + raise gridsim.GridSimError(resp + ' ' + self.cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise gridsim.GridSimError(str(e)) + + return resp + + def info(self): + return self.query('*IDN?\n') + + def reset(self): + self.cmd('*RST\n') + + def waveform(self, wave_num=None): + if wave_num is not None: + self.cmd(':SOURce:WAVEFORM,%d\n' % wave_num) + # wave numbers stored in program 0 + prog_settings = self.program(prog=1) + return prog_settings['wave1'], prog_settings['wave2'], prog_settings['wave3'] + + def config_phase_angles(self, read=False): + if read is True: + # phase angles stored in program 0 + prog_settings = self.program(prog=0) + return prog_settings['phase1'], prog_settings['phase2'], prog_settings['phase3'] + else: + if self.phases_param == 1: + self.ts.log_debug('Configuring system for single phase.') + # phase 1 always 'preconfigured' at 0 phase angle + self.cmd(':SOURce:WAVEFORM,1\n') + # self.form(1) - UNSUPPORTED + elif self.phases_param == 2: + # set the phase angles for split phase + self.ts.log_debug('Configuring system for split phase on Phases A & B.') + self.cmd(':SOURce:PHASe2,180.0\n') + # self.form(2) - UNSUPPORTED + elif self.phases_param == 3: + # set the phase angles for the 3 phases + self.ts.log_debug('Configuring system for three phase.') + self.cmd(':SOURce:PHASe2,120.0\n') + self.cmd(':SOURce:PHASe2,240.0\n') + # self.form(3) - UNNECESSARY BECAUSE IT IS THE DEFAULT + else: + raise gridsim.GridSimError('Unsupported phase parameter: %s' % (self.phases_param)) + + def open(self): + """ + Open the communications resources associated with the grid simulator. + """ + try: + self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, + timeout=self.timeout, writeTimeout=self.write_timeout) + time.sleep(2) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + self.relay(state=gridsim.RELAY_CLOSED) + if self.conn: + self.ts.log('Closing connection to grid simulator.') + self.conn.close() + + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + if current is not None: + self.ts.log_warning('Cannot set the current of the grid simulator.') + # there is no capability to set the current + return 0. + else: + i1_str = self.query('meas:curr1?\n') + i2_str = self.query('meas:curr2?\n') + i3_str = self.query('meas:curr3?\n') + return float(i1_str[:-1])+float(i2_str[:-1])+float(i3_str[:-1])/3 + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + """ + if current is not None: + self.cmd(':SOURce:curr:lim %0.2f\n' % current) + # max current stored in program 0 + prog_settings = self.program(prog=1) + return prog_settings['i_lim'] + + def form(self, form=None): + # sets the number of phases used by the equipment + # 1 = single phase, 2 = split phase, and 3 = 3 phase + if form is not None: + self.cmd(':SOURce:FORM %d\n' % form) + # form stored in program 0 + prog_settings = self.program(prog=1) + return prog_settings['form'] + + def coupling(self, coupling=None): + # sets the equipment coupling + # 'DIRECT' = direct coupling, 'XFMR' = transformer coupling + if coupling is not None: + self.cmd(':SOURce:coupling %s\n' % coupling) + prog_settings = self.program(prog=1) + return prog_settings['coupling'] + + def xfmr_ratio(self, ratio=None): + # sets the transformer ratio as ratio:1 (range of ratio is 0.1 to 2.5) + if ratio is not None: + #self.cmd('xfmrratio,%0.1f\n' % ratio) + self.ts.log_warning('Transformer ratio cannot be set through communications, because it is set with ' + 'DIP switches in the UPC.') + prog_settings = self.program(prog=1) + return prog_settings['xfmrratio'] + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if freq is not None: + self.cmd(':FREQ %0.2f\n' % freq) + # freq = self.query(':MEAS:FREQ?\n') + prog_settings = self.program(prog=1) + return prog_settings['freq'] + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + """ + Creates a profile for a given program. An example execution sequence is: + + :PROG:NAME 3;:PROG:DEF? + + :PROG:NAME 0;*STB? + + :PROG:NAME 3;:PROG:DEL;*STB? + + :PROG:NAME 3;:PROG:DEF FORM,3,COUPL,XFMR,XFMRRATIO,2.88,FREQ,60.000000,VOLT1,120.000000,VOLT2,115.000000, + VOLT3,115.000000,CURR:LIM,40.000000,CURR:PROT:LEV,40.000000,CURR:PROT:TOUT,1,PHAS2,120,PHAS3,240,WAVEFORM1,1, + WAVEFORM2,1,WAVEFORM3,1,EVENTS,1,AUTORMS,1;*STB? + + :PROG:DEF SEG,1,FSEG,58.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, + TSEG,0.100000,SEG,2,FSEG,62.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1, + WFSEG3,1,TSEG,0.300000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1, + WFSEG2,1,WFSEG3,1,TSEG,0.100000,LAST;*STB? + + :PROG:EXEC?;:PROG:CRC? + + :PROG:NAME 3;:PROG:EXEC;:OUTP?;:PROG:EXEC?;*OPC;*STB?;:STAT:OPER:COND?;*OPC? + + :PROG:NAME 0;:PROG:DEF? + """ + + if profile_name is None: + raise gridsim.GridSimError('Profile not specified.') + + if profile_name == 'Manual': # Manual reserved for not running a profile. + self.ts.log_warning('Manual reserved for not running a profile') + return + + v_nom = self.v_nom_param + freq_nom = self.freq_param + + # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step + if profile_name is 'Transient_Step': + if t_step is None: + raise gridsim.GridSimError('Transient profile did not have a duration.') + else: + # (time offset in seconds, % nominal voltage, % nominal frequency) + profile = [(0, v_step, f_step), (t_step, v_step, f_step), (t_step, 100, 100)] + + else: + # get the profile from grid_profiles + profile = grid_profiles.profiles.get(profile_name) + if profile is None: + raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) + + # prepare the program for default operation after execution + self.select_program(prog=1) # select program 1 + self.program(prog=1, config=True) # define program 1 + + cmd_list = ':PROG:DEF ' + for i in range(1, len(profile)): + freq = (float(profile[i - 1][2])/100.) * float(freq_nom) + volt = (float(profile[i][1])/100.) * float(v_nom) + t_delta = float(profile[i][0]) - float(profile[i - 1][0]) + cmd_list += 'SEG,%d' % i + ',' # segment number + cmd_list += 'FSEG,%0.6f' % freq + ',' # segment frequency + cmd_list += 'VSEG1,%0.6f' % volt + ',' # segment voltage + cmd_list += 'VSEG2,%0.6f' % volt + ',' # segment voltage + cmd_list += 'VSEG3,' + str(0.000000) + ',' # segment voltage + cmd_list += 'WFSEG1,1,' # waveform, phase 1 + cmd_list += 'WFSEG2,1,' # waveform, phase 2 + cmd_list += 'WFSEG3,1,' # waveform, phase 3 + cmd_list += 'TSEG,%0.6f' % t_delta + ',' # execution time (sec) to reach objective f,v (0=1 cycle) + cmd_list += 'LAST\n' # sets selected segment to be the last segment of selected program + + self.ts.log_debug('cmd_list:') + self.ts.log_debug('%s' % cmd_list) + self.profile = cmd_list + + # Put the profile in the program (...turns out this is unnecessary) + # prog_str = self.query_program(prog=1).strip() + # self.ts.log_debug('Program string from query: %s' % prog_str) + # self.ts.log_debug('prog_str.find(SEG) = %d' % prog_str.find('SEG')) + # if prog_str.find('SEG') > 0: + # self.ts.log('Program already has a profile. Reloading program...') + # head, sep, tail = prog_str.partition('EVENTS,') + # prog_str = head + sep + tail[0] + # prog_str += ',' + self.profile + # self.ts.log_debug('Program string with profile: %s' % prog_str) + + # Examples: + # :PROG:DEF SEG,1,FSEG,58.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, + # TSEG,0.100000,SEG,2,FSEG,62.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1,WFSEG2,1, + # WFSEG3,1,TSEG,0.300000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,115.000000,VSEG3,115.000000,WFSEG1,1, + # WFSEG2,1,WFSEG3,1,TSEG,0.100000,LAST + + # :PROG:DEF SEG,1,FSEG,60.000000,VSEG1,96.000000,VSEG2,96.000000,VSEG3,96.000000,WFSEG1,1,WFSEG2,1,WFSEG3,1, + # TSEG,0.000200,SEG,2,FSEG,60.000000,VSEG1,96.000000,VSEG2,96.000000,VSEG3,96.000000,WFSEG1,1,WFSEG2,1, + # WFSEG3,1,TSEG,0.500000,SEG,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,120.000000,VSEG3,120.000000,WFSEG1,1, + # WFSEG2,1,WFSEG3,1,TSEG,0.000200,LAST + + self.cmd(':PROG:NAME 1\n') + self.cmd(self.profile) + self.ts.log_debug('Returned program string: %s' % self.query_program(prog=1)) + + # Example returned program string: + # FORM,3,COUPL,DIRECT,XFMRRATIO,2.00,FREQ,60.000000, + # VOLT1,120.000000,VOLT2,120.000000,VOLT3,120.000000,CURR:LIM,40.000000,CURR:PROT:LEV,40.000000, + # CURR:PROT:TOUT,1,PHAS2,120,PHAS3,240,WAVEFORM1,1,WAVEFORM2,1,WAVEFORM3,1,EVENTS,1,AUTORMS,1, + # NSEGS,3,FSEG,60.000000,VSEG1,120.000000,VSEG2,120.000000,VSEG3,120.000000,WFSEG1,1,WFSEG2,1, + # WFSEG3,1,TSEG,0.000200,LAST + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.profile is not None: + # self.execute_trans_program() + + self.cmd('*OPC;*TRG\n') # execute transient program (same as ':PROG:EXECute:TRANS\n') + # Executes pre-processed Transient portion of selected Program. Pre-processing is performed bne + # executing a program. Transient terminates upon receipt of any data byte (DAB) from the IEEE-488 Bus, + # Device Clear, or when the LAST segment of the last EVENT is executed. Steady-state values are then + # restored. Immediately follow this command (in the same program message with *OPC to detect the + # termination of the Transient events. An SQR will occur when the Transient is completed (if the ESB + # bit is set in the SRE and the opc bit is set in the ESE. *OPC? may also be used in the same manner. + + def profile_stop(self): + """ + Stop the running profile. + """ + # no such command + pass + + def op_complete(self): + return self.query('*OPC?\n') == 1 + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + """ + self.ts.log_warning('This equipment does not have a regenerative mode.') + state == gridsim.REGEN_OFF + return state + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, returns unknown relay state. + + Note: in the case of the Pacific there is no relay to be actuated, but rather the output is turned on or off + """ + if state is not None: + if state == gridsim.RELAY_OPEN: + self.ts.log_debug("Energizando sistema") + self.cmd(':OUTput ON\n') + elif state == gridsim.RELAY_CLOSED: + self.ts.log_debug("Desenergizando sistema") + self.cmd(':OUTput OFF\n') + else: + raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) + else: + state = self.query(':OUTP?\n').strip() + self.ts.log_debug('state: %s' % state) + if state == '1': + self.ts.log_debug("Sistema energizado") + state = gridsim.RELAY_CLOSED + elif state == '0': + self.ts.log_debug("Sistema NO energizado") + state = gridsim.RELAY_OPEN + else: + self.ts.log_debug("Sistema desconocido") + state = gridsim.RELAY_UNKNOWN + return state + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if self.coupling() == 'DIRECT': + # Based on the transformer ratio, we need to reduce the voltage command by this amount + # (only if the device is in direct mode. + xfmrratio = self.xfmr_ratio() + else: + xfmrratio = 1 + + if voltage is not None: + # set output voltage on all phases + # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) + if type(voltage) is not list and type(voltage) is not tuple: + #self.cmd(':SOURce:VOLTage1 %0.1f\n' % (voltage/xfmrratio)) + self.cmd(':SOURce:VOLTage1 %0.1f;VOLTage2 %0.1f;VOLTage3 %0.1f;\n' % + (voltage/xfmrratio, voltage/xfmrratio, voltage/xfmrratio)) + else: + self.cmd(':SOURce:VOLTage1%0.1f;VOLTage2 %0.1f;VOLTage3 %0.1f;\n' % + (voltage[0]/xfmrratio, voltage[1]/xfmrratio, voltage[2]/xfmrratio)) + # v1 = self.query(':MEAS:VOLTage1?\n') + # v2 = self.query(':MEAS:VOLTage2?\n') + # v3 = self.query(':MEAS:VOLTage3?\n') + # return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + # Voltage settings are stored in the program + prog_settings = self.program(prog=1) + return prog_settings['v1']*xfmrratio, prog_settings['v2']*xfmrratio, prog_settings['v3']*xfmrratio + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage + """ + if self.coupling() == 'DIRECT': + # Based on the transformer ratio, we need to reduce the voltage command by this amount + # (only if the device is in direct mode. + xfmrratio = self.xfmr_ratio() + else: + xfmrratio = 1 + + if voltage is not None: + try: # first assume the voltage is a tuple + voltage = max(voltage)/xfmrratio # voltage is a tuple but Pacific only takes one value + except TypeError: + voltage = voltage/xfmrratio # voltage is a single value + self.cmd(':SOUR:volt:lim:max %0.0f\n' % voltage) + + # v = self.query(':SOUR:volt:lim:max?\n') # Does not work. + return self.v_max_param, self.v_max_param, self.v_max_param + + def voltage_min(self, voltage=None): + """ + Set the value for min voltage + """ + if self.coupling() == 'DIRECT': + # Based on the transformer ratio, we need to reduce the voltage command by this amount + # (only if the device is in direct mode. + xfmrratio = self.xfmr_ratio() + else: + xfmrratio = 1 + + if voltage is not None: + voltage = max(voltage)/xfmrratio # voltage is a triplet but Pacific only takes one value + self.cmd(':SOUR:volt:lim:min %0.0f\n' % voltage) + # v = self.query(':SOUR:volt:lim:min?\n') # Does not work. + return 0., 0., 0. + + def freq_max(self, freq=None): + """ + Set the value for max freq + """ + if freq is not None: + self.cmd(':SOUR:FREQ:LIM:MAX %0.0f\n' % freq) + return self.query(':SOUR:FREQ:LIM:MAX?\n') + + def freq_min(self, freq=None): + """ + Set the value for min freq + """ + if freq is not None: + self.cmd(':SOUR:FREQ:LIM:MIN %0.0f\n' % freq) + return self.query(':SOUR:FREQ:LIM:MIN?\n') + +if __name__ == "__main__": + pass + diff --git a/Lib/svpelab/gridsim_pass.py b/Lib/svpelab/gridsim_pass.py index 7334f9f..799711a 100644 --- a/Lib/svpelab/gridsim_pass.py +++ b/Lib/svpelab/gridsim_pass.py @@ -1,193 +1,193 @@ -""" -Copyright (c) 2017, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os - -import gridsim - -pass_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Pass' -} - -def gridsim_info(): - return pass_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = pass_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('v_nom'), label='Nominal voltage for all phases', default=240.) - info.param(pname('v_max'), label='Max Voltage', default=600.0) - info.param(pname('i_max'), label='Max Current', default=100.0) - info.param(pname('freq'), label='Frequency', default=60.0) - -GROUP_NAME = 'pass' - - -class GridSim(gridsim.GridSim): - - def __init__(self, ts, group_name, params=None): - gridsim.GridSim.__init__(self, ts, group_name, params) - - self.v_nom = self._param_value('v_nom') - self.v_max = self._param_value('v_max') - self.i_max = self._param_value('i_max') - self.freq_param = self._param_value('freq') - self.relay_state = gridsim.RELAY_OPEN - self.regen_state = gridsim.REGEN_OFF - - if self.auto_config == 'Enabled': - self.config() - - def _param_value(self, name): - return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - if self.ts.confirm('Configure grid simulator to following settings:\n' - ' \nVoltage = %s\nMax voltage = %s\nMax current = %s\nFrequency = %s' % - (self.v_nom, self.v_max, self.i_max, self.freq_param)) is False: - raise gridsim.GridSimError('Aborted grid simulation') - - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max current. - """ - if current is not None: - if self.ts.confirm('Set grid simulator maximum current to %s' % (current)) is False: - raise gridsim.GridSimError('Aborted grid simulation') - else: - current = self.i_max - return current - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - pass - else: - freq = self.freq_param - return freq - - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - """ - Load the profile either in list format or from a file. - - Each entry in the profile contains: - time offset in seconds, voltage 1, voltage 2, voltage 3, frequency - The voltage is applied to all phases. - - The profile param specifies the profile as a list of tuples in the form: - (time, v1, v2, v3, frequency) - - The filename param specifies the profile as a csv file with the first - line specifying the elements order of the elements and subsequent lines - containing each profile entry: - time, voltage 1, voltage 2, voltage 3, frequency - t0, v1, v2, v3, f - t1, v1, v2, v3, f - """ - if self.ts.confirm('Load grid simulator profile') is False: - raise gridsim.GridSimError('Aborted grid simulation') - - def profile_start(self): - """ - Start the loaded profile. - """ - if self.ts.confirm('Start grid simulator profile') is False: - raise gridsim.GridSimError('Aborted grid simulation') - - def profile_stop(self): - """ - Stop the running profile. - """ - if self.ts.confirm('Stop grid simulator profile') is False: - raise gridsim.GridSimError('Aborted grid simulation') - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - """ - if state is not None: - self.regen_state = state - else: - state = self.regen_state - return state - - def relay(self, state=None): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - if state is not None: - self.relay_state = state - if self.ts.confirm('Set grid simulator relay to %s' % (state)) is False: - raise gridsim.GridSimError('Aborted grid simulation') - else: - state = self.relay_state - return state - - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if voltage is not None: - pass - else: - voltage = (self.v_nom, self.v_nom, self.v_nom) - return voltage - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - if voltage is not None: - self.v_max = voltage[0] - if self.ts.confirm('Set grid simulator maximum voltage to %s' % (voltage)) is False: - raise gridsim.GridSimError('Aborted grid simulation') - else: - voltage = (self.v_max, self.v_max, self.v_max) - return voltage +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os + +from . import gridsim + +pass_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Pass' +} + +def gridsim_info(): + return pass_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = pass_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('v_nom'), label='Nominal voltage for all phases', default=240.) + info.param(pname('v_max'), label='Max Voltage', default=600.0) + info.param(pname('i_max'), label='Max Current', default=100.0) + info.param(pname('freq'), label='Frequency', default=60.0) + +GROUP_NAME = 'pass' + + +class GridSim(gridsim.GridSim): + + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + + self.v_nom = self._param_value('v_nom') + self.v_max = self._param_value('v_max') + self.i_max = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.relay_state = gridsim.RELAY_OPEN + self.regen_state = gridsim.REGEN_OFF + + if self.auto_config == 'Enabled': + self.config() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + if self.ts.confirm('Configure grid simulator to following settings:\n' + ' \nVoltage = %s\nMax voltage = %s\nMax current = %s\nFrequency = %s' % + (self.v_nom, self.v_max, self.i_max, self.freq_param)) is False: + raise gridsim.GridSimError('Aborted grid simulation') + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + """ + if current is not None: + if self.ts.confirm('Set grid simulator maximum current to %s' % (current)) is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + current = self.i_max + return current + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if freq is not None: + pass + else: + freq = self.freq_param + return freq + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + """ + Load the profile either in list format or from a file. + + Each entry in the profile contains: + time offset in seconds, voltage 1, voltage 2, voltage 3, frequency + The voltage is applied to all phases. + + The profile param specifies the profile as a list of tuples in the form: + (time, v1, v2, v3, frequency) + + The filename param specifies the profile as a csv file with the first + line specifying the elements order of the elements and subsequent lines + containing each profile entry: + time, voltage 1, voltage 2, voltage 3, frequency + t0, v1, v2, v3, f + t1, v1, v2, v3, f + """ + if self.ts.confirm('Load grid simulator profile') is False: + raise gridsim.GridSimError('Aborted grid simulation') + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.ts.confirm('Start grid simulator profile') is False: + raise gridsim.GridSimError('Aborted grid simulation') + + def profile_stop(self): + """ + Stop the running profile. + """ + if self.ts.confirm('Stop grid simulator profile') is False: + raise gridsim.GridSimError('Aborted grid simulation') + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + """ + if state is not None: + self.regen_state = state + else: + state = self.regen_state + return state + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + if state is not None: + self.relay_state = state + if self.ts.confirm('Set grid simulator relay to %s' % (state)) is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + state = self.relay_state + return state + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if voltage is not None: + pass + else: + voltage = (self.v_nom, self.v_nom, self.v_nom) + return voltage + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + """ + if voltage is not None: + self.v_max = voltage[0] + if self.ts.confirm('Set grid simulator maximum voltage to %s' % (voltage)) is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + voltage = (self.v_max, self.v_max, self.v_max) + return voltage diff --git a/Lib/svpelab/gridsim_rse.py b/Lib/svpelab/gridsim_rse.py index b903048..46e9303 100644 --- a/Lib/svpelab/gridsim_rse.py +++ b/Lib/svpelab/gridsim_rse.py @@ -1,526 +1,525 @@ -""" -Copyright (c) 2017, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os -import time -import socket - -import serial - -import grid_profiles -import gridsim - -rse_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'RSE' -} - -def gridsim_info(): - return rse_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = rse_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('modbus'), label='Modbus Address Start', default='40000') - info.param(pname('ip_addr'), label='IP Address', - active=pname('comm'), active_value=['TCP/IP'], default='192.168.1.10') - info.param(pname('ip_port'), label='IP Port', - active=pname('comm'), active_value=['TCP/IP'], default=5025) - -GROUP_NAME = 'rse' - - -class GridSim(gridsim.GridSim): - """ - RSE grid simulation implementation. - - Valid parameters: - mode - 'RSE' - auto_config - ['Enabled', 'Disabled'] - v_nom - v_max - i_max - freq - profile_name - serial_port - baudrate - timeout - write_timeout - ip_addr - ip_port - """ - def __init__(self, ts, group_name): - self.buffer_size = 1024 - self.conn = None - - gridsim.GridSim.__init__(self, ts, group_name) - - self.v_nom_param = self._param_value('v_nom') - self.v_max_param = self._param_value('v_max') - self.i_max_param = self._param_value('i_max') - self.freq_param = self._param_value('freq') - self.comm = self._param_value('comm') - self.serial_port = self._param_value('serial_port') - self.ipaddr = self._param_value('ip_addr') - self.ipport = self._param_value('ip_port') - self.baudrate = 115200 - self.timeout = 5 - self.write_timeout = 2 - self.cmd_str = '' - self._cmd = None - self._query = None - self.profile_name = ts.param_value('profile.profile_name') - - if self.comm == 'Serial': - self.open() # open communications - self._cmd = self.cmd_serial - self._query = self.query_serial - elif self.comm == 'TCP/IP': - self._cmd = self.cmd_tcp - self._query = self.query_tcp - - self.profile_stop() - - if self.auto_config == 'Enabled': - ts.log('Configuring the Grid Simulator.') - self.config() - - state = self.relay() - if state != gridsim.RELAY_CLOSED: - if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: - raise gridsim.GridSimError('Aborted grid simulation') - else: - self.ts.log('Turning on grid simulator.') - self.relay(state=gridsim.RELAY_CLOSED) - - def _param_value(self, name): - return - - def cmd_serial(self, cmd_str): - self.cmd_str = cmd_str - try: - if self.conn is None: - raise gridsim.GridSimError('Communications port not open') - - self.conn.flushInput() - self.conn.write(cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query_serial(self, cmd_str): - resp = '' - more_data = True - - self.cmd_serial(cmd_str) - - while more_data: - try: - count = self.conn.inWaiting() - if count < 1: - count = 1 - data = self.conn.read(count) - if len(data) > 0: - for d in data: - resp += d - if d == '\n': - more_data = False - break - else: - raise gridsim.GridSimError('Timeout waiting for response') - except gridsim.GridSimError: - raise - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response - More data problem') - - return resp - - def cmd_tcp(self, cmd_str): - try: - if self.conn is None: - self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) - self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.conn.settimeout(self.timeout) - self.conn.connect((self.ipaddr, self.ipport)) - - # print 'cmd> %s' % (cmd_str) - self.conn.send(cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query_tcp(self, cmd_str): - resp = '' - more_data = True - - self._cmd(cmd_str) - - while more_data: - try: - data = self.conn.recv(self.buffer_size) - if len(data) > 0: - for d in data: - resp += d - if d == '\n': #\r - more_data = False - break - except Exception, e: - raise gridsim.GridSimError('Timeout waiting for response') - - return resp - - def cmd(self, cmd_str): - self.cmd_str = cmd_str - try: - self._cmd(cmd_str) - resp = self._query('SYSTem:ERRor?\n') #\r - - if len(resp) > 0: - if resp[0] != '0': - raise gridsim.GridSimError(resp + ' ' + self.cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def query(self, cmd_str): - try: - resp = self._query(cmd_str).strip() - except Exception, e: - raise gridsim.GridSimError(str(e)) - - return resp - - def info(self): - return self.query('*IDN?\n') - - def config_phase_angles(self): - # set the phase angles for the 3 phases - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') - self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') - self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') - self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') - self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') - self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') - self.cmd('inst:coup none;:inst:nsel 3;:func sin\n') - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - self.ts.log('Grid simulator model: %s' % self.info().strip()) - - # put simulator in regenerative mode - state = self.regen() - if state != gridsim.REGEN_ON: - state = self.regen(gridsim.REGEN_ON) - self.ts.log('Grid sim regenerative mode is: %s' % state) - - # set the phase angles for the 3 phases - self.config_phase_angles() - - # set voltage range - v_max = self.v_max_param - v1, v2, v3 = self.voltage_max() - if v1 != v_max or v2 != v_max or v3 != v_max: - self.voltage_max(voltage=(v_max, v_max, v_max)) - v1, v2, v3 = self.voltage_max() - self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - # set nominal voltage - v_nom = self.v_nom_param - v1, v2, v3 = self.voltage() - if v1 != v_nom or v2 != v_nom or v3 != v_nom: - self.voltage(voltage=(v_nom, v_nom, v_nom)) - v1, v2, v3 = self.voltage() - self.ts.log('Grid sim nominal voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) - - # set max current if it's not already at gridsim_Imax - i_max = self.i_max_param - current = self.current() - if current != i_max: - self.current(i_max) - current = self.current() - self.ts.log('Grid sim max current: %s Amps' % current) - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - try: - self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, - timeout=self.timeout, writeTimeout=self.write_timeout) - time.sleep(2) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - if self.conn: - self.conn.close() - - def current(self, current=None): - """ - Set the value for current if provided. If none provided, obtains - the value for current. - """ - if current is not None: - self.cmd('inst:coup all;:curr %0.2f\n' % current) - curr_str = self.query('inst:nsel 1;:curr?\n') - return float(curr_str[:-1]) - - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max current. - """ - if current is not None: - self.cmd('inst:coup all;:curr %0.2f\n' % current) - curr_str = self.query('inst:nsel 1;:curr? max\n') - return float(curr_str[:-1]) - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - """ - if freq is not None: - self.cmd('freq %0.2f\n' % freq) - freq = self.query('freq?\n') - return freq - - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - if profile_name is None: - raise gridsim.GridSimError('Profile not specified.') - - if profile_name == 'Manual': # Manual reserved for not running a profile. - self.ts.log_warning('"Manual" simulation profile reserved for not autonomously running a profile.') - return - - v_nom = self.v_nom_param - freq_nom = self.freq_param - - # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step - if profile_name is 'Transient_Step': - if t_step is None: - raise gridsim.GridSimError('Transient profile did not have a duration.') - else: - # (time offset in seconds, % nominal voltage, % nominal frequency) - profile = [(0, v_step, f_step),(t_step, v_step, f_step),(t_step, 100, 100)] - - else: - # get the profile from grid_profiles - profile = grid_profiles.profiles.get(profile_name) - if profile is None: - raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) - - dwell_list = '' - v_list = '' - v_slew_list = '' - freq_list = '' - freq_slew_list = '' - func_list = '' - rep_list = '' - for i in range(1, len(profile)): - v = float(profile[i - 1][1]) - freq = float(profile[i - 1][2]) - t_delta = float(profile[i][0]) - float(profile[i - 1][0]) - v_delta = abs(float(profile[i][1]) - v) - freq_delta = abs(float(profile[i][2]) - freq) - v_slew = 'MAX' - freq_slew = 'MAX' - if t_delta > 0: - if i > 1: - dwell_list += ',' - v_list += ',' - v_slew_list += ',' - freq_list += ',' - freq_slew_list += ',' - func_list += ',' - rep_list += ',' - if v_delta > 0: - v_slew = '%0.3f' % (((v_delta/t_delta)/100.) * float(v_nom)) - v = float(profile[i][1]) # look at next voltage - if freq_delta > 0: - freq_slew = '%0.3f' % (((freq_delta/t_delta)/100.) * float(freq_nom)) - freq = float(profile[i][2]) # look at next frequency - dwell_list += '%0.3f' % t_delta - v_list += '%0.3f' % ((v/100.) * float(v_nom)) - v_slew_list += v_slew - freq_list += '%0.3f' % ((freq/100.) * float(freq_nom)) - freq_slew_list += freq_slew - func_list += 'SINE' - rep_list += '0' - - cmd_list = [] - cmd_list.append('trig:tran:sour imm\n') - cmd_list.append('list:step auto\n') - cmd_list.append('abort\n') - cmd_list.append('abort;:inst:coup none;:list:coun 1;:freq:mode list;:freq:slew:mode list\n') - cmd_list.append(':inst:nsel 1;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append(':inst:nsel 2;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append(':inst:nsel 3;:volt:mode list;:volt:slew:mode list;:func:mode list\n') - cmd_list.append('inst:coup all\n') - cmd_list.append(':list:dwel %s\n' % dwell_list) - cmd_list.append(':list:freq %s\n' % freq_list) - cmd_list.append(':list:freq:slew %s\n' % freq_slew_list) - cmd_list.append(':inst:nsel 1;:list:volt %s\n' % v_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':inst:nsel 2;:list:volt %s\n' % v_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':inst:nsel 3;:list:volt %s\n' % v_list) - cmd_list.append(':list:volt:slew %s\n' % v_slew_list) - cmd_list.append(':list:func %s\n' % func_list) - cmd_list.append(':list:rep %s\n' % rep_list) - cmd_list.append('*esr?\n') - cmd_list.append('trig:sync:sour imm\n') - cmd_list.append(':init\n') - - self.profile = cmd_list - - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - for entry in self.profile: - self.cmd(entry) - - def profile_stop(self): - """ - Stop the running profile. - """ - self.cmd('abort\n') - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - """ - if state == gridsim.REGEN_ON: - self.cmd('REGenerate:STATe ON\n') - self.query('*esr?\n') - self.cmd('INST:COUP ALL\n') - self.query('*esr?\n') - self.cmd('INST:COUP none;:inst:nsel 1;\n') - elif state == gridsim.REGEN_OFF: - self.cmd('REGenerate:STATe OFF\n') - self.query('*esr?\n') - self.cmd('INST:COUP ALL\n') - self.query('*esr?\n') - self.cmd('INST:COUP none;:inst:nsel 1;\n') - elif state is None: - current_state = self.query('REGenerate:STATe?\n') - if current_state is '1': - state = 'on' - else: - state = 'off' - else: - raise gridsim.GridSimError('Unknown regen state: %s', state) - return state - - def relay(self, state=None): - """ - Set the state of the relay if provided. Valid states are: RELAY_OPEN, - RELAY_CLOSED. If none is provided, obtains the state of the relay. - """ - if state is not None: - if state == gridsim.RELAY_OPEN: - self.cmd('abort;:outp off\n') - elif state == gridsim.RELAY_CLOSED: - self.cmd('abort;:outp on\n') - else: - raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) - else: - relay = self.query('outp?\n').strip() - # self.ts.log(relay) - if relay == '0': - state = gridsim.RELAY_OPEN - elif relay == '1': - state = gridsim.RELAY_CLOSED - else: - state = gridsim.RELAY_UNKNOWN - return state - - def voltage(self, voltage=None): - """ - Set the value for voltage 1, 2, 3 if provided. If none provided, obtains - the value for voltage. Voltage is a tuple containing a voltage value for - each phase. - """ - if voltage is not None: - # set output voltage on all phases - # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) - if type(voltage) is not list and type(voltage) is not tuple: - self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage) - else: - self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage[0]) # use the first value in the 3 phase list - v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac?\n') - v2 = self.query('inst:nsel 2;:volt:ac?\n') - v3 = self.query('inst:nsel 3;:volt:ac?\n') - return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - """ - if voltage is not None: - voltage = max(voltage) # voltage is a triplet but RSE only takes one value - if voltage == 150 or voltage == 300 or voltage == 600: - self.cmd('volt:rang %0.0f\n' % voltage) - else: - raise gridsim.GridSimError('Invalid Max Voltage %s, must be 150, 300 or 600 V.' % str(voltage)) - v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac? max\n') - v2 = self.query('inst:nsel 2;:volt:ac? max\n') - v3 = self.query('inst:nsel 3;:volt:ac? max\n') - return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v_nom_param - -if __name__ == "__main__": +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +import time +import socket + +import serial + +from . import grid_profiles +from . import gridsim + +rse_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'RSE' +} + +def gridsim_info(): + return rse_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = rse_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('modbus'), label='Modbus Address Start', default='40000') + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['TCP/IP'], default='192.168.1.10') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['TCP/IP'], default=5025) + +GROUP_NAME = 'rse' + + +class GridSim(gridsim.GridSim): + """ + RSE grid simulation implementation. + + Valid parameters: + mode - 'RSE' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + serial_port + baudrate + timeout + write_timeout + ip_addr + ip_port + """ + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.buffer_size = 1024 + self.conn = None + + self.v_nom_param = self._param_value('v_nom') + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.comm = self._param_value('comm') + self.serial_port = self._param_value('serial_port') + self.ipaddr = self._param_value('ip_addr') + self.ipport = self._param_value('ip_port') + self.baudrate = 115200 + self.timeout = 5 + self.write_timeout = 2 + self.cmd_str = '' + self._cmd = None + self._query = None + self.profile_name = ts.param_value('profile.profile_name') + + if self.comm == 'Serial': + self.open() # open communications + self._cmd = self.cmd_serial + self._query = self.query_serial + elif self.comm == 'TCP/IP': + self._cmd = self.cmd_tcp + self._query = self.query_tcp + + self.profile_stop() + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() + + state = self.relay() + if state != gridsim.RELAY_CLOSED: + if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + self.ts.log('Turning on grid simulator.') + self.relay(state=gridsim.RELAY_CLOSED) + + def _param_value(self, name): + return name + + def cmd_serial(self, cmd_str): + self.cmd_str = cmd_str + try: + if self.conn is None: + raise gridsim.GridSimError('Communications port not open') + + self.conn.flushInput() + self.conn.write(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_serial(self, cmd_str): + resp = '' + more_data = True + + self.cmd_serial(cmd_str) + + while more_data: + try: + count = self.conn.inWaiting() + if count < 1: + count = 1 + data = self.conn.read(count) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': + more_data = False + break + else: + raise gridsim.GridSimError('Timeout waiting for response') + except gridsim.GridSimError: + raise + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response - More data problem') + + return resp + + def cmd_tcp(self, cmd_str): + try: + if self.conn is None: + self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_tcp(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': #\r + more_data = False + break + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + self.cmd_str = cmd_str + try: + self._cmd(cmd_str) + resp = self._query('SYSTem:ERRor?\n') #\r + + if len(resp) > 0: + if resp[0] != '0': + raise gridsim.GridSimError(resp + ' ' + self.cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise gridsim.GridSimError(str(e)) + + return resp + + def info(self): + return self.query('*IDN?\n') + + def config_phase_angles(self): + # set the phase angles for the 3 phases + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:phas 0.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') + self.cmd('inst:coup none;:inst:nsel 2;:phas 120.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') + self.cmd('inst:coup none;:inst:nsel 3;:phas 240.0\n') + self.cmd('inst:coup none;:inst:nsel 1;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 2;:func sin\n') + self.cmd('inst:coup none;:inst:nsel 3;:func sin\n') + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('Grid simulator model: %s' % self.info().strip()) + + # put simulator in regenerative mode + state = self.regen() + if state != gridsim.REGEN_ON: + state = self.regen(gridsim.REGEN_ON) + self.ts.log('Grid sim regenerative mode is: %s' % state) + + # set the phase angles for the 3 phases + self.config_phase_angles() + + # set voltage range + v_max = self.v_max_param + v1, v2, v3 = self.voltage_max() + if v1 != v_max or v2 != v_max or v3 != v_max: + self.voltage_max(voltage=(v_max, v_max, v_max)) + v1, v2, v3 = self.voltage_max() + self.ts.log('Grid sim max voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) + + # set nominal voltage + v_nom = self.v_nom_param + v1, v2, v3 = self.voltage() + if v1 != v_nom or v2 != v_nom or v3 != v_nom: + self.voltage(voltage=(v_nom, v_nom, v_nom)) + v1, v2, v3 = self.voltage() + self.ts.log('Grid sim nominal voltage settings: v1 = %s, v2 = %s, v3 = %s' % (v1, v2, v3)) + + # set max current if it's not already at gridsim_Imax + i_max = self.i_max_param + current = self.current() + if current != i_max: + self.current(i_max) + current = self.current() + self.ts.log('Grid sim max current: %s Amps' % current) + + def open(self): + """ + Open the communications resources associated with the grid simulator. + """ + try: + self.conn = serial.Serial(port=self.serial_port, baudrate=self.baudrate, bytesize=8, stopbits=1, xonxoff=0, + timeout=self.timeout, writeTimeout=self.write_timeout) + time.sleep(2) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + if self.conn: + self.conn.close() + + def current(self, current=None): + """ + Set the value for current if provided. If none provided, obtains + the value for current. + """ + if current is not None: + self.cmd('inst:coup all;:curr %0.2f\n' % current) + curr_str = self.query('inst:nsel 1;:curr?\n') + return float(curr_str[:-1]) + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + """ + if current is not None: + self.cmd('inst:coup all;:curr %0.2f\n' % current) + curr_str = self.query('inst:nsel 1;:curr? max\n') + return float(curr_str[:-1]) + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if freq is not None: + self.cmd('freq %0.2f\n' % freq) + freq = self.query('freq?\n') + return freq + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + if profile_name is None: + raise gridsim.GridSimError('Profile not specified.') + + if profile_name == 'Manual': # Manual reserved for not running a profile. + self.ts.log_warning('"Manual" simulation profile reserved for not autonomously running a profile.') + return + + v_nom = self.v_nom_param + freq_nom = self.freq_param + + # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step + if profile_name is 'Transient_Step': + if t_step is None: + raise gridsim.GridSimError('Transient profile did not have a duration.') + else: + # (time offset in seconds, % nominal voltage, % nominal frequency) + profile = [(0, v_step, f_step),(t_step, v_step, f_step),(t_step, 100, 100)] + + else: + # get the profile from grid_profiles + profile = grid_profiles.profiles.get(profile_name) + if profile is None: + raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) + + dwell_list = '' + v_list = '' + v_slew_list = '' + freq_list = '' + freq_slew_list = '' + func_list = '' + rep_list = '' + for i in range(1, len(profile)): + v = float(profile[i - 1][1]) + freq = float(profile[i - 1][2]) + t_delta = float(profile[i][0]) - float(profile[i - 1][0]) + v_delta = abs(float(profile[i][1]) - v) + freq_delta = abs(float(profile[i][2]) - freq) + v_slew = 'MAX' + freq_slew = 'MAX' + if t_delta > 0: + if i > 1: + dwell_list += ',' + v_list += ',' + v_slew_list += ',' + freq_list += ',' + freq_slew_list += ',' + func_list += ',' + rep_list += ',' + if v_delta > 0: + v_slew = '%0.3f' % (((v_delta/t_delta)/100.) * float(v_nom)) + v = float(profile[i][1]) # look at next voltage + if freq_delta > 0: + freq_slew = '%0.3f' % (((freq_delta/t_delta)/100.) * float(freq_nom)) + freq = float(profile[i][2]) # look at next frequency + dwell_list += '%0.3f' % t_delta + v_list += '%0.3f' % ((v/100.) * float(v_nom)) + v_slew_list += v_slew + freq_list += '%0.3f' % ((freq/100.) * float(freq_nom)) + freq_slew_list += freq_slew + func_list += 'SINE' + rep_list += '0' + + cmd_list = [] + cmd_list.append('trig:tran:sour imm\n') + cmd_list.append('list:step auto\n') + cmd_list.append('abort\n') + cmd_list.append('abort;:inst:coup none;:list:coun 1;:freq:mode list;:freq:slew:mode list\n') + cmd_list.append(':inst:nsel 1;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append(':inst:nsel 2;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append(':inst:nsel 3;:volt:mode list;:volt:slew:mode list;:func:mode list\n') + cmd_list.append('inst:coup all\n') + cmd_list.append(':list:dwel %s\n' % dwell_list) + cmd_list.append(':list:freq %s\n' % freq_list) + cmd_list.append(':list:freq:slew %s\n' % freq_slew_list) + cmd_list.append(':inst:nsel 1;:list:volt %s\n' % v_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':inst:nsel 2;:list:volt %s\n' % v_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':inst:nsel 3;:list:volt %s\n' % v_list) + cmd_list.append(':list:volt:slew %s\n' % v_slew_list) + cmd_list.append(':list:func %s\n' % func_list) + cmd_list.append(':list:rep %s\n' % rep_list) + cmd_list.append('*esr?\n') + cmd_list.append('trig:sync:sour imm\n') + cmd_list.append(':init\n') + + self.profile = cmd_list + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.profile is not None: + for entry in self.profile: + self.cmd(entry) + + def profile_stop(self): + """ + Stop the running profile. + """ + self.cmd('abort\n') + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + """ + if state == gridsim.REGEN_ON: + self.cmd('REGenerate:STATe ON\n') + self.query('*esr?\n') + self.cmd('INST:COUP ALL\n') + self.query('*esr?\n') + self.cmd('INST:COUP none;:inst:nsel 1;\n') + elif state == gridsim.REGEN_OFF: + self.cmd('REGenerate:STATe OFF\n') + self.query('*esr?\n') + self.cmd('INST:COUP ALL\n') + self.query('*esr?\n') + self.cmd('INST:COUP none;:inst:nsel 1;\n') + elif state is None: + current_state = self.query('REGenerate:STATe?\n') + if current_state is '1': + state = 'on' + else: + state = 'off' + else: + raise gridsim.GridSimError('Unknown regen state: %s', state) + return state + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + if state is not None: + if state == gridsim.RELAY_OPEN: + self.cmd('abort;:outp off\n') + elif state == gridsim.RELAY_CLOSED: + self.cmd('abort;:outp on\n') + else: + raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) + else: + relay = self.query('outp?\n').strip() + # self.ts.log(relay) + if relay == '0': + state = gridsim.RELAY_OPEN + elif relay == '1': + state = gridsim.RELAY_CLOSED + else: + state = gridsim.RELAY_UNKNOWN + return state + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if voltage is not None: + # set output voltage on all phases + # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) + if type(voltage) is not list and type(voltage) is not tuple: + self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage) + else: + self.cmd('inst:coup all;:volt:ac %0.1f\n' % voltage[0]) # use the first value in the 3 phase list + v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac?\n') + v2 = self.query('inst:nsel 2;:volt:ac?\n') + v3 = self.query('inst:nsel 3;:volt:ac?\n') + return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + """ + if voltage is not None: + voltage = max(voltage) # voltage is a triplet but RSE only takes one value + if voltage == 150 or voltage == 300 or voltage == 600: + self.cmd('volt:rang %0.0f\n' % voltage) + else: + raise gridsim.GridSimError('Invalid Max Voltage %s, must be 150, 300 or 600 V.' % str(voltage)) + v1 = self.query('inst:coup none;:inst:nsel 1;:volt:ac? max\n') + v2 = self.query('inst:nsel 2;:volt:ac? max\n') + v3 = self.query('inst:nsel 3;:volt:ac? max\n') + return float(v1[:-1]), float(v2[:-1]), float(v3[:-1]) + + def i_max(self): + return self.i_max_param + + def v_max(self): + return self.v_max_param + + def v_nom(self): + return self.v_nom_param + +if __name__ == "__main__": pass \ No newline at end of file diff --git a/Lib/svpelab/gridsim_sim.py b/Lib/svpelab/gridsim_sim.py index d11459d..3bd8d40 100644 --- a/Lib/svpelab/gridsim_sim.py +++ b/Lib/svpelab/gridsim_sim.py @@ -1,60 +1,60 @@ -""" -Copyright (c) 2017, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os - -import gridsim - -sim_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'Grid Simulator Simulation' -} - -def gridsim_info(): - return sim_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = sim_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param_add_value(gname('mode'), sim_info['mode']) - -GROUP_NAME = 'sim' - - -class GridSim(gridsim.GridSim): - - def __init__(self, ts, group_name, params=None): - gridsim.GridSim.__init__(self, ts, group_name, params) +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os + +from . import gridsim + +sim_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Grid Simulator Simulation' +} + +def gridsim_info(): + return sim_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sim_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param_add_value(gname('mode'), sim_info['mode']) + +GROUP_NAME = 'sim' + + +class GridSim(gridsim.GridSim): + + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) diff --git a/Lib/svpelab/gridsim_sps.py b/Lib/svpelab/gridsim_sps.py index 432cd59..8a0cab2 100644 --- a/Lib/svpelab/gridsim_sps.py +++ b/Lib/svpelab/gridsim_sps.py @@ -1,829 +1,832 @@ -""" -Copyright (c) 2017, Austrian Institute of Technology, Sandia National Labs and SunSpec Alliance -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Questions can be directed to support@sunspec.org -""" - -import os -from collections import namedtuple - -import grid_profiles -import gridsim - -sps_info = { - 'name': os.path.splitext(os.path.basename(__file__))[0], - 'mode': 'SPS' -} - -def gridsim_info(): - return sps_info - -def params(info, group_name): - gname = lambda name: group_name + '.' + name - pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name - mode = sps_info['mode'] - info.param_add_value(gname('mode'), mode) - info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, - active=gname('mode'), active_value=mode, glob=True) - info.param(pname('v_nom'), label='EUT nominal voltage for all 3 phases', default=230.0) - info.param(pname('v_max'), label='Max Voltage', default=270.0) - info.param(pname('i_max'), label='Max Current', default=150.0) - info.param(pname('freq'), label='Frequency', default=50.0) - - info.param(pname('comm'), label='Communications Interface', default='VISA', - values=['Serial', 'GPIB', 'VISA']) - - info.param(pname('serial_port'), label='Serial Port', - active=pname('comm'), active_value=['Serial'], default='com1') - - info.param(pname('gpib_bus_address'), label='GPIB Bus Address', - active=pname('comm'), active_value=['GPIB'], default=6) - info.param(pname('gpib_board'), label='GPIB Board Number', - active=pname('comm'), active_value=['GPIB'], default=0) - - info.param(pname('visa_device'), label='VISA Device String', active=pname('comm'), - active_value=['VISA'], default='GPIB0::6::INSTR') - # info.param(pname('visa_path', label='VISA Module Path', active=pname('comm', - # active_value=['VISA'], default='C:\Python27\lib\site-packages', ptype=script.PTYPE_DIR) - -GROUP_NAME = 'sps' - - -class GridSim(gridsim.GridSim): - """ - Spitzenberger Spiess (SPS) grid simulation implementation. - - Valid parameters: - mode - 'SPS' - auto_config - ['Enabled', 'Disabled'] - v_nom - v_max - i_max - freq - profile_name - serial_port - gpib_bus_address - gpib_board - visa_device - visa_path - """ - - def __init__(self, ts, group_name): - self.rm = None # Resource Manager for VISA - self.conn = None # Connection to instrument for VISA-GPIB - - self.dt_min = 0.02 # minimal delta t for amplitude pulses to avoid to fast amplitude changes - self.ProfileEntry = namedtuple('ProfileEntry', 't v f ph') - self.execution_time = 0.02 - self.eps = 0.01 - - gridsim.GridSim.__init__(self, ts, group_name) - - self.v_nom_param = self._param_value('v_nom') - self.v_max_param = self._param_value('v_max') - self.i_max_param = self._param_value('i_max') - self.freq_param = self._param_value('freq') - self.profile_name = self._param_value('profile_name') - self.comm = self._param_value('comm') - self.serial_port = self._param_value('serial_port') - - self.gpib_bus_address = self._param_value('gpib_bus_address') - self.gpib_board = self._param_value('gpib_board') - - self.visa_device = self._param_value('visa_device') - self.visa_path = self._param_value('visa_path') - - self.open() # open communications, not the relay - self.profile_stop() - - if self.auto_config == 'Enabled': - ts.log('Configuring the Grid Simulator.') - self.config() # sets the output voltage to v_nom - - state = self.relay() - if state != gridsim.RELAY_CLOSED: - if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: - raise gridsim.GridSimError('Aborted grid simulation') - else: - self.ts.log('Turning on grid simulator.') - self.relay(state=gridsim.RELAY_CLOSED) - - if self.profile_name is not None and self.profile_name != 'Manual': - self.profile_load(self.v_nom_param, self.freq_param, self.profile_name) - - def _param_value(self, name): - return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) - - def info(self): - """ - Returns the SCPI identification of the device - :return: a string like "SPS SyCore V2.01.074" - """ - - return self._query('*IDN?') - - def _config_phase_angles(self): - # set the phase angles for the 3 phases - self._write('OSC:ANG 1,0') - self._write('OSC:ANG 2,120') - self._write('OSC:ANG 3,240') - - def config(self): - """ - Perform any configuration for the simulation based on the previously - provided parameters. - """ - self.ts.log('Grid simulator model: %s' % self.info().strip()) - - # set the phase angles for the 3 phases - self._config_phase_angles() - - # set voltage range - v_max = self.v_max_param - v1, v2, v3 = self.voltage_max() - if v1 != v_max or v2 != v_max or v3 != v_max: - self.voltage_max(v_max) - v1, v2, v3 = self.voltage_max() - self.ts.log('Grid sim max voltage settings: %.2fV' % v1) - - # set nominal voltage - v_nom = self.v_nom_param - v1, v2, v3 = self.voltage() - if not (self._numeric_equal(v1, v_nom, self.eps) and self._numeric_equal(v2, v_nom, self.eps) - and self._numeric_equal(v3, v_nom, self.eps)): - # because of 229.995 equals 230 due to limited accuracy of SPS - self.voltage(voltage=(v_nom, v_nom, v_nom)) - v1, v2, v3 = self.voltage() - self.ts.log('Grid sim nominal voltage settings: %.2fV' % v1) - - # set max current if it's not already at gridsim_Imax - i_max = self.i_max_param - current = self.current_max() - if i_max != max(current) and i_max != min(current): - # TODO: discuss what to do, when max currents for single phases are not the same - self.current_max(i_max) - current = self.current_max(i_max) - self.ts.log('Grid sim max current: %.2fA' % current[0]) - - # set nominal frequency - f_nom = self.freq_param - f = self.freq() - - if not self._numeric_equal(f, f_nom, self.eps): # f != f_nom: - f = self.freq(f_nom) - self.ts.log('Grid sim nominal frequency settings: %.2fHz' % f) - - # TODO: discuss what else should be configured here... - # trigger angle, AMP mode (AC, DC) - # current limitation mode, ... - - def open(self): - """ - Open the communications resources associated with the grid simulator. - """ - if self.comm == 'Serial': - ''' Config according to th SyCore manual - Baudrate: 9600 B/s - Databit: 8 - Stopbit: 1 - Parity: no - Handshake: none - use CR at the end of a command - ''' - raise NotImplementedError('The driver for serial connection (RS232/RS485) is not implemented yet. ' + - 'Please use VISA which supports also serial connection') - elif self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + - 'Please use VISA which supports also GPIB devices') - elif self.comm == 'VISA': - try: - # sys.path.append(os.path.normpath(self.visa_path)) - import visa - self.rm = visa.ResourceManager() - self.conn = self.rm.open_resource(self.visa_device) - - # the default pyvisa write termination is '\r\n' which does not work with the SPS - self.conn.write_termination = '\n' - - self.ts.sleep(1) - - except Exception, e: - raise gridsim.GridSimError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) - else: - raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) - - def close(self): - """ - Close any open communications resources associated with the grid - simulator. - """ - - if self.comm == 'Serial': - raise NotImplementedError('The driver for serial connection (RS232/RS485) is not implemented yet') - elif self.comm == 'GPIB': - raise NotImplementedError('The driver for plain GPIB is not implemented yet.') - elif self.comm == 'VISA': - try: - if self.rm is not None: - if self.conn is not None: - self.conn.close() - self.rm.close() - - self.ts.sleep(1) - except Exception, e: - raise gridsim.GridSimError(str(e)) - else: - raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) - - def current(self, current=None): - """ - WARNING: the SPS cannot set the current, because it is only a voltage amplifier - :param current: parameter just here because of base class. Anything != None will raise an Exception - :return: Returns a measurement of the currents of the SPS - - """ - - if current is not None: - raise gridsim.GridSimError('SPS cannot set the current. Use this function only to get current measurements') - else: - # TODO: current measurements are not - return [self._measure_current(1), self._measure_current(2), self._measure_current(3)] - - def current_max(self, current=None): - """ - Set the value for max current if provided. If none provided, obtains - the value for max current. - :param current: - :return: - """ - - if current is not None: - i_max = self._create_3tuple(current) - - # activate current limitation - self._write('curr:limitation:control 1') - # set current limitation - self._write('curr:limitation:level 1,%f' % i_max[0]) - self._write('curr:limitation:level 2,%f' % i_max[1]) - self._write('curr:limitation:level 3,%f' % i_max[2]) - - else: - i_max = [float(self._query('curr:limitation:level 1?')), - float(self._query('curr:limitation:level 2?')), - float(self._query('curr:limitation:level 3?'))] - - return i_max - - def freq(self, freq=None): - """ - Set the value for frequency if provided. If none provided, obtains - the value for frequency. - - :param freq: Frequency in Hertz as float - """ - - if freq is not None: - self._write('OSC:FREQ %.2f' % freq) - else: - # measuring the frequency seems not to work. SPS only returns strange values - # --> return the frequency set value instead - freq = float(self._query('OSC:FREQ?')) - - return freq - - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): - """ - - :param v_nom: - :param freq_nom: - :param profile_name: - :param v_step: - :param f_step: - :param t_step: - :return: - """ - - if profile_name is None: - raise gridsim.GridSimError('Profile not specified') - - if profile_name == 'Manual': # Manual reserved for not running a profile. - self.ts.log_warning('Manual reserved for not running a profile') - return - - v_nom = self.v_nom_param - freq_nom = self.freq_param - - profile_entry = self.ProfileEntry # t v f ph - raw_profile = [] - profile = [] - dt_min = self.dt_min - - # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step - if profile_name is 'Transient_Step': - if t_step is None: - raise gridsim.GridSimError('Transient profile did not have a duration.') - else: - # (time offset in seconds, % nominal voltage, % nominal frequency) - raw_profile.append(profile_entry(t=0, v=v_step, f=f_step, ph=123)) - raw_profile.append(profile_entry(t=t_step, v=v_step, f=f_step, ph=123)) - raw_profile.append(profile_entry(t=t_step, v=100, f=100, ph=123)) - else: - # get the profile from grid_profiles - input_profile = grid_profiles.profiles.get(profile_name) - - if input_profile is None: - raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) - else: - for entry in input_profile: - raw_profile.append(profile_entry(t=entry[0], - v=entry[1], - f=entry[2], - ph=123)) - - if raw_profile[0].t == 0: - first_dt = dt_min - slew_rate_limited = True - else: - first_dt = raw_profile[0].t - slew_rate_limited = False - - profile.append(profile_entry(t=first_dt, # at least dt_min as rise time - v=(raw_profile[0].v/100.0)*v_nom, - f=(raw_profile[0].f/100.0)*freq_nom, - ph=123)) - - # TODO: possible bug: more than once a slew rate limitation --> time of sync for slew rate - # possible solution: instead a bool-value, use a float for 'slew rate time offsync' that counts up and down - for i in range(1, len(raw_profile)): - dt = raw_profile[i].t - raw_profile[i-1].t - if dt < self.dt_min: - dt = self.dt_min - slew_rate_limited = True - else: - if slew_rate_limited: # limited slew rate the last change, so reduce the current duration by dt_min - dt -= self.dt_min - slew_rate_limited = False - else: - pass - - profile.append(profile_entry(t=dt, - v=(raw_profile[i].v/100.0)*v_nom, - f=(raw_profile[i].f/100.0)*freq_nom, - ph=123)) - - self.profile = profile - - @staticmethod - def _numeric_equal(x, y, eps): - return abs(x-y) < eps - - def profile_start(self): - """ - Start the loaded profile. - """ - if self.profile is not None: - self.ts.log('Starting profile: %s' % self.profile_name) - prev_v = self.voltage()[0] - prev_f = self.freq() - - for entry in self.profile: - if not self._numeric_equal(prev_v, entry.v, self.eps): - if not self._numeric_equal(prev_f, entry.f, self.eps): - # change in voltage and frequency - self.ts.log('\tChange voltage from %0.1fV to %0.1fV and frequency from %0.1fHz to %0.1fHz in %0.2fs' - % (prev_v, entry.v, prev_f, entry.f, entry.t)) - self.amplitude_frequency_ramp(amplitude_end_value=entry.v, end_frequency=entry.f, - ramp_time=entry.t, phases=entry.ph, - amplitude_start_value=prev_v, start_frequency=prev_f) - else: - # change in voltage - self.ts.log('\tChange voltage from %0.1fV to %0.1fV in %0.2fs' % (prev_v, entry.v, entry.t)) - self.amplitude_ramp(end_value=entry.v, ramp_time=entry.t, phases=entry.ph, start_value=prev_v) - - elif not self._numeric_equal(prev_f, entry.f, self.eps): - # change in frequency - self.ts.log('\tChange frequency from %0.1fHz to %0.1fHz in %0.2fs' % (prev_f, entry.f, entry.t)) - self.frequency_ramp(end_frequency=entry.f, ramp_time=entry.t, start_frequency=prev_f) - - else: - # wait, because no change in voltage or frequency - self.ts.log('\tWait %0.2fs' % entry.t) - self.ts.sleep(entry.t) - - prev_v = entry.v - prev_f = entry.f - - self.ts.log('Finished profile') - else: - raise gridsim.GridSimError('You have to load a profile before starting it') - - def profile_stop(self): - """ - Stop the running profile. - """ - self.stop_command() - # TODO: this will NOT stop the profile, but only the current ramp. - # Also, at the moment the profile_start function will be executed until the profile is done - - def regen(self, state=None): - """ - Set the state of the regen mode if provided. Valid states are: REGEN_ON, - REGEN_OFF. If none is provided, obtains the state of the regen mode. - :param state: - :return: - """ - if state == gridsim.REGEN_ON: - # do nothing, because regen mode is always on - pass - elif state == gridsim.REGEN_OFF: - raise gridsim.GridSimError('Cannot disable the regen mode. It is always ON for the SPS gridsim') - elif state is None: - state = gridsim.REGEN_ON # Regeneration is always on for SPS - else: - raise gridsim.GridSimError('Unknown regen state: %s', state) - - return state - - def relay(self, state=None): - """ - Set the state of the relay if provided. If none is provided, obtains the state of the relay. - - :param state: valid states are: RELAY_OPEN, RELAY_CLOSED - """ - - if state is not None: - if state == gridsim.RELAY_OPEN: - self._write('AMP:Output 0') - self.ts.log('Opened Relay') - elif state == gridsim.RELAY_CLOSED: - self._write('AMP:Output 1') - self.ts.log('Closed Relay') - else: - raise gridsim.GridSimError('Invalid relay state: %s' % state) - else: - state = int(self._query('AMP:Output?')) - if state == 0: - state = gridsim.RELAY_OPEN - elif state == 1: - state = gridsim.RELAY_CLOSED - else: - state = gridsim.RELAY_UNKNOWN - return state - - def voltage(self, voltage=None): - """ - Set the value for voltage phase 1 to 3 if provided. If none provided, obtains - the set value for voltage. Voltage is a tuple containing a voltage value for - each phase. - - :param voltage: Voltages in Volt as float - """ - - if voltage is not None: - v = self._create_3tuple(voltage) - - # use ramp instead of setting voltages directly to limit slew rate - if v[0] == v[1] == v[2]: - # one ramp for all - self.amplitude_ramp(v[0], self.dt_min, 123) - else: - # three consecutive ramps for each phase - self.amplitude_ramp(v[0], self.dt_min, 1) - self.amplitude_ramp(v[1], self.dt_min, 2) - self.amplitude_ramp(v[2], self.dt_min, 3) - else: - # as discussed, return here the set value and not the measured voltage - v = [self._get_voltage_set_value(1), - self._get_voltage_set_value(2), - self._get_voltage_set_value(3)] - - return v - - def voltage_max(self, voltage=None): - """ - Set the value for max voltage if provided. If none provided, obtains - the value for max voltage. - :param voltage: - :return: - """ - - if voltage is not None: - voltage = float(max(voltage)) # voltage is a triplet but SPS only takes one value - - if voltage <= 0: - raise gridsim.GridSimError('Maximum Voltage must be greater than 0V') - - # get range values - range_values = str(self._query('conf:amp:range?')).split(',') - - for i, rg in enumerate(range_values): - value = float(rg[:-1]) - if voltage == value: - self._write('amp:range %i' % i) - return self._create_3tuple(voltage) - - # if code reaches this, the set value is not within the supported ranges - raise gridsim.GridSimError( - 'Invalid maximum voltage. SPS does not support %sV as maximum Voltage (Range)' % str(voltage)) - else: - # return 270 - - # get range - act_range = int(self._query('amp:range?')) - # get range values - range_values = str(self._query('conf:amp:range?')).split(',') - - return self._create_3tuple(float(range_values[act_range - 1][:-1])) - - def i_max(self): - return self.i_max_param - - def v_max(self): - return self.v_max_param - - def v_nom(self): - return self.v_nom_param - - def stop_command(self): - """ - Stops the current command. Used to stop an amplitude pulse - :return: None - """ - - self._write('BREAK') - - def setup_amplitude_pulse(self, start_value, pulse_value, end_value, - rise_time, duration, fall_time): - """ - Times in seconds, 0-3600 - - :param start_value: - :param pulse_value: - :param end_value: - :param rise_time: - :param duration: - :param fall_time: - :return: - """ - - # Amplitude values - self._write('OSC:APuls:START %0.3fV' % start_value) - self._write('OSC:APuls:PULS %0.3fV' % pulse_value) - self._write('OSC:APuls:END %0.3fV' % end_value) - - # Times - self._write('OSC:APuls:RISET %.3f' % rise_time) - self._write('OSC:APuls:DURAT %.3f' % duration) - self._write('OSC:APuls:FALLT %.3f' % fall_time) - - def start_amplitude_pulse(self, phases): - """ - - :param phases: string or int, 1,2,3,12,23,13, 123 - :return: - """ - phases = self._phases2int(phases) - self._write('OSC:APULS:GO %i' % phases) - - def start_amplitude_frequency_pulse(self, phases): - """ - - :param phases: string or int, 1,2,3,12,23,13, 123 - :return: - """ - phases = self._phases2int(phases) - self._write('OSC:AFPULS:GO %i' % phases) - - def amplitude_frequency_ramp(self, amplitude_end_value, end_frequency, ramp_time, phases, - amplitude_start_value=None, start_frequency=None): - - self.amplitude_ramp(end_value=amplitude_end_value, ramp_time=ramp_time, phases=phases, - start_value=amplitude_start_value, start_ramp=False) - self.frequency_ramp(end_frequency=end_frequency, ramp_time=ramp_time, - start_frequency=start_frequency, start_ramp=False) - - self.start_amplitude_frequency_pulse(phases) - self.ts.sleep(max(0, ramp_time - 4 * self.execution_time)) - - def amplitude_ramp(self, end_value, ramp_time, phases, start_value=None, start_ramp=True): - """ - - :param end_value: - :param ramp_time: - :param phases: - :param start_value: - :param start_ramp: - :return: - """ - - """ equlas amplitude pulse with - - start_value <-> current_value - - pulse_value <-> end_value - - end_value <-> end_value - - rise_time <-> rise_time - - duration <-> 0 - - fall_time <-> 0 - """ - - phases = self._phases2int(phases) - - if start_value is None: - if phases in (1, 2, 3): - start_value = self._get_voltage_set_value(phases) - elif phases in (12, 13, 123): - # TODO: check if the phases have the same set value, if not, raise exception - # by now, use value of phase 1 or 2 - start_value = self._get_voltage_set_value(1) - elif phases == 23: - start_value = self._get_voltage_set_value(2) - else: - raise ValueError('Invalid argument for phases: %i' % phases) - - self.setup_amplitude_pulse(start_value, end_value, end_value, ramp_time, 0, 0) - - if start_ramp: # only start if start_ramp == True; False is needed for the AFPULS - self.start_amplitude_pulse(phases) - self.ts.sleep(max(0, ramp_time - 2 * self.execution_time)) - # TODO: improve timing accuracy by finding out the execution time - - def setup_frequency_pulse(self, start_frequency, pulse_frequency, end_frequency, - rise_time, duration, fall_time): - """ - Times in seconds, 0-3600, Frequency in Hertz - - :param start_frequency: - :param pulse_frequency: - :param end_frequency: - :param rise_time: - :param duration: - :param fall_time: - :return: - """ - - # Frequency values - self._write('OSC:FPuls:START %0.3f' % start_frequency) - self._write('OSC:FPuls:PULS %0.3f' % pulse_frequency) - self._write('OSC:FPuls:END %0.3f' % end_frequency) - - # Times - self._write('OSC:FPuls:RISET %.3f' % rise_time) - self._write('OSC:FPuls:DURAT %.3f' % duration) - self._write('OSC:FPuls:FALLT %.3f' % fall_time) - - def start_frequency_pulse(self): - """ - - :return: - """ - self._write('OSC:FPULS:GO') - - def frequency_ramp(self, end_frequency, ramp_time, start_frequency=None, start_ramp=True): - if start_frequency is None: - start_frequency = self.freq() - - self.setup_frequency_pulse(start_frequency, end_frequency, end_frequency, ramp_time, 0, 0) - - if start_ramp: # only start if start_ramp == True; False is needed for the AFPULS - self.start_frequency_pulse() - self.ts.sleep(max(0, ramp_time - 2*self.execution_time)) - - def _query(self, cmd_str): - """ - Performs a SCPI query with the given cmd_str and returns the reply of the device - :param cmd_str: the SCPI command which must be a valid command - :return: the answer from the SPS - """ - - try: - if self.conn is None: - raise gridsim.GridSimError('GPIB connection not open') - - return self.conn.query(cmd_str) - except Exception, e: - raise gridsim.GridSimError(str(e)) - - def _write(self, cmd_str): - """ - Performs a SCPI write command with the given cmd_str - :param cmd_str: the SCPI command which must be a valid command - """ - try: - if self.conn is None: - raise gridsim.GridSimError('GPIB connection not open') - - num_written_bytes = self.conn.write(cmd_str) - # TODO: check num_written_bytes to see if writing succeeded - - return num_written_bytes - except Exception, e: - raise gridsim.GridSimError(str(e)) - - @staticmethod - def _create_3tuple(value): - """ - Checks whether value is a - :param value: - :return: - """ - - try: # value is an array - if len(value) == 1: - return [value[0], value[0], value[0]] - elif len(value) == 3: - return [value[0], value[1], value[2]] - else: - raise ValueError('Value must be length 1 or 3') - except (IndexError, TypeError): # value is a scalar - return [value, value, value] - - @staticmethod - def _phases2int(phases): - if isinstance(phases, (str, float)): - try: - phases = int(phases) # "12" --> 12 - - except ValueError: - raise ValueError('String %s for phases has to represent a valid int' % phases) - return phases - - def _measure_value(self, phase, what): - """ - Returns a measurement value from the SPS - :param phase: which phase, from 1 to 3 - :param what: which entity according to SPS manual. Currently supported: 'VOLT', 'CURR', 'S' - :return: the measured value as float - """ - - phase = int(float(phase)) # convert if string '1' or 1.0 instead of int 1 - if phase < 1 or phase > 3: - raise ValueError('Phase must be between 1 and 3') - else: - suffix = {'VOLT': -2, 'CURR': -2, 'S': -3} - if what in suffix.keys(): - self._write('CONF:MEAS:PH %i' % phase) - value = self._query('MEAS:' + what + '?') - # query returns the unit + '\n' which has to be removed before converting to float - return float(value[:suffix[what]]) - else: - raise ValueError('A query for the measurement of ' + what + ' is not possible or not implemented yet') - - def _measure_current(self, phase): - """ - Measures the current of the given phase - :param phase: which phase, from 1 to 3 - :return: the current in A as float - """ - return self._measure_value(phase, 'CURR') - - def _measure_apparent_power(self, phase): - """ - Measures the apparent power of the given phase - :param phase: which phase, int from 1 to 3 - :return: the apparent power in VA as float - """ - return self._measure_value(phase, 'S') - - def _measure_voltage(self, phase): - """ - Measures the voltage of the given phase - :param phase: which phase, int from 1 to 3 - :return: the voltage in V as float - """ - return self._measure_value(phase, 'VOLT') - - def _get_voltage_set_value(self, phase): - """ - - :param phase: - :return: - """ - return float(self._query('OSC:AMP %i?' % phase)) - -if __name__ == "__main__": - pass +""" +Copyright (c) 2017, Austrian Institute of Technology, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from collections import namedtuple +from . import grid_profiles +from . import gridsim + +sps_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'SPS' +} + +def gridsim_info(): + return sps_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sps_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('v_nom'), label='EUT nominal voltage for all 3 phases', default=230.0) + info.param(pname('v_max'), label='Max Voltage', default=270.0) + info.param(pname('i_max'), label='Max Current', default=150.0) + info.param(pname('freq'), label='Frequency', default=50.0) + + info.param(pname('comm'), label='Communications Interface', default='VISA', + values=['Serial', 'GPIB', 'VISA']) + + info.param(pname('serial_port'), label='Serial Port', + active=pname('comm'), active_value=['Serial'], default='com1') + + info.param(pname('gpib_bus_address'), label='GPIB Bus Address', + active=pname('comm'), active_value=['GPIB'], default=6) + info.param(pname('gpib_board'), label='GPIB Board Number', + active=pname('comm'), active_value=['GPIB'], default=0) + + info.param(pname('visa_device'), label='VISA Device String', active=pname('comm'), + active_value=['VISA'], default='GPIB0::6::INSTR') + # info.param(pname('visa_path', label='VISA Module Path', active=pname('comm', + # active_value=['VISA'], default='C:\Python27\lib\site-packages', ptype=script.PTYPE_DIR) + +GROUP_NAME = 'sps' + + +class GridSim(gridsim.GridSim): + """ + Spitzenberger Spiess (SPS) grid simulation implementation. + + Valid parameters: + mode - 'SPS' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + serial_port + gpib_bus_address + gpib_board + visa_device + visa_path + """ + + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.rm = None # Resource Manager for VISA + self.conn = None # Connection to instrument for VISA-GPIB + + self.dt_min = 0.02 # minimal delta t for amplitude pulses to avoid to fast amplitude changes + self.ProfileEntry = namedtuple('ProfileEntry', 't v f ph') + self.execution_time = 0.02 + self.eps = 0.01 + + self.v_nom_param = self._param_value('v_nom') + self.v_max_param = self._param_value('v_max') + self.i_max_param = self._param_value('i_max') + self.freq_param = self._param_value('freq') + self.profile_name = self._param_value('profile_name') + self.comm = self._param_value('comm') + self.serial_port = self._param_value('serial_port') + + self.gpib_bus_address = self._param_value('gpib_bus_address') + self.gpib_board = self._param_value('gpib_board') + + self.visa_device = self._param_value('visa_device') + self.visa_path = self._param_value('visa_path') + + self.open() # open communications, not the relay + self.profile_stop() + + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + self.config() # sets the output voltage to v_nom + + state = self.relay() + if state != gridsim.RELAY_CLOSED: + if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + self.ts.log('Turning on grid simulator.') + self.relay(state=gridsim.RELAY_CLOSED) + + if self.profile_name is not None and self.profile_name != 'Manual': + self.profile_load(self.v_nom_param, self.freq_param, self.profile_name) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def info(self): + """ + Returns the SCPI identification of the device + :return: a string like "SPS SyCore V2.01.074" + """ + + return self._query('*IDN?') + + def _config_phase_angles(self): + # set the phase angles for the 3 phases + self._write('OSC:ANG 1,0') + self._write('OSC:ANG 2,120') + self._write('OSC:ANG 3,240') + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log('Grid simulator model: %s' % self.info().strip()) + + # set the phase angles for the 3 phases + self._config_phase_angles() + + # set voltage range + v_max = self.v_max_param + v1, v2, v3 = self.voltage_max() + if v1 != v_max or v2 != v_max or v3 != v_max: + self.voltage_max(v_max) + v1, v2, v3 = self.voltage_max() + self.ts.log('Grid sim max voltage settings: %.2fV' % v1) + + # set nominal voltage + v_nom = self.v_nom_param + v1, v2, v3 = self.voltage() + if not (self._numeric_equal(v1, v_nom, self.eps) and self._numeric_equal(v2, v_nom, self.eps) + and self._numeric_equal(v3, v_nom, self.eps)): + # because of 229.995 equals 230 due to limited accuracy of SPS + self.voltage(voltage=(v_nom, v_nom, v_nom)) + v1, v2, v3 = self.voltage() + self.ts.log('Grid sim nominal voltage settings: %.2fV' % v1) + + # set max current if it's not already at gridsim_Imax + i_max = self.i_max_param + current = self.current_max() + if i_max != max(current) and i_max != min(current): + # TODO: discuss what to do, when max currents for single phases are not the same + self.current_max(i_max) + current = self.current_max(i_max) + self.ts.log('Grid sim max current: %.2fA' % current[0]) + + # set nominal frequency + f_nom = self.freq_param + f = self.freq() + + if not self._numeric_equal(f, f_nom, self.eps): # f != f_nom: + f = self.freq(f_nom) + self.ts.log('Grid sim nominal frequency settings: %.2fHz' % f) + + # TODO: discuss what else should be configured here... + # trigger angle, AMP mode (AC, DC) + # current limitation mode, ... + + def open(self): + """ + Open the communications resources associated with the grid simulator. + """ + if self.comm == 'Serial': + ''' Config according to th SyCore manual + Baudrate: 9600 B/s + Databit: 8 + Stopbit: 1 + Parity: no + Handshake: none + use CR at the end of a command + ''' + raise NotImplementedError('The driver for serial connection (RS232/RS485) is not implemented yet. ' + + 'Please use VISA which supports also serial connection') + elif self.comm == 'GPIB': + raise NotImplementedError('The driver for plain GPIB is not implemented yet. ' + + 'Please use VISA which supports also GPIB devices') + elif self.comm == 'VISA': + try: + # sys.path.append(os.path.normpath(self.visa_path)) + import pyvisa as visa + self.rm = visa.ResourceManager() + self.conn = self.rm.open_resource(self.visa_device) + + # the default pyvisa write termination is '\r\n' which does not work with the SPS + self.conn.write_termination = '\n' + + self.ts.sleep(1) + + except Exception as e: + raise gridsim.GridSimError('Cannot open VISA connection to %s\n\t%s' % (self.visa_device,str(e))) + else: + raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) + + def close(self): + """ + Close any open communications resources associated with the grid + simulator. + """ + + if self.comm == 'Serial': + raise NotImplementedError('The driver for serial connection (RS232/RS485) is not implemented yet') + elif self.comm == 'GPIB': + raise NotImplementedError('The driver for plain GPIB is not implemented yet.') + elif self.comm == 'VISA': + try: + if self.rm is not None: + if self.conn is not None: + self.conn.close() + self.rm.close() + + self.ts.sleep(1) + except Exception as e: + raise gridsim.GridSimError(str(e)) + else: + raise ValueError('Unknown communication type %s. Use Serial, GPIB or VISA' % self.comm) + + def current(self, current=None): + """ + WARNING: the SPS cannot set the current, because it is only a voltage amplifier + :param current: parameter just here because of base class. Anything != None will raise an Exception + :return: Returns a measurement of the currents of the SPS + + """ + + if current is not None: + raise gridsim.GridSimError('SPS cannot set the current. Use this function only to get current measurements') + else: + # TODO: current measurements are not + return [self._measure_current(1), self._measure_current(2), self._measure_current(3)] + + def current_max(self, current=None): + """ + Set the value for max current if provided. If none provided, obtains + the value for max current. + :param current: + :return: + """ + + if current is not None: + i_max = self._create_3tuple(current) + + # activate current limitation + self._write('curr:limitation:control 1') + # set current limitation + self._write('curr:limitation:level 1,%f' % i_max[0]) + self._write('curr:limitation:level 2,%f' % i_max[1]) + self._write('curr:limitation:level 3,%f' % i_max[2]) + + else: + i_max = [float(self._query('curr:limitation:level 1?')), + float(self._query('curr:limitation:level 2?')), + float(self._query('curr:limitation:level 3?'))] + + return i_max + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + + :param freq: Frequency in Hertz as float + """ + + if freq is not None: + self._write('OSC:FREQ %.2f' % freq) + else: + # measuring the frequency seems not to work. SPS only returns strange values + # --> return the frequency set value instead + freq = float(self._query('OSC:FREQ?')) + + return freq + + def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + """ + + :param v_nom: + :param freq_nom: + :param profile_name: + :param v_step: + :param f_step: + :param t_step: + :return: + """ + + if profile_name is None: + raise gridsim.GridSimError('Profile not specified') + + if profile_name == 'Manual': # Manual reserved for not running a profile. + self.ts.log_warning('Manual reserved for not running a profile') + return + + v_nom = self.v_nom_param + freq_nom = self.freq_param + + profile_entry = self.ProfileEntry # t v f ph + raw_profile = [] + profile = [] + dt_min = self.dt_min + + # for simple transient steps in voltage or frequency, use v_step, f_step, and t_step + if profile_name is 'Transient_Step': + if t_step is None: + raise gridsim.GridSimError('Transient profile did not have a duration.') + else: + # (time offset in seconds, % nominal voltage, % nominal frequency) + raw_profile.append(profile_entry(t=0, v=v_step, f=f_step, ph=123)) + raw_profile.append(profile_entry(t=t_step, v=v_step, f=f_step, ph=123)) + raw_profile.append(profile_entry(t=t_step, v=100, f=100, ph=123)) + else: + # get the profile from grid_profiles + input_profile = grid_profiles.profiles.get(profile_name) + + if input_profile is None: + raise gridsim.GridSimError('Profile Not Found: %s' % profile_name) + else: + for entry in input_profile: + raw_profile.append(profile_entry(t=entry[0], + v=entry[1], + f=entry[2], + ph=123)) + + if raw_profile[0].t == 0: + first_dt = dt_min + slew_rate_limited = True + else: + first_dt = raw_profile[0].t + slew_rate_limited = False + + profile.append(profile_entry(t=first_dt, # at least dt_min as rise time + v=(raw_profile[0].v/100.0)*v_nom, + f=(raw_profile[0].f/100.0)*freq_nom, + ph=123)) + + # TODO: possible bug: more than once a slew rate limitation --> time of sync for slew rate + # possible solution: instead a bool-value, use a float for 'slew rate time offsync' that counts up and down + for i in range(1, len(raw_profile)): + dt = raw_profile[i].t - raw_profile[i-1].t + if dt < self.dt_min: + dt = self.dt_min + slew_rate_limited = True + else: + if slew_rate_limited: # limited slew rate the last change, so reduce the current duration by dt_min + dt -= self.dt_min + slew_rate_limited = False + else: + pass + + profile.append(profile_entry(t=dt, + v=(raw_profile[i].v/100.0)*v_nom, + f=(raw_profile[i].f/100.0)*freq_nom, + ph=123)) + + self.profile = profile + + @staticmethod + def _numeric_equal(x, y, eps): + return abs(x-y) < eps + + def profile_start(self): + """ + Start the loaded profile. + """ + if self.profile is not None: + self.ts.log('Starting profile: %s' % self.profile_name) + prev_v = self.voltage()[0] + prev_f = self.freq() + + for entry in self.profile: + if not self._numeric_equal(prev_v, entry.v, self.eps): + if not self._numeric_equal(prev_f, entry.f, self.eps): + # change in voltage and frequency + self.ts.log('\tChange voltage from %0.1fV to %0.1fV and frequency from %0.1fHz to %0.1fHz in %0.2fs' + % (prev_v, entry.v, prev_f, entry.f, entry.t)) + self.amplitude_frequency_ramp(amplitude_end_value=entry.v, end_frequency=entry.f, + ramp_time=entry.t, phases=entry.ph, + amplitude_start_value=prev_v, start_frequency=prev_f) + else: + # change in voltage + self.ts.log('\tChange voltage from %0.1fV to %0.1fV in %0.2fs' % (prev_v, entry.v, entry.t)) + self.amplitude_ramp(end_value=entry.v, ramp_time=entry.t, phases=entry.ph, start_value=prev_v) + + elif not self._numeric_equal(prev_f, entry.f, self.eps): + # change in frequency + self.ts.log('\tChange frequency from %0.1fHz to %0.1fHz in %0.2fs' % (prev_f, entry.f, entry.t)) + self.frequency_ramp(end_frequency=entry.f, ramp_time=entry.t, start_frequency=prev_f) + + else: + # wait, because no change in voltage or frequency + self.ts.log('\tWait %0.2fs' % entry.t) + self.ts.sleep(entry.t) + + prev_v = entry.v + prev_f = entry.f + + self.ts.log('Finished profile') + else: + raise gridsim.GridSimError('You have to load a profile before starting it') + + def profile_stop(self): + """ + Stop the running profile. + """ + self.stop_command() + # TODO: this will NOT stop the profile, but only the current ramp. + # Also, at the moment the profile_start function will be executed until the profile is done + + def regen(self, state=None): + """ + Set the state of the regen mode if provided. Valid states are: REGEN_ON, + REGEN_OFF. If none is provided, obtains the state of the regen mode. + :param state: + :return: + """ + if state == gridsim.REGEN_ON: + # do nothing, because regen mode is always on + pass + elif state == gridsim.REGEN_OFF: + raise gridsim.GridSimError('Cannot disable the regen mode. It is always ON for the SPS gridsim') + elif state is None: + state = gridsim.REGEN_ON # Regeneration is always on for SPS + else: + raise gridsim.GridSimError('Unknown regen state: %s', state) + + return state + + def relay(self, state=None): + """ + Set the state of the relay if provided. If none is provided, obtains the state of the relay. + + :param state: valid states are: RELAY_OPEN, RELAY_CLOSED + """ + + if state is not None: + if state == gridsim.RELAY_OPEN: + self._write('AMP:Output 0') + self.ts.log('Opened Relay') + elif state == gridsim.RELAY_CLOSED: + self._write('AMP:Output 1') + self.ts.log('Closed Relay') + else: + raise gridsim.GridSimError('Invalid relay state: %s' % state) + else: + state = int(self._query('AMP:Output?')) + if state == 0: + state = gridsim.RELAY_OPEN + elif state == 1: + state = gridsim.RELAY_CLOSED + else: + state = gridsim.RELAY_UNKNOWN + return state + + def voltage(self, voltage=None): + """ + Set the value for voltage phase 1 to 3 if provided. If none provided, obtains + the set value for voltage. Voltage is a tuple containing a voltage value for + each phase. + + :param voltage: Voltages in Volt as float + """ + + if voltage is not None: + v = self._create_3tuple(voltage) + + # use ramp instead of setting voltages directly to limit slew rate + if v[0] == v[1] == v[2]: + # one ramp for all + self.amplitude_ramp(v[0], self.dt_min, 123) + else: + # three consecutive ramps for each phase + self.amplitude_ramp(v[0], self.dt_min, 1) + self.amplitude_ramp(v[1], self.dt_min, 2) + self.amplitude_ramp(v[2], self.dt_min, 3) + else: + # as discussed, return here the set value and not the measured voltage + v = [self._get_voltage_set_value(1), + self._get_voltage_set_value(2), + self._get_voltage_set_value(3)] + + return v + + def voltage_max(self, voltage=None): + """ + Set the value for max voltage if provided. If none provided, obtains + the value for max voltage. + :param voltage: + :return: + """ + + if voltage is not None: + # if voltage is a list or tuple, only take one value + if type(voltage) is list or type(voltage) is tuple: + voltage = float(max(voltage)) + + if voltage <= 0: + raise gridsim.GridSimError('Maximum Voltage must be greater than 0V') + + # get range values + range_values = str(self._query('conf:amp:range?')).split(',') + + for i, rg in enumerate(range_values): + self.ts.log_debug('Range is "%s"' % rg) + self.ts.log_debug('rg[:-1] produces "%s"' % rg[:-1]) + self.ts.log_debug('rg[:-2] produces "%s"' % rg[:-2]) + value = float(rg[:-1]) + if voltage == value: + self._write('amp:range %i' % i) + return self._create_3tuple(voltage) + + # if code reaches this, the set value is not within the supported ranges + raise gridsim.GridSimError( + 'Invalid maximum voltage. SPS does not support %sV as maximum Voltage (Range)' % str(voltage)) + else: + # return 270 + + # get range + act_range = int(self._query('amp:range?')) + # get range values + range_values = str(self._query('conf:amp:range?')).split(',') + + return self._create_3tuple(float(range_values[act_range - 1][:-1])) + + def i_max(self): + return self.i_max_param + + def v_max(self): + return self.v_max_param + + def v_nom(self): + return self.v_nom_param + + def stop_command(self): + """ + Stops the current command. Used to stop an amplitude pulse + :return: None + """ + + self._write('BREAK') + + def setup_amplitude_pulse(self, start_value, pulse_value, end_value, + rise_time, duration, fall_time): + """ + Times in seconds, 0-3600 + + :param start_value: + :param pulse_value: + :param end_value: + :param rise_time: + :param duration: + :param fall_time: + :return: + """ + + # Amplitude values + self._write('OSC:APuls:START %0.3fV' % start_value) + self._write('OSC:APuls:PULS %0.3fV' % pulse_value) + self._write('OSC:APuls:END %0.3fV' % end_value) + + # Times + self._write('OSC:APuls:RISET %.3f' % rise_time) + self._write('OSC:APuls:DURAT %.3f' % duration) + self._write('OSC:APuls:FALLT %.3f' % fall_time) + + def start_amplitude_pulse(self, phases): + """ + + :param phases: string or int, 1,2,3,12,23,13, 123 + :return: + """ + phases = self._phases2int(phases) + self._write('OSC:APULS:GO %i' % phases) + + def start_amplitude_frequency_pulse(self, phases): + """ + + :param phases: string or int, 1,2,3,12,23,13, 123 + :return: + """ + phases = self._phases2int(phases) + self._write('OSC:AFPULS:GO %i' % phases) + + def amplitude_frequency_ramp(self, amplitude_end_value, end_frequency, ramp_time, phases, + amplitude_start_value=None, start_frequency=None): + + self.amplitude_ramp(end_value=amplitude_end_value, ramp_time=ramp_time, phases=phases, + start_value=amplitude_start_value, start_ramp=False) + self.frequency_ramp(end_frequency=end_frequency, ramp_time=ramp_time, + start_frequency=start_frequency, start_ramp=False) + + self.start_amplitude_frequency_pulse(phases) + self.ts.sleep(max(0, ramp_time - 4 * self.execution_time)) + + def amplitude_ramp(self, end_value, ramp_time, phases, start_value=None, start_ramp=True): + """ + + :param end_value: + :param ramp_time: + :param phases: + :param start_value: + :param start_ramp: + :return: + """ + + """ equlas amplitude pulse with + - start_value <-> current_value + - pulse_value <-> end_value + - end_value <-> end_value + - rise_time <-> rise_time + - duration <-> 0 + - fall_time <-> 0 + """ + + phases = self._phases2int(phases) + + if start_value is None: + if phases in (1, 2, 3): + start_value = self._get_voltage_set_value(phases) + elif phases in (12, 13, 123): + # TODO: check if the phases have the same set value, if not, raise exception + # by now, use value of phase 1 or 2 + start_value = self._get_voltage_set_value(1) + elif phases == 23: + start_value = self._get_voltage_set_value(2) + else: + raise ValueError('Invalid argument for phases: %i' % phases) + + self.setup_amplitude_pulse(start_value, end_value, end_value, ramp_time, 0, 0) + + if start_ramp: # only start if start_ramp == True; False is needed for the AFPULS + self.start_amplitude_pulse(phases) + self.ts.sleep(max(0, ramp_time - 2 * self.execution_time)) + # TODO: improve timing accuracy by finding out the execution time + + def setup_frequency_pulse(self, start_frequency, pulse_frequency, end_frequency, + rise_time, duration, fall_time): + """ + Times in seconds, 0-3600, Frequency in Hertz + + :param start_frequency: + :param pulse_frequency: + :param end_frequency: + :param rise_time: + :param duration: + :param fall_time: + :return: + """ + + # Frequency values + self._write('OSC:FPuls:START %0.3f' % start_frequency) + self._write('OSC:FPuls:PULS %0.3f' % pulse_frequency) + self._write('OSC:FPuls:END %0.3f' % end_frequency) + + # Times + self._write('OSC:FPuls:RISET %.3f' % rise_time) + self._write('OSC:FPuls:DURAT %.3f' % duration) + self._write('OSC:FPuls:FALLT %.3f' % fall_time) + + def start_frequency_pulse(self): + """ + + :return: + """ + self._write('OSC:FPULS:GO') + + def frequency_ramp(self, end_frequency, ramp_time, start_frequency=None, start_ramp=True): + if start_frequency is None: + start_frequency = self.freq() + + self.setup_frequency_pulse(start_frequency, end_frequency, end_frequency, ramp_time, 0, 0) + + if start_ramp: # only start if start_ramp == True; False is needed for the AFPULS + self.start_frequency_pulse() + self.ts.sleep(max(0, ramp_time - 2*self.execution_time)) + + def _query(self, cmd_str): + """ + Performs a SCPI query with the given cmd_str and returns the reply of the device + :param cmd_str: the SCPI command which must be a valid command + :return: the answer from the SPS + """ + + try: + if self.conn is None: + raise gridsim.GridSimError('GPIB connection not open') + + return self.conn.query(cmd_str).rstrip("\n\r") + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def _write(self, cmd_str): + """ + Performs a SCPI write command with the given cmd_str + :param cmd_str: the SCPI command which must be a valid command + """ + try: + if self.conn is None: + raise gridsim.GridSimError('GPIB connection not open') + + num_written_bytes = self.conn.write(cmd_str) + # TODO: check num_written_bytes to see if writing succeeded + + return num_written_bytes + except Exception as e: + raise gridsim.GridSimError(str(e)) + + @staticmethod + def _create_3tuple(value): + """ + Checks whether value is a + :param value: + :return: + """ + + try: # value is an array + if len(value) == 1: + return [value[0], value[0], value[0]] + elif len(value) == 3: + return [value[0], value[1], value[2]] + else: + raise ValueError('Value must be length 1 or 3') + except (IndexError, TypeError): # value is a scalar + return [value, value, value] + + @staticmethod + def _phases2int(phases): + if isinstance(phases, (str, float)): + try: + phases = int(phases) # "12" --> 12 + + except ValueError: + raise ValueError('String %s for phases has to represent a valid int' % phases) + return phases + + def _measure_value(self, phase, what): + """ + Returns a measurement value from the SPS + :param phase: which phase, from 1 to 3 + :param what: which entity according to SPS manual. Currently supported: 'VOLT', 'CURR', 'S' + :return: the measured value as float + """ + + phase = int(float(phase)) # convert if string '1' or 1.0 instead of int 1 + if phase < 1 or phase > 3: + raise ValueError('Phase must be between 1 and 3') + else: + suffix = {'VOLT': -2, 'CURR': -2, 'S': -3} + if what in list(suffix.keys()): + self._write('CONF:MEAS:PH %i' % phase) + value = self._query('MEAS:' + what + '?') + # query returns the unit + '\n' which has to be removed before converting to float + return float(value[:suffix[what]]) + else: + raise ValueError('A query for the measurement of ' + what + ' is not possible or not implemented yet') + + def _measure_current(self, phase): + """ + Measures the current of the given phase + :param phase: which phase, from 1 to 3 + :return: the current in A as float + """ + return self._measure_value(phase, 'CURR') + + def _measure_apparent_power(self, phase): + """ + Measures the apparent power of the given phase + :param phase: which phase, int from 1 to 3 + :return: the apparent power in VA as float + """ + return self._measure_value(phase, 'S') + + def _measure_voltage(self, phase): + """ + Measures the voltage of the given phase + :param phase: which phase, int from 1 to 3 + :return: the voltage in V as float + """ + return self._measure_value(phase, 'VOLT') + + def _get_voltage_set_value(self, phase): + """ + + :param phase: + :return: + """ + return float(self._query('OSC:AMP %i?' % phase)) + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/gridsim_sunrex.py b/Lib/svpelab/gridsim_sunrex.py new file mode 100644 index 0000000..d38daef --- /dev/null +++ b/Lib/svpelab/gridsim_sunrex.py @@ -0,0 +1,200 @@ + +import os +import time +import serial +import socket +from . import gridsim +from . import grid_profiles + +sunrex_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Sunrex' +} + +def gridsim_info(): + return sunrex_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sunrex_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('phases'), label='Phases', default=1, values=[1, 2, 3]) + info.param(pname('comm'), label='Communications Interface', default='TCP/IP', values=['Serial', 'TCP/IP']) + info.param(pname('ip_addr'), label='IP Address', + active=pname('comm'), active_value=['TCP/IP'], default='192.168.0.171') + info.param(pname('ip_port'), label='IP Port', + active=pname('comm'), active_value=['TCP/IP'], default=1234) + +GROUP_NAME = 'sunrex' + +class GridSim(gridsim.GridSim): + """ + Sunrex grid simulation implementation. + + Valid parameters: + mode - 'SunrexGrd' + auto_config - ['Enabled', 'Disabled'] + v_nom + v_max + i_max + freq + profile_name + baudrate + timeout + write_timeout + ip_addr + ip_port + """ + + def __init__(self, ts, group_name, support_interfaces=None): + gridsim.GridSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) + self.buffer_size = 1024 + self.conn = None + + self.phases_param = ts.param_value('gridsim.sunrex.phases') + self.auto_config = ts.param_value('gridsim.auto_config') + self.freq_param = ts.param_value('gridsim.sunrex.freq') + self.comm = ts.param_value('gridsim.sunrex.comm') + self.ipaddr = ts.param_value('gridsim.sunrex.ip_addr') + self.ipport = ts.param_value('gridsim.sunrex.ip_port') + self.relay_state = gridsim.RELAY_OPEN + self.regen_state = gridsim.REGEN_OFF + self.timeout = 100 + self.cmd_str = '' + self._cmd = None + self._query = None + self.profile_name = ts.param_value('profile.profile_name') + + if self.comm == 'TCP/IP': + self._cmd = self.cmd_tcp + self._query = self.query_tcp + if self.auto_config == 'Enabled': + ts.log('Configuring the Grid Simulator.') + # self.config() + + state = self.relay() + if state != gridsim.RELAY_CLOSED: + if self.ts.confirm('Would you like to close the grid simulator relay and ENERGIZE the system?') is False: + raise gridsim.GridSimError('Aborted grid simulation') + else: + self.ts.log('Turning on grid simulator.') + self.relay(state=gridsim.RELAY_CLOSED) + + def cmd_tcp(self, cmd_str): + try: + if self.conn is None: + self.ts.log('ipaddr = %s ipport = %s' % (self.ipaddr, self.ipport)) + self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn.settimeout(self.timeout) + self.conn.connect((self.ipaddr, self.ipport)) + + # print 'cmd> %s' % (cmd_str) + self.conn.send(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query_tcp(self, cmd_str): + resp = '' + more_data = True + + self._cmd(cmd_str) + + while more_data: + try: + data = self.conn.recv(self.buffer_size) + if len(data) > 0: + for d in data: + resp += d + if d == '\n': #\r + more_data = False + break + except Exception as e: + raise gridsim.GridSimError('Timeout waiting for response') + + return resp + + def cmd(self, cmd_str): + self.cmd_str = cmd_str + try: + self._cmd(cmd_str) + except Exception as e: + raise gridsim.GridSimError(str(e)) + + def query(self, cmd_str): + try: + resp = self._query(cmd_str).strip() + except Exception as e: + raise gridsim.GridSimError(str(e)) + return resp + + def freq(self, freq=None): + """ + Set the value for frequency if provided. If none provided, obtains + the value for frequency. + """ + if freq is not None: + self.cmd(':AC:SETB:FREQ %0.2f\n' % freq) + self.freq_param = freq + + return freq + + def relay(self, state=None): + """ + Set the state of the relay if provided. Valid states are: RELAY_OPEN, + RELAY_CLOSED. If none is provided, obtains the state of the relay. + """ + if state is not None: + if state == gridsim.RELAY_OPEN: + self.cmd('abort;:outp off\n') + elif state == gridsim.RELAY_CLOSED: + self.cmd('abort;:outp on\n') + else: + raise gridsim.GridSimError('Invalid relay state. State = "%s"', state) + else: + relay = self.query(':AC:STAT:READ?\n').strip() + #self.ts.log(relay) + #if relay == '0': + if relay == ':AC:STAT:READ 0': + state = gridsim.RELAY_OPEN + #elif relay == '1': + elif relay == ':AC:STAT:READ 1': + state = gridsim.RELAY_CLOSED + + else: + state = gridsim.RELAY_UNKNOWN + return state + + def cmd_run(self): + relay = self.query(':AC:STAT:READ?\n').strip() + if relay == ':AC:STAT:READ 1': + self.cmd(':AC:CONT:RUN 1') + + def cmd_stop(self): + self.cmd(':AC:CONT:RUN 0') + + def voltage(self, voltage=None): + """ + Set the value for voltage 1, 2, 3 if provided. If none provided, obtains + the value for voltage. Voltage is a tuple containing a voltage value for + each phase. + """ + if voltage is not None: + # set output voltage on all phases + # self.ts.log_debug('voltage: %s, type: %s' % (voltage, type(voltage))) + if type(voltage) is not list and type(voltage) is not tuple: + self.cmd(':AC:SETB:VOLT PERC,%0.1f,%0.1f,%0.1f\n' % (voltage, voltage, voltage)) + v1 = voltage + v2 = voltage + v3 = voltage + else: + self.cmd(':AC:SETB:VOLT PERC,%0.1f,%0.1f,%0.1f\n' % (voltage[0], voltage[0], voltage[0])) # use the first value in the 3 phase list + v1 = voltage[0] + v2 = voltage[0] + v3 = voltage[0] + + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/Lib/svpelab/gridsim_typhoon.py b/Lib/svpelab/gridsim_typhoon.py index b3075de..b488110 100644 --- a/Lib/svpelab/gridsim_typhoon.py +++ b/Lib/svpelab/gridsim_typhoon.py @@ -31,12 +31,12 @@ """ import os -import gridsim +from . import gridsim try: - import typhoon.api.hil_control_panel as cp -except Exception, e: - print('Typhoon HIL API not installed. %s' % e) + import typhoon.api.hil as cp # control panel +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) typhoon_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -57,6 +57,9 @@ def params(info, group_name): info.param(pname('f_nom'), label='EUT nominal frequency', default=50.) info.param(pname('p_nom'), label='EUT nominal power (W)', default=34500.) + info.param(pname('waveform_names'), label='Waveform Names', + default="V_source_phase_A, V_source_phase_B, V_source_phase_C") + GROUP_NAME = 'typhoon' @@ -80,9 +83,24 @@ def __init__(self, ts, group_name): self.p_nom = self._param_value('p_nom') self.v_nom = self._param_value('v_nom') self.v = self.v_nom + + # for asymettric voltage tests + self.v1 = self.v_nom + self.v2 = self.v_nom + self.v3 = self.v_nom + self.f_nom = self._param_value('f_nom') self.f = self.f_nom + try: + tempstring = self._param_value('waveform_names').strip().split(',') + self.waveform_source_list = [i .rstrip(' ').lstrip(' ')for i in tempstring] + except Exception as e: + ts.log("Failed waveform_names: %s" % e) + raise e + + self.ts.log_debug('Grid Sources: %s.' % self.waveform_source_list) + if self.auto_config == 'Enabled': ts.log('Configuring the Typhoon HIL Emulated Grid Simulator.') self.config() @@ -101,9 +119,18 @@ def config(self): def config_phase_angles(self): # set the phase angles for the 3 phases - cp.set_source_sine_waveform('V_source_phase_A', phase=0.0) - cp.set_source_sine_waveform('V_source_phase_B', phase=-120.0) - cp.set_source_sine_waveform('V_source_phase_C', phase=120.0) + if len(self.waveform_source_list) == 1: # single phase + cp.set_source_sine_waveform(self.waveform_source_list[0], phase=0.0) + elif len(self.waveform_source_list) == 2: # split phase + cp.set_source_sine_waveform(self.waveform_source_list[0], phase=0.0) + cp.set_source_sine_waveform(self.waveform_source_list[1], phase=180.0) + elif len(self.waveform_source_list) == 3: # three phase + cp.set_source_sine_waveform(self.waveform_source_list[0], phase=0.0) + cp.set_source_sine_waveform(self.waveform_source_list[1], phase=-120.0) + cp.set_source_sine_waveform(self.waveform_source_list[2], phase=120.0) + else: + self.ts.log_warning('Phase angles not set for simulation because the number of grid simulation ' + 'waveforms is not 1, 2, or 3.') def current(self, current=None): """ @@ -126,14 +153,17 @@ def freq(self, freq=None): """ if freq is not None: self.f = freq - cp.prepare_source_sine_waveform('V_source_phase_A', frequency=self.f) - cp.prepare_source_sine_waveform('V_source_phase_B', frequency=self.f) - cp.prepare_source_sine_waveform('V_source_phase_C', frequency=self.f) - cp.update_sources(["V_source_phase_A", "V_source_phase_B", "V_source_phase_C"], executeAt=None) + for wave in self.waveform_source_list: + cp.prepare_source_sine_waveform(wave, frequency=self.f) + cp.update_sources(self.waveform_source_list, executeAt=None) + + # For setting sine source in Anti-islanding component you can use: + # cp.set_source_sine_waveform('Anti-islanding1.Vgrid', rms=50.0, frequency=50.0) + freq = self.f return freq - def profile_load(self, profile_name, v_step=100, f_step=100, t_step=None): + def profile_load(self, profile_name=None, v_step=100, f_step=100, t_step=None, profile=None): pass # if profile_name is None: # raise gridsim.GridSimError('Profile not specified.') @@ -187,27 +217,43 @@ def voltage(self, voltage=None): """ if voltage is not None: # set output voltage on all phases + # self.ts.log_debug('waveforms: %s' % self.waveform_source_list) if type(voltage) is not list and type(voltage) is not tuple: self.v = voltage - self.ts.log_debug(' Setting Typhoon AC voltage to %s' % self.v) - cp.prepare_source_sine_waveform('V_source_phase_A', rms=self.v) - cp.prepare_source_sine_waveform('V_source_phase_B', rms=self.v) - cp.prepare_source_sine_waveform('V_source_phase_C', rms=self.v) - cp.update_sources(["V_source_phase_A", "V_source_phase_B", "V_source_phase_C"], executeAt=None) + # self.ts.log_debug(' Setting Typhoon AC voltage to %s' % self.v) + for wave in self.waveform_source_list: + # self.ts.log_debug('Source: %s set to %s V.' % (wave, self.v)) + cp.prepare_source_sine_waveform(name=wave, rms=self.v) + # cp.update_sources(self.waveform_source_list, executeAt=None) + cp.update_sources(self.waveform_source_list, executeAt=None) # cp.wait_msec(100.0) + self.v1 = self.v + self.v2 = self.v + self.v3 = self.v else: - self.v = voltage[0] # currently don't support asymmetric voltages. - cp.prepare_source_sine_waveform('V_source_phase_A', rms=self.v) - cp.prepare_source_sine_waveform('V_source_phase_B', rms=self.v) - cp.prepare_source_sine_waveform('V_source_phase_C', rms=self.v) - cp.update_sources(["V_source_phase_A", "V_source_phase_B", "V_source_phase_C"], executeAt=None) + # self.ts.log('Creating asymmetric voltage condition with voltages: %s, %s, %s' % + # (voltage[0], voltage[1], voltage[2])) + phase = 0 + for wave in self.waveform_source_list: + phase += 1 + self.v = voltage[phase-1] + cp.prepare_source_sine_waveform(name=wave, rms=voltage[phase-1]) + if phase == 1: + v1 = voltage[phase-1] + if phase == 2: + v2 = voltage[phase-1] + if phase == 3: + v3 = voltage[phase-1] + + cp.update_sources(self.waveform_source_list, executeAt=None) # cp.wait_msec(100.0) + self.v = (v1 + v2 + v3)/3 + self.v1 = v1 + self.v2 = v2 + self.v3 = v3 - v1 = self.v - v2 = self.v - v3 = self.v - return v1, v2, v3 + return self.v1, self.v2, self.v3 def voltage_max(self, voltage=None): """ @@ -219,6 +265,36 @@ def voltage_max(self, voltage=None): pass return self.v, self.v, self.v + def config_asymmetric_phase_angles(self, mag=None, angle=None): + """ + :param mag: list of voltages for the imbalanced test, e.g., [277.2, 277.2, 277.2] + :param angle: list of phase angles for the imbalanced test, e.g., [0, 120, -120] + + :returns: voltage list and phase list + """ + if mag is not None: + if type(mag) is not list: + raise gridsim.GridSimError('Waveform magnitudes were not provided as list. "mag" type: %s' % type(mag)) + + if angle is not None: + if type(angle) is list: + cp.set_source_sine_waveform(self.waveform_source_list[0], rms=mag[0], phase=angle[0]) + cp.set_source_sine_waveform(self.waveform_source_list[1], rms=mag[1], phase=angle[1]) + cp.set_source_sine_waveform(self.waveform_source_list[2], rms=mag[2], phase=angle[2]) + cp.update_sources(self.waveform_source_list, executeAt=None) + # cp.wait_msec(100.0) + self.v = (v1 + v2 + v3)/3 + self.v1 = v1 + self.v2 = v2 + self.v3 = v3 + + else: + raise gridsim.GridSimError('Waveform angles were not provided as list.') + + voltages = [self.v1, self.v2, self.v3] + phases = angle + return voltages, phases + def i_max(self): return self.v/self.p_nom @@ -228,5 +304,77 @@ def v_max(self): def v_nom(self): return self.v_nom + def meas_voltage(self, ph_list=(1, 2, 3)): + v1 = float(cp.read_analog_signal(name='V( Vrms1 )')) + v2 = float(cp.read_analog_signal(name='V( Vrms2 )')) + v3 = float(cp.read_analog_signal(name='V( Vrms3 )')) + return v1, v2, v3 + + def meas_current(self, ph_list=(1, 2, 3)): + # for use during anti-islanding testing to determine the current to the utility + try: + i1 = float(cp.read_analog_signal(name='I( Anti-islanding1.Irms1_utility )')) + i2 = float(cp.read_analog_signal(name='I( Anti-islanding1.Irms2_utility )')) + i3 = float(cp.read_analog_signal(name='I( Anti-islanding1.Irms3_utility )')) + print(('Utility currents are %s, %s, %s' % (i1, i2, i3))) + except Exception as e: + i1 = i2 = i3 = None + return i1, i2, i3 + if __name__ == "__main__": - pass + import sys + import time + import numpy as np + import math + sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable/Lib/site-packages') + sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable') + sys.path.insert(0, r'C:/Typhoon HIL Control Center') + import typhoon.api.hil_control_panel as cp + from typhoon.api.schematic_editor import model + import os + + cp.set_debug_level(level=1) + cp.stop_simulation() + + model.get_hw_settings() + if not model.load(r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib/svpelab/Typhoon/ASGC.tse'): + print("Model did not load!") + + if not model.compile(): + print("Model did not compile!") + + # first we need to load model + cp.load_model(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib' + r'/svpelab/Typhoon/ASGC Target files/ASGC.cpd') + + # we could also open existing settings file... + cp.load_settings_file(file=r'D:/SVP/SVP 1.4.3 Directories 5-2-17/svp_energy_lab-loadsim/Lib/' + r'svpelab/Typhoon/settings2.runx') + + # after setting parameter we could start simulation + cp.start_simulation() + + # let the inverter startup + sleeptime = 15 + for i in range(1, sleeptime): + print(("Waiting another %d seconds until the inverter starts. Power = %f." % + ((sleeptime-i), cp.read_analog_signal(name='Pdc')))) + time.sleep(1) + + print(('Sources: %s' % cp.available_sources())) + + waveform_source_list = ['V_source_phase_A', 'V_source_phase_B', 'V_source_phase_C'] + + for voltage in range(210, 250, 1): + for wave in waveform_source_list: + cp.prepare_source_sine_waveform(name=wave, rms=voltage) + cp.update_sources(waveform_source_list, executeAt=None) + + time.sleep(2) + + v1 = float(cp.read_analog_signal(name='V( Vrms1 )')) + v2 = float(cp.read_analog_signal(name='V( Vrms2 )')) + v3 = float(cp.read_analog_signal(name='V( Vrms3 )')) + + # print('Voltage Target: %s, Voltages: %s' % (voltage, [v1, v2, v3])) + print(('%s, %s, %s, %s, %s' % (voltage, voltage, v1, v2, v3))) diff --git a/Lib/svpelab/hil.py b/Lib/svpelab/hil.py index 54041ce..5338acc 100644 --- a/Lib/svpelab/hil.py +++ b/Lib/svpelab/hil.py @@ -34,6 +34,26 @@ import glob import importlib + +class HILGenericException(Exception): + pass + + +class HILCompileException(Exception): + pass + + +class HILModelException(Exception): + pass + + +class HILRuntimeException(Exception): + pass + + +class HILSimulationException(Exception): + pass + # Import all hardware-in-the-loop extensions in current directory. # A hil extension has a file name of hil_*.py and contains a function hil_params(info) that contains # a dict with the following entries: name, init_func. @@ -42,14 +62,23 @@ hil_modules = {} -def params(info): - info.param_group('hil', label='HIL Parameters', glob=True) - info.param('hil.mode', label='HIL Environment', default='Disabled', values=['Disabled']) - for mode, m in hil_modules.iteritems(): - m.params(info) +def params(info, id=None, label='HIL', group_name=None, active=None, active_value=None): + if group_name is None: + group_name = HIL_DEFAULT_ID + else: + group_name += '.' + HIL_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + name = lambda name: group_name + '.' + name + info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) + info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) + for mode, m in hil_modules.items(): + + m.params(info, group_name=group_name) +HIL_DEFAULT_ID = 'hil' -def hil_init(ts): +def hil_init(ts, id=None, group_name=None): """ Function to create specific HIL implementation instances. @@ -57,14 +86,22 @@ def hil_init(ts): Module import for the simulator is done within the conditional so modules only need to be present if used. """ - mode = ts.param_value('hil.mode') + if group_name is None: + group_name = HIL_DEFAULT_ID + else: + group_name += '.' + HIL_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + mode = ts.param_value(group_name + '.' + 'mode') + # ts.log_debug('group_name, %s, mode: %s' % (group_name, mode)) sim = None if mode != 'Disabled': - hil_module = hil_modules.get(mode) - if hil_module is not None: - sim = hil_module.HIL(ts) + sim_module = hil_modules.get(mode) + if sim_module is not None: + + sim = sim_module.HIL(ts, group_name) else: - raise HILError('Unknown grid simulation mode: %s' % mode) + raise HILError('Unknown HIL mode: %s' % mode) return sim @@ -82,8 +119,9 @@ class HIL(object): independent HIL classes can be created containing the methods contained in this class. """ - def __init__(self, ts, params=None): + def __init__(self, ts, group_name): self.ts = ts + self.group_name = group_name self.params = params if self.params is None: @@ -160,10 +198,11 @@ def hil_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - raise HILError('Error scanning module %s: %s' % (module_name, str(e))) + print(HILError('Error scanning module %s: %s' % (module_name, str(e)))) + # scan for hil modules on import hil_scan() diff --git a/Lib/svpelab/hil_opal.py b/Lib/svpelab/hil_opal.py new file mode 100644 index 0000000..661880f --- /dev/null +++ b/Lib/svpelab/hil_opal.py @@ -0,0 +1,1256 @@ +""" +Copyright (c) 2020, CanmetENERGY, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" +import os +try: + import hil +except ImportError as e: + print("Could not import hil") + from . import hil + +import sys +from time import sleep +# import glob +# import numpy as np + +# Dictionary used to create a mapping between a realTimeModeString and a realTimeId +realTimeModeList = {'Hardware Synchronized': 0, + 'Simulation': 1, + 'Software Synchronized': 2, + 'Simulation with no data loss': 3, + 'Simulation with low priority': 4} + +opalrt_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Opal-RT' +} + + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = opalrt_info['mode'] + info.param_add_value('hil.mode', opalrt_info['mode']) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), + active_value=mode, glob=True) + + info.param(pname('rt_lab_version'), label='RT-LAB Version', default="2020.4") + info.param(pname('target_name'), label='Target name in RT-LAB', default="RTServer") + info.param(pname('project_dir_path'), label='Project Location (Full Path to LLP File)', + default='C:\\Users\\DETLDAQ\\OPAL-RT\\RT-LABv2020.4_Workspace\\IEEE_1547.1_Phase_Jump\\' + 'IEEE_1547.1_Phase_Jump.llp') + info.param(pname('rt_lab_model'), label='RT-LAB Project name or file name (.mdl, .llp, or .slx)', + default='IEEE_1547.1_Phase_Jump.llp') + info.param(pname('rt_mode'), label='Real-Time simulation mode', default='Hardware', values=["Software", "Hardware"]) + info.param(pname('workspace_path'), label='Workspace Path (Unused if full path used in Project Directory Location)', + default='C:\\Users\\DETLDAQ\\OPAL-RT\\RT-LABv2019.1_Workspace') + + info.param(pname('hil_config'), label='Configure HIL in init', default='False', values=['True', 'False']) + # info.param(pname('hil_config_open'), label='Open Project?', default="Yes", values=["Yes", "No"], + # active=pname('hil_config'), active_value='True') + info.param(pname('hil_config_compile'), label='Compilation needed?', default="No", values=["Yes", "No"], + active=pname('hil_config'), active_value='True') + info.param(pname('hil_config_stop_sim'), label='Stop the simulation before loading/execution?', + default="Yes", values=["Yes", "No"], active=pname('hil_config'), active_value='True') + info.param(pname('hil_config_load'), label='Load the model to target?', default="Yes", values=["Yes", "No"], + active=pname('hil_config'), active_value='True') + info.param(pname('hil_config_execute'), label='Execute the model on target?', default="Yes", values=["Yes", "No"], + active=pname('hil_config'), active_value='True') + info.param(pname('hil_stop_time'), label='Stop Time', default=3600.) + + +GROUP_NAME = 'opal' + + +def hil_info(): + return opalrt_info + + +class HIL(hil.HIL): + """ + Opal_RT HIL implementation - The default. + """ + def __init__(self, ts, group_name): + hil.HIL.__init__(self, ts, group_name) + rt_version = self._param_value('rt_lab_version') + + # Latent import based on the version number + import_path = "C://OPAL-RT//RT-LAB//%s//common//python" % rt_version + try: + sys.path.insert(0, import_path) + import RtlabApi + import OpalApiPy + self.RtlabApi = RtlabApi + self.OpalApiPy = OpalApiPy + ts.log_debug('RtlabApi Imported. Using %s' % import_path) + except ImportError as e: + ts.log_error('RtlabApi Import Error. Check the version number. Using path = %s' % import_path) + print(e) + + # Add REGEX or parser manipulation to get a good control over user entries. + self.project_dir_path = self._param_value('project_dir_path') + self.workspace_path = self._param_value('workspace_path') + self.target_name = self._param_value('target_name') + self.rt_mode = self._param_value('rt_mode') + + self.rt_lab_model = self._param_value('rt_lab_model') + self.rt_lab_model_dir = self.project_dir_path + self.ts = ts + self.time_sig_path = None + + self.hil_config_open = self._param_value('hil_config_open') + self.hil_config_compile = self._param_value('hil_config_compile') + self.hil_config_stop_sim = self._param_value('hil_config_stop_sim') + self.hil_config_load = self._param_value('hil_config_load') + self.hil_config_execute = self._param_value('hil_config_execute') + self.hil_stop_time = self._param_value('hil_stop_time') + + if self._param_value('hil_config') == 'True': + self.config() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def hil_info(self): + return opalrt_info + + def config(self): + """ + Perform any configuration for the simulation based on the previously + provided parameters. + """ + self.ts.log("{}".format(self.info())) + self.open() + if self.hil_config_compile == 'Yes': + self.ts.sleep(1) + self.ts.log(" Model ID: {}".format(self.compile_model().get("modelId"))) + if self.hil_config_stop_sim == 'Yes': + self.ts.sleep(1) + self.ts.log(" {}".format(self.stop_simulation())) + + self.ts.log('Setting the simulation stop time for %0.1f to run experiment.' % self.hil_stop_time) + self.set_stop_time(self.hil_stop_time) + + if self.hil_config_load == 'Yes': + self.ts.sleep(1) + self.ts.log(" {}".format(self.load_model_on_hil())) + if self.hil_config_execute == 'Yes': + self.ts.log(" {}".format(self.start_simulation())) + + def command(self, ownerId=None, command=None, attributes=None, values=None): + """ + :param ownerId: - The ID of the object that owns the command. Where there is ambiguity, the owner of the two + objects is the expected ID. For example CMD_REMOVE: when the owner is a project, the + command removes a model. + :param command - The command to be executed (see OP_COMMAND). For each command, the requirements vary + depending on the owner ID supplied to OpalCommand. + :param attributes - The tuple of attributes to send as command arguments (see OP_ATTRIBUTE). The size of the + tuple must match the size of the attributeValues tuple. + :param values - The tuple of attribute values to send as command arguments. The size of the tuple must match + the size of the attributes tuple. + + :return: outputId - The ID corresponding to the object directly affected by the command. If no other object + than the parent is affected, the parent ID is returned. + + Examples: + + New Project + Owner ID class: OP_RTLAB_OBJ + Command : CMD_NEW + Description : Create a new project in the current RT-Lab session. If a project is already open it is closed. + Required control : None + Required attributes : ATT_FILENAME + Optional attributes : None + Output ID class: OP_PROJECT_OBJ + + Open Project + Owner ID class: OP_RTLAB_OBJ + Command : CMD_OPEN + Description : Open an existing project from file or connect to an active project. After this action the + project opened becomes the current project in the current RT-Lab session. If a project is open + beforehand it is closed. When connecting to a previously active project, control of this project may + also be requested. + Required control : None + Required attributes : None + Optional attributes : ATT_FILENAME, ATT_API_INSTANCE_ID, ATT_FUNCTIONAL_BLOCK, ATT_CONTROL_PRIOTRITY, + ATT_RETURN_ON_AMBIGUUITY + Output ID class: OP_PROJECT_OBJ + + + Add Default Environment Variable + Owner ID class : OP_RTLAB_OBJ + Command : CMD_ADD + Description : Add an environment variable to the default RT-LAB settings. This variable will NOT affect + the current project directly. + Required control : OP_FB_CONFIG + Required attributes : ATT_OBJECT_TYPE, ATT_NAME + Required attribute values : ATT_OBJECT_TYPE = OP_ENVIRONMENT_VARIABLE_OBJ + Optional attributes : ATT_VALUE + Output ID class: OP_ENVIRONMENT_VARIABLE_OBJ + + + Load Model Configuration + Name : CMD_OPEN + Description : Load an existing model's settings from a file. + Owner type : OP_TYPE_MODEL + Required control : OP_FB_SYSTEM + Required attributes : ATT_FILENAME + Optional attributes : None + Output ID class: Same as the value of ATT_REF_ID + """ + + return self.RtlabApi.Command(ownerId, command, attributes, values) + + def get_active_projects(self): + """ + Calls GetActiveProjects() to list the current projects + + :return: + """ + active_projects = self.RtlabApi.GetActiveProjects() + for proj in range(len(active_projects)): + self.ts.log_debug(active_projects[proj]) # *(str(),OP_INSTANCE_ID(),str(),str(),int(),tuple()) + pass + + def open(self): + """ + Open the communications resources associated with the HIL. + """ + try: + self.ts.log('Opening Project: %s' % self.rt_lab_model) + self.RtlabApi.OpenProject(self.rt_lab_model) + project_unopened = False + except Exception as e: + self.ts.log_warning('Unable to Open %s' % self.rt_lab_model) + project_unopened = True + + if project_unopened: + self.ts.log('Opening Project: PATH: %s, FILE: %s' % (self.project_dir_path, self.rt_lab_model)) + # Many possible combinations of paths and files below... + path_is_file = os.path.isfile(self.project_dir_path) + path_is_dir = os.path.isdir(self.project_dir_path) + pathfile = self.project_dir_path.rstrip('\\') + '\\' + self.rt_lab_model.split('.')[0] + '.llp' + pathfile_is_file = os.path.isfile(pathfile) + workspace_is_file = os.path.isdir(self.workspace_path) and \ + os.path.join(self.workspace_path, self.project_dir_path, + self.rt_lab_model.split('.')[0] + '.llp') + svpelab_dir = os.path.abspath(os.path.dirname(__file__)) + svp_path_is_file = os.path.isfile(svpelab_dir.rstrip('\\') + '\\' + self.project_dir_path) + svp_pathfile_is_file = os.path.isfile(svpelab_dir.rstrip('\\') + '\\' + self.project_dir_path.rstrip('\\') + + self.rt_lab_model.split('.')[0] + '.llp') + debug_rt_lab_file_name = False + if debug_rt_lab_file_name: + self.ts.log_debug('path_is_file = %s' % path_is_file) + self.ts.log_debug('path_is_dir = %s' % path_is_dir) + self.ts.log_debug('pathfile = %s' % pathfile) + self.ts.log_debug('pathfile_is_file = %s' % pathfile_is_file) + self.ts.log_debug('workspace_is_file = %s' % workspace_is_file) + self.ts.log_debug('svp_path_is_file = %s' % svp_path_is_file) + self.ts.log_debug('svp_pathfile_is_file = %s' % svp_pathfile_is_file) + + if path_is_file: + self.ts.log('Assuming project name is an absolute path to .llp file') + proj_path = self.project_dir_path + elif path_is_dir and pathfile_is_file: + self.ts.log('Assuming project directory + project name is an absolute path to .llp file') + proj_path = self.project_dir_path.rstrip('\\') + '\\' + self.rt_lab_model.split('.')[0] + '.llp' + elif workspace_is_file: + self.ts.log('Assuming workspace is used with Project Name and directory') + proj_path = os.path.join(self.workspace_path, self.project_dir_path.rstrip('\\') + '\\', + self.rt_lab_model.split('.')[0] + '.llp') + elif svp_path_is_file: + self.ts.log('Assuming .llp file is located in svpelab directory') + proj_path = svpelab_dir.rstrip('\\') + '\\' + self.project_dir_path + elif svp_pathfile_is_file: + self.ts.log('Assuming project directory and .llp file are located in svpelab directory') + proj_path = svpelab_dir.rstrip('\\') + '\\' + self.project_dir_path.rstrip('\\') + \ + self.rt_lab_model.split('.')[0] + '.llp' + else: + self.ts.log_error('project_dir_path is not a directory or a file!') + raise FileNotFoundError + + try: + # projectId = self.RtlabApi.OpenProject(project='', functionalBlock=None, + # controlPriority=OP_CTRL_PRIO_NORMAL, returnOnAmbiguity=False) + self.ts.log('Opening project file: %s' % proj_path) + self.RtlabApi.OpenProject(proj_path) + except Exception as e: + self.ts.log_warning('Could not open the project %s: %s' % (proj_path, e)) + raise + + # Set controls to the API + self.RtlabApi.GetParameterControl(1) + self.RtlabApi.GetSignalControl(1) + # self.RtlabApi.GetAcquisitionControl(1, 0) + self.RtlabApi.GetMonitoringControl(1) + self.control_panel_info(state=1) # GetSystemControl + + return 1 + + def close(self): + """ + Close any open communications resources associated with the HIL. + """ + try: + self.stop_simulation() + self.RtlabApi.CloseProject() + except Exception as e: + self.ts.log_error('Unable to close project. %s' % e) + + def info(self): + """ + Return system information + :return: Opal Information + """ + self.ts.log_debug('info(), self.target_name = %s' % self.target_name) + system_info = self.RtlabApi.GetTargetNodeSystemInfo(self.target_name) + opal_rt_info = "OPAL-RT - Platform version {0} (IP address : {1})".format(system_info[1], system_info[6]) + return opal_rt_info + + def control_panel_info(self, state=1): + """ + Requests or releases the system control of the currently connected model. System control enables the client + API to control the model's execution. Only one client API at a time is granted system control. + + :param state = systemControl: True(1) to request system control of the model, False(0) to release its control. + :return: None + """ + try: + if state == 1 or state == 0: + self.RtlabApi.GetSystemControl(state) + else: + self.ts.log_warning('Incorrect GetSystemControl state provided: state = %s' % state) + except Exception as e: + self.ts.log_warning('Error getting system control: %s' % e) + pass + + def load_schematic(self): + """ + Nonfunctional and deprecated! + + Load .mdl file + + :return: None + """ + + # SetCurrentModel is deprecated + model_info = {"mdlFolder": self.rt_lab_model_dir, "mdlName": self.rt_lab_model} + if self.rt_lab_model != 'None': + model_full_loc = self.rt_lab_model_dir + self.rt_lab_model + '\\' + self.rt_lab_model + '.mdl' + llp_full_loc = self.rt_lab_model_dir + self.rt_lab_model + '\\' + self.rt_lab_model + '.llp' + os.remove(llp_full_loc) # remove the .llp associated with the .mdl + + self.ts.log('Setting Current Model to %s.' % model_full_loc) + (instance_id,) = self.RtlabApi.SetCurrentModel(model_full_loc) + self.ts.log('Set Current Model to %s with instance ID: %s.' % (self.rt_lab_model, instance_id)) + else: + model_info["mdlFolder"], model_info["mdlName"] = self.RtlabApi.GetCurrentModel() + self.ts.log('Using default model. %s\\%s' % (model_info["mdlFolder"], model_info["mdlName"])) + + return model_info + + def model_state(self): + """ + modelState, realTimeMode = RtlabApi.GetModelState() + + modelState - The state of the model. See OP_MODEL_STATE. + realTimeMode - The real-time mode of the model. See OP_REALTIME_MODE. + + OP_MODEL_STATE: + MODEL_NOT_CONNECTED (0) - No connected model. + MODEL_NOT_LOADABLE (1) - Model has not been compiled + MODEL_COMPILING(2) - Model is compiling + MODEL_LOADABLE (3) - Model has been compiled and is ready to load + MODEL_LOADING(4) - Model is loading + MODEL_RESETTING(5) - Model is resetting + MODEL_LOADED (6) - Model loaded on target + MODEL_PAUSED (7) - Model is loaded and paused on target + MODEL_RUNNING (8) - Model is loaded and executed on target + MODEL_DISCONNECTED (9) - Model is disconnect + + OP_REALTIME_MODE: + HARD_SYNC_MODE (0) - Hardware synchronization mode (not available on WIN32 target). An I/O board with + timer is required on target + SIM_MODE (1) - Simulation as fast as possible mode + SOFT_SIM_MODE (2) - Software synchronization mode + SIM_W_NO_DATA_LOSS_MODE (3) - Not used anymore + SIM_W_LOW_PRIO_MODE (4) - Simulation as fast as possible in low priority mode (available only on WIN32 targ) + + :return: string with model state + """ + + model_status, _ = self.RtlabApi.GetModelState() + if model_status == self.RtlabApi.MODEL_NOT_CONNECTED: + return 'Model Not Connected' + elif model_status == self.RtlabApi.MODEL_NOT_LOADABLE: + return 'Model Not Loadable' + elif model_status == self.RtlabApi.MODEL_COMPILING: + return 'Model Compiling' + elif model_status == self.RtlabApi.MODEL_LOADABLE: + return 'Model Loadable' + elif model_status == self.RtlabApi.MODEL_LOADING: + return 'Model Loading' + elif model_status == self.RtlabApi.MODEL_RESETTING: + return 'Model Resetting' + elif model_status == self.RtlabApi.MODEL_LOADED: + return 'Model Loaded' + elif model_status == self.RtlabApi.MODEL_PAUSED: + return 'Model Paused' + elif model_status == self.RtlabApi.MODEL_RUNNING: + return 'Model Running' + elif model_status == self.RtlabApi.MODEL_DISCONNECTED: + return 'Model Disconnected' + else: + return 'Unknown Model state' + + def compile_model(self): + """ + Compiles the model + + :return: model_info dict with "mdlFolder", "mdlPath", and "modelId" keys + """ + + # Register Display information to get the target script STD output + # RtlabApi.RegisterDisplay(RtlabApi.DISPLAY_REGISTER_ALL) + + model_info = {} + try: + model_info["mdlFolder"], model_info["mdlName"] = self.RtlabApi.GetCurrentModel() + model_info["mdlPath"] = model_info["mdlFolder"] + model_info["mdlName"] + model_info["modelId"] = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_MODEL, model_info["mdlPath"]) + self.RtlabApi.SetAttribute(model_info["modelId"], self.RtlabApi.ATT_FORCE_RECOMPILE, True) + self.ts.log('Using default model. %s%s' % (model_info["mdlFolder"], model_info["mdlName"])) + except Exception as e: + self.ts.log_warning('Error using Current Model: %s' % e) + + try: + model_info["mdlFolder"] = self.rt_lab_model_dir + self.rt_lab_model + '\\' + model_info["mdlPath"] = self.rt_lab_model + '.mdl' + model_info["mdlPath"] = model_info["mdlFolder"] + model_info["mdlPath"] + model_info["modelId"] = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_MODEL, model_info["mdlPath"]) + self.RtlabApi.SetAttribute(model_info["modelId"], self.RtlabApi.ATT_FORCE_RECOMPILE, True) + + except Exception as e: + self.ts.log_warning('Error compiling model %s: %s' % (model_info["mdlPath"], e)) + + if self.model_state() == 'Model Paused': + self.ts.log('Model is loaded and paused. Restarting Model to re-compile.') + self.RtlabApi.Reset() + + # Launch compilation + compilationSteps = self.RtlabApi.OP_COMPIL_ALL_NT | self.RtlabApi.OP_COMPIL_ALL_LINUX + self.RtlabApi.StartCompile2((("", compilationSteps),), ) + self.ts.log('Compilation started. This will take a while...') + + # Wait until the end of the compilation + status = self.RtlabApi.MODEL_COMPILING + while status == self.RtlabApi.MODEL_COMPILING: + try: + # Check status every 0.5 second + sleep(0.5) + + # Get new status. To be done before DisplayInformation because DisplayInformation may generate an + # Exception when there is nothing to read + status, _ = self.RtlabApi.GetModelState() + + # Display compilation log into Python console + msg = '' + while len(msg) > 0: + self.ts.log(msg) + _, _, msg = self.RtlabApi.DisplayInformation(100) + + except Exception as exc: + # Ignore error 11 which is raised when RtlabApi.DisplayInformation is called when there is no + # pending message + info = sys.exc_info() + self.ts.log_debug('%s' % info) + if info[1][0] != 11: # 'There is currently no data waiting.' + # If a exception occur: stop waiting + self.ts.log_debug("An error occurred during compilation: %s", exc) + raise + + # Because we use a comma after print when forward compilation log into python log we have to ensure to + # write a carriage return when finished. + print('') + + # Get project status to check is compilation succeeded + if self.model_state() == 'Model Loadable': + self.ts.log('Compilation success.') + else: + self.ts.log('Compilation failed.') + + return model_info + + def load_model_on_hil(self): + """ + Load the model on the target + + :return: str indicating load state + """ + + if self.model_state() == 'Model Loadable': + self.ts.log('Loading Model. This may take a while...') + if self.rt_mode == "Hardware": + realTimeMode = self.RtlabApi.HARD_SYNC_MODE + else: # self.rt_mode == "Software": + realTimeMode = self.RtlabApi.SOFT_SIM_MODE + # Also possible to use SIM_MODE, SOFT_SIM_MODE, SIM_W_NO_DATA_LOSS_MODE or SIM_W_LOW_PRIO_MODE + timeFactor = 1 + try: + self.ts.log(f'The realtimemod : {realTimeMode}') + self.RtlabApi.Load(realTimeMode, timeFactor) + except Exception as e: + self.ts.log_warning('Model failed to load. Recommend opening and rebuilding the model in RT-Lab. ' + '%s' % e) + raise + return "The model is loaded." + else: + self.ts.log_warning('Model was not loaded because the status is: %s' % self.model_state()) + return "The model is not loaded." + + pass + + def matlab_cmd(self, cmd): + return self.RtlabApi.ExecuteMatlabCmd(cmd) + + def init_sim_settings(self): + pass + + def init_control_panel(self): + pass + + def voltage(self, voltage=None): + pass + + def stop_simulation(self): + """ + Reset simulation + + :return: model status + """ + self.ts.log('Stopping/Resetting simulation. Current State: %s' % self.model_state()) + if self.model_state() == 'Model Loadable': + self.ts.log('Model already stopped.') + else: + self.RtlabApi.Reset() + self.ts.log('Model state is now: %s' % self.model_state()) + return self.model_state() + + def start_simulation(self): + """ + Begin the simulation + + :return: Status str + """ + if self.model_state() == 'Model Paused': + # When in real-time mode, the execution rate is the model's sampling rate times the time factor. + timeFactor = 1 + self.ts.log('Simulation started.') + self.RtlabApi.Execute(timeFactor) + else: + self.ts.log_warning('Model is not running because the status is: %s' % self.model_state()) + return 'The model state is now: %s' % self.model_state() + + def run_py_script_on_target(self): + """ + Untested placeholder to run python code on the Opal target + + This example shows how to use the OpalExecuteTargetScript() API function + to start a python script on the remote target. + + The OpalExecuteTargetScript API function call requires a valid connection + to a model. We use in this example an empty model called empty.mdl only for + the Rt-Lab connection to be present. + + :return: None + """ + + # Get the current script directory + currentFolder = os.path.abspath(sys.path[0]) + scriptFullPath = os.path.join(currentFolder, 'myscript.py') + + import glob + projectName = os.path.abspath(str(glob.glob('.\\..\\*.llp')[0])) + self.RtlabApi.OpenProject(projectName) + print("The connection with '%s' is completed." % projectName) + + modelState, realTimeMode = self.RtlabApi.GetModelState() + print(('Model State: %s, Real Time Mode: %s' % (modelState, realTimeMode))) + + TargetPlatform = self.RtlabApi.GetTargetPlatform() + nodelist = self.RtlabApi.GetPhysNodeList() + + if TargetPlatform != self.RtlabApi.NT_TARGET: + if len(nodelist) > 0: + TargetName = nodelist[0] + + print("List of Physicals Nodes available to run the script: ", nodelist) + print("The script will be executed on the first Physical Node") + print("Selected Physical Node is: ", TargetName) + print(" ") + try: + # Register Display information to get the target script STD output + self.RtlabApi.RegisterDisplay(1) + + print(("Transferring the script :\n%s \nto the physical node %s" % (scriptFullPath, TargetName))) + self.RtlabApi.PutTargetFile(TargetName, scriptFullPath, "/home/ntuser/", self.RtlabApi.OP_TRANSFER_ASCII, 0) + + # Executing the script on the target + self.RtlabApi.StartTargetPythonScript(TargetName, "/home/ntuser/myscript.py", "Hello World", "") + + # Displaying the STD output of the script + print("*************Script output on the target************") + print((self.RtlabApi.DisplayInformation(0)[2])) + print("****************************************************") + finally: + pass + else: + print("At least one Physical Node should be configured in the Rt-Lab configuration") + print("See RT-LAB User Guide for more details about Physical Node configuration") + print("This information can be found in the section 2.2.5.9 - Hardware Tab") + else: + print("The empty.mdl file is configured to run a Windows Target. \nThis example does not support the " + "Windows target, please select another target platform") + + pass + + def set_parameters(self, parameters): + """ + Sets the parameters in the RT-Lab Model + + :param parameters: tuple of (parameter, value) pairs + :return: None + """ + + if parameters is not None: + for p, v in parameters: + self.ts.log_debug('Setting parameter %s = %s' % (p, v)) + self.set_params(p, v) + + def get_parameters(self, verbose=False): + """ + Get the parameters from the model + + :return: list of parameter tuples with (path, name, value) + """ + + model_parameters = self.RtlabApi.GetParametersDescription() + # array of tuples: (id, path, name, variableName, value) + mdl_params = [] + for param in range(len(model_parameters)): + mdl_params.append((model_parameters[param][1], + model_parameters[param][2], + model_parameters[param][4])) + if verbose: + self.ts.log_debug('Param: %s, %s is %s' % (model_parameters[param][1], + model_parameters[param][2], + model_parameters[param][4])) + return mdl_params + + def get_matlab_variable_value(self, variableName): + """ + Get the matlab variable value + + :param variableName: name of the variable + :return: value string + """ + attributeNumber = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_VARIABLE, self.rt_lab_model + '/' + variableName) + value = self.RtlabApi.GetAttribute(attributeNumber, self.RtlabApi.ATT_MATRIX_VALUE) + return str(value) + + def set_matlab_variable_value(self, variableName, valueToSet): + """ + Change matlab variable. Typically these are referenced in the simulink model, so these changes affect the + simulation. + + :param variableName: Matlab variable + :param valueToSet: New matlab value + :return: value of variable as measured from the + """ + # self.ts.log_debug('set_matlab_variable_value() variableName = %s, valueToSet = %s' % + # (variableName, valueToSet)) + + attributeNumber = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_VARIABLE, + self.rt_lab_model + '/' + variableName) + value = self.RtlabApi.GetAttribute(attributeNumber, self.RtlabApi.ATT_MATRIX_VALUE) + if valueToSet != value: + self.ts.log_debug(f'Setting matlab variable {variableName} to {valueToSet} instead of {value} ') + self.ts.sleep(0.5) + + self.RtlabApi.SetAttribute(attributeNumber, self.RtlabApi.ATT_MATRIX_VALUE, valueToSet) + attributeNumber = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_VARIABLE, + self.rt_lab_model + '/' + variableName) + value = self.RtlabApi.GetAttribute(attributeNumber, self.RtlabApi.ATT_MATRIX_VALUE) + else: + self.ts.log_debug(f'matlab variable {variableName} was already configured to {valueToSet} ') + return value + + def get_acq_signals_raw(self, signal_map=None, verbose=False): + """ + Returns the acquisition signals sent to the console subsystem while the model is running. The acquisition + signals are the signals sent from the computation nodes to console subsystem in the same order that it was + specified at the input of the OpComm block for the specified acquisition group. The outputs contains two + arrays: acquisition signals + monitoring signals. + + The user can activate the synchronization algorithm to synchronize the acquisition time with the simulation + time by inserting data during missed data intervals. The interpolation can be used in this case to get a + better result during missed data intervals. Threshold time between acquisition time and simulation time + exceeds the threshold, the acquisition (console) will be updated to overtake the difference. The acqtimestep + offers the user a way to change his console step size as in Simulink. + + :param signal_map: list of acquisition signals names + :param verbose: bool that indicates if the function prints results + :return: if a signal map is provided, returns a dict of the acq values mapped to the list' + if no signal map, return list of data. + """ + + # SetAcqBlockLastVal -- Set the current settings for the blocking / non-blocking, and associated last value + # flag, for signal acquisition. This information is used by the API acquisition functions, which have the + # option not to wait for data, needed by some console's lack of multiple thread support. The ProbeControl + # panel sets these settings using this call. + + # blockOnGroup: Acquisition group for which the API functions will wait for data (specify n for group n). + # If 0, API function will wait for data for all groups. If -1, API functions will not wait for any group's data. + BlockOnGroup = 0 + # lastValues: Boolean, 1 means API functions will output the last values received while a group's data is + # not available. If 0, the API function will output zeroes. This paramater is ignored when blockOnGroup is + # not equal to -1. + lastValues = 1 + self.RtlabApi.SetAcqBlockLastVal(BlockOnGroup, lastValues) + + # acquisitionGroup: Acquisition group number, starts from 0. + # synchronization: synchronization 1/0 = Enable/Disable + # interpolation: interpolation 1/0 = Enable/Disable + # threshold: Threshold difference time between the simulation and the acquisition (console) time in seconds. + # acqTimeStep: Sample interval: acquisition (console) timestep must be equal or greater than the model time step + acquisitionGroup = 0 + synchronization = 0 + interpolation = 0 + threshold = 0 + acqTimeStep = 80e-6 + values, monitoringInfo, simulationTimeStep, endFrame = \ + self.RtlabApi.GetAcqGroupSyncSignals(acquisitionGroup, synchronization, + interpolation, threshold, acqTimeStep) + + # monitoringInfo: Monitoring information tuple. It contains the following values: Missed data, offset, + # simulationTime and sampleSec. See below for more details. + missedData, offset, simulationTime, sampleSec = monitoringInfo + # self.ts.log_debug('SimulationTime of data acquisition = %s' % simulationTime) + + if missedData >= 1.0: + self.ts.log_warning('Missing data in last acquisition. Number of missing data points: %s' % missedData) + + if verbose: + # values: Acquired signals from acquisition. It contains a tuple of values with one value for each signal in + # the acquisition group. + self.ts.log_debug('Acquired signals from acquisition: %s' % (str(values))) + # missedData: Number of values between two acquisition frame. If value is 0, there are no missing data + # between the two frames. Missing data may appear if network communication and display are too slow to + # refresh value generated by the model. + self.ts.log_debug('Number of values missing between two acquisition frames (missedData): %s' % + (str(missedData))) + # offset: simulation time when the acquisition started. + self.ts.log_debug('Simulation time when the acquisition started (offset): %s' % (str(offset))) + # simulationTime: simulation time of the model when the acquisition has been done. + self.ts.log_debug('Simulation time at acquisition (simulationTime): %s' % + (str(simulationTime))) + # sampleSec: Number of sample/sec received from target. Calculation is made for one sample. Ex: If model is + # running at 0.001s, sample/sec value should not exceed 1000 sample/sec. + self.ts.log_debug('Number of sample/sec received from target (sampleSec): %s' % (str(sampleSec))) + # simulationTimeStep: Simulation timestep of the acquired data. + self.ts.log_debug('Simulation timestep of the acquired data (simTimeStep): %s' % (str(simulationTimeStep))) + # endFrame: True when signals are the last in the acquisition buffer (next values will be in a next frame). + self.ts.log_debug('Number of values between two acquisition frames: %s' % (str(endFrame))) + + if signal_map is not None: + idx = 0 + for key, value in signal_map: + signal_map[key] = values[idx] + idx += 1 + signal_map['TIME'] = simulationTime + return signal_map + else: + return list(values) + + def get_acq_signals(self, verbose=False): + """ + Get the data acquisition signals from the model + + :return: list of tuples of data acq signals from SC_ outputs, (signalId, label, value) + + """ + + signals = self.RtlabApi.GetSignalsDescription() + # array of tuples: (signalType, signalId, path, label, reserved, readonly, value) + # 0 signalType: Signal type. See OP_SIGNAL_TYPE. + # 1 signalId: Id of the signal. + # 2 path: Path of the signal. + # 3 label: Label or name of the signal. + # 4 reserved: unused? + # 5 readonly: True when the signal is read-only. + # 6 value: Current value of the signal. + + acq_signals = [] + for sig in range(len(signals)): + if str(signals[sig][0]) == 'OP_ACQUISITION_SIGNAL(0)': + acq_signals.append((signals[sig][1], signals[sig][3], signals[sig][6])) + if verbose: + self.ts.log_debug('Sig #%d: Type: %s, Path: %s, Label: %s, value: %s' % + (signals[sig][1], signals[sig][0], signals[sig][2], signals[sig][3], + signals[sig][6])) + + return acq_signals + + def get_control_signals(self, details=False, verbose=False): + """ + Get the control signals from the model + + The control signals are the signals sent from the console to the computation nodes in the same order as + specified in the input of the OpComm of the specified computation nodes. + + :return: list of control signals + if details == True, return a list of tuples (signalType, signalId, path, label, value) + if details == False, return list of values for the signals in the control + """ + + if details: + signals = self.RtlabApi.GetControlSignalsDescription() + # (signalType, signalId, path, label, reserved, readonly, value) = signalInfo1 + # 0 signalType: Signal type. See OP_SIGNAL_TYPE. + # 1 id: Id of the signal. + # 2 path: Path of the signal. + # 3 label: Label or name of the signal. + # 4 reserved: + # 5 readonly: True when the signal is read-only. + # 6 value: Current value of the signal + + control_signals = [] + for sig in range(len(signals)): + control_signals.append((signals[sig][0], signals[sig][1], signals[sig][2], signals[sig][3], + signals[sig][6])) + if verbose: + self.ts.log_debug('Sig #%d: Type: %s, Path: %s, Label: %s, value: %s' % + (signals[sig][1], signals[sig][0], signals[sig][2], signals[sig][3], + signals[sig][6])) + + else: + control_signals = list(self.RtlabApi.GetControlSignals()) + if verbose: + for param in range(len(control_signals)): + self.ts.log_debug('Control Signal #%d = %s' % (param, control_signals[param])) + + return control_signals + + def set_control_signals(self, values=None): + """ + Set the control signals from the model + + The control signals are the signals sent from the console to the computation nodes in the same order as + specified in the input of the OpComm of the specified computation nodes. + + :return: None + """ + + logical_id = 1 # SC_Subsystem + # A unique number associated with each subsystem of type SM, SC or SS in a model. This number is assigned during + # the compilation of the model and is independent of the target where the simulation is performed. The SM + # subsystem is always assigned Id 1, the SC subsystem is always assigned Id 2 and the SS subsystem is assigned + # Id 2 if there is no console and a value greater than 2 if there is a console. The logical Id can be obtained + # by calling the GetSubsystemList function. Note: Some API functions accept an Id of 0. This value means that + # the function will be applied to all subsystem. + + subsystems = self.RtlabApi.GetSubsystemList() + # self.ts.log_debug(subsystems) + # ('IEEE_P1547_TEST_V22/SM_SystemDynamics', 1, 'Target_3') + # ('IEEE_P1547_TEST_V22/SC_InputsandOutputs', 2, '') + subsystem = 'None' + for sub in subsystems: + # self.ts.log_debug('sub = %s' % str(sub)) + if sub[1] == logical_id: + subsystem = sub[0] + if subsystem == 'None': + self.ts.log_warning('No subsystem was found') + + # self.ts.log_debug('Sending the following control signals: %s to %s' % (values, subsystem)) + if values is not None: + if isinstance(values, list): + self.RtlabApi.SetControlSignals(logical_id, tuple(values)) + elif isinstance(values, tuple): + self.RtlabApi.SetControlSignals(logical_id, values) + else: + self.ts.log_warning('No values set by RtlabApi.SetControlSignals() because values were not list ' + 'or tuple') + else: + self.ts.log_warning('No values set by RtlabApi.SetControlSignals()') + + return None + + def set_params(self, param, value): + """ + Set parameters in the model + + :param param: tuple of/or str for the parameter location, e.g., "PF818072_test_model/sm_computation/Rocof/Value" + :param value: tuple of/or float values of the parameters + + :return: None + """ + + if type(param) is tuple and type(value) is tuple: + self.RtlabApi.SetParametersByName(param, value) + elif type(param) is str and type(float(value)) is float: + self.RtlabApi.SetParametersByName(param, value) + else: + self.ts.log_debug('Error in the param or value types. type(param) = %s, type(value) = %s ' % + (type(param), type(value))) + pass + + def set_var(self, variable, value): + """ + Set Matlab variable in the model + """ + modelName = self.rt_lab_model + attributeNumber = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_VARIABLE, modelName + '/' + variable) + self.RtlabApi.SetAttribute(attributeNumber, self.RtlabApi.ATT_MATRIX_VALUE, value) + + # attributeNumber = self.RtlabApi.FindObjectId(self.RtlabApi.OP_TYPE_VARIABLE, + # self.rt_lab_model + '/' + variable) + + def set_matlab_variables(self, variables): + """ + Sets the variables in the RT-Lab Model + + :param variables: tuple of (variableName, valueToSet) pairs + :return: None + """ + + if variables is not None: + for variable, value in variables: + self.set_matlab_variable_value(variable, value) + + def get_matlab_variables(self, variables): + """ + Get the variables in the RT-Lab Model + + :param variables: tuple or list of (variableName) pairs + :return: None + """ + parameter = [] + if variables is not None: + for variable in variables: + parameter.append((variable, self.get_matlab_variable_value(variable))) + return parameter + + def get_signals(self, verbose=False): + """ + Get the signals from the model + + :return: list of parameter tuples with (signalID, path, label, value) + """ + # (signalType, signalId, path, label, reserved, readonly, value) = signalInfo = RtlabApi.GetSignalsDescription() + signal_parameters = self.RtlabApi.GetSignalsDescription() + signal_params = [] + for sig in range(len(signal_parameters)): + signal_params.append((signal_parameters[sig][1], + signal_parameters[sig][2], + signal_parameters[sig][3], + signal_parameters[sig][6])) + if verbose: + self.ts.log_debug('Signal #%s: %s [%s] = %s' % (signal_parameters[sig][1], + signal_parameters[sig][2], + signal_parameters[sig][3], + signal_parameters[sig][6])) + return signal_params + + def get_sample_time(self): + """ + Get the acquisition sample time from the model + + :return: time + """ + # Get the acquisition sample time for the specified group. The acquisition sample time is the interval between + # two values inside an acquisition frame. sampleTime = self.RtlabApi.GetAcqSample Time(acqGroup) + # sample_time_step = self.RtlabApi.GetAcqSampleTime() + + calculationStep, timeFactor = self.RtlabApi.GetTimeInfo() + # self.ts.log_debug('Time Info. calculationStep = %s, timeFactor = %s' % (calculationStep, timeFactor)) + return calculationStep + + def set_stop_time(self, stop_time): + """ + Set the simulation stop time + + :return: None + """ + + if self.RtlabApi.GetStopTime() != stop_time: + self.RtlabApi.SetStopTime(stop_time) + else: + self.ts.log_warning('Stop time already set to %s' % stop_time) + return self.RtlabApi.GetStopTime() + + def set_time_sig(self, time_path): + """ + Set the path of time signal + + :return: None + """ + _, model_name = self.RtlabApi.GetCurrentModel() + model_name = model_name.rstrip('.mdl').rstrip('.slx') + + self.time_sig_path = model_name + time_path + self.ts.log_debug(f'Set the time signal path to {self.time_sig_path } ') + + def get_time(self): + """ + Get simulation time from the clock signal + :return: simulation time in sec + """ + + try: + if self.model_state() == 'Model Running': + sim_time = self.RtlabApi.GetSignalsByName(self.time_sig_path) + return sim_time + else: + self.ts.log_debug('Can not read simulation time becauase the simulation is not running. Returning 1e6.') + sim_time = 1.0e6 # ensures that the simulation loop will stop in the script + return sim_time + except Exception as e: + self.ts.log_debug('Could not get time for simulation. Simulation likely completed. Returning 1e6. %s' % e) + sim_time = 1.0e6 # ensures that the simulation loop will stop in the script + return sim_time + + +if __name__ == "__main__": + import time + import_path = "C://OPAL-RT//RT-LAB//2020.4//common//python" + try: + sys.path.insert(0, import_path) + import RtlabApi + import OpalApiPy + print('RtlabApi Imported. Using %s' % import_path) + except ImportError as e: + print('RtlabApi Import Error. Check the version number. Using path = %s' % import_path) + print(e) + + system_info = RtlabApi.GetTargetNodeSystemInfo("Target_3") + for i in range(len(system_info)): + print((system_info[i])) + print(("OPAL-RT - Platform version {0} (IP address : {1})".format(system_info[1], system_info[6]))) + + # projectName = "C:\\Users\\DETLDAQ\\OPAL-RT\\RT-LABv2020.4_Workspace\\IEEE_1547.1_Phase_Jump\\models\\" \ + # "Phase_Jump_A_B_A\\Phase_Jump_A_B_A.llp" + projectName = "C:\\Users\\DETLDAQ\\OPAL-RT\\RT-LABv2020.4_Workspace\\" \ + "IEEE_1547.1_Phase_Jump\\IEEE_1547.1_Phase_Jump.llp" + # projectName = "IEEE_1547.1_Phase_Jump" + RtlabApi.OpenProject(projectName) + RtlabApi.GetParameterControl(1) + RtlabApi.GetSystemControl(1) + RtlabApi.GetAcquisitionControl(1) + + RtlabApi.SetAcqBlockLastVal(0, 1) + print(RtlabApi.GetAcqBlockLastVal()) + + # RtlabApi.Reset() + RtlabApi.Load(RtlabApi.HARD_SYNC_MODE, 1) + RtlabApi.Execute(1) + + acquisitionGroup = 0 + synchronization = 0 + interpolation = 0 + threshold = 0 + acqTimeStep = 0.001 + + for i in range(10): + + values, (missedData, offset, simulationTime, sampleSec), simulationTimeStep, endFrame = \ + RtlabApi.GetAcqGroupSyncSignals(acquisitionGroup, synchronization, interpolation, + threshold, acqTimeStep) + print('Simulation Time = %s, Acquired signals from acquisition: %s' % + (simulationTime, str(values)[0:80])) + + time.sleep(1) + + for i in range(10): + frames = 0 # the number of frames is configured in the probe control for the model in RT-Lab + while 1: + values, (missedData, offset, simulationTime, sampleSec), simulationTimeStep, endFrame = \ + RtlabApi.GetAcqGroupSyncSignals(acquisitionGroup, synchronization, interpolation, + threshold, acqTimeStep) + + frames += 1 + if endFrame: # if the frame is over + print('Simulation Time = %s, Acquired signals from acquisition: %s' % + (simulationTime, str(values)[0:300])) + break # stop loop + + # print('Total frames: %s' % frames) + time.sleep(1) + + # io_interface = RtlabApi.GetIOInterfaces() + # print('GetIOInterfaces: %s' % io_interface) + # print(type(io_interface[0])) + # io_name = io_interface[0]['name'] + # print('io_name: %s' % io_name) + # io_points = RtlabApi.GetConnectionPointsForIO() + # for point in range(len(io_points)): + # print(io_points[point]) + # + # control_signals = RtlabApi.GetControlSignals() + # for sig in range(len(control_signals)): + # print('GetControlSignals[%d]: %s' % (sig, control_signals[sig])) + + + + # status, _ = RtlabApi.GetModelState() + # if status == RtlabApi.MODEL_LOADABLE: + # realTimeMode = RtlabApi.HARD_SYNC_MODE + # timeFactor = 1 + # RtlabApi.Load(realTimeMode, timeFactor) + # print("The model is loaded.") + # else: + # print("The model is not loadable.") + # + # status, _ = RtlabApi.GetModelState() + # print('Status is: %s' % status) + # if status == RtlabApi.MODEL_PAUSED: + # RtlabApi.Execute(1) + # modelState, realTimeMode = RtlabApi.GetModelState() + # "The model state is now %s." % RtlabApi.OP_MODEL_STATE(modelState) + # sleep(2) + # + # model_parameters = RtlabApi.GetParametersDescription() + # for param in range(len(model_parameters)): + # print('Param: %s, %s is %s' % (model_parameters[param][1], + # model_parameters[param][2], + # model_parameters[param][4])) + + # print('Simulation time is: %s' % [RtlabApi.GetTimeInfo()]) + # print('Simulation time is: %s' % (RtlabApi.GetPauseTime())) + # print('Simulation time is: %s' % (RtlabApi.GetStopTime())) + # print('Simulation time is: %s' % (RtlabApi.GetAcqSampleTime())) + + # RtlabApi.Pause() + # sleep(2) + + ''' + RtlabApi.CloseProject() + + RtlabApi.SetParametersByName("PF818072_test_model/sm_computation/Rocof/Value", 10.) + RtlabApi.SetParametersByName("PF818072_test_model/sm_computation/Rocom/Value", 10.) + sleep(2) + ''' + + ''' Change phase and amplitude of sine wave + # ampl = [2, 2, 2] + # phases = [0, 60, 120] + for loop in range(1, 10): + ampl = [1.*loop, 1.*loop, 1.*loop] + phases = [0.+5.*loop, 120.-5.*loop, 240.+2.*loop] + print('Amplitudes are: %s, Phase angles are: %s' % (ampl, phases)) + RtlabApi.SetParametersByName((mag[1], mag[2], mag[3], ang[1], ang[2], ang[3]), + (ampl[0], ampl[1], ampl[2], phases[0], phases[1], phases[2])) + sleep(1) + ''' + + ''' + # Compile Model + model_info = {} + model_info["mdlFolder"], model_info["mdlName"] = RtlabApi.GetCurrentModel() # Get path to model + model_info["mdlPath"] = os.path.join(model_info["mdlFolder"], model_info["mdlName"]) + RtlabApi.RegisterDisplay(RtlabApi.DISPLAY_REGISTER_ALL) + + # Set attribute on project to force to recompile (optional) + model_info["modelId"] = RtlabApi.FindObjectId(RtlabApi.OP_TYPE_MODEL, model_info["mdlPath"]) + RtlabApi.SetAttribute(model_info["modelId"], RtlabApi.ATT_FORCE_RECOMPILE, True) + + # Launch compilation + compilationSteps = RtlabApi.OP_COMPIL_ALL_NT | RtlabApi.OP_COMPIL_ALL_LINUX + RtlabApi.StartCompile2((("", compilationSteps),), ) + print('Compilation started.') + + # Wait until the end of the compilation + status = RtlabApi.MODEL_COMPILING + while status == RtlabApi.MODEL_COMPILING: + try: + # Check status every 0.5 second + sleep(0.5) + + # Get new status. To be done before DisplayInformation because DisplayInformation may generate an Exception + # when there is nothing to read + status, _ = RtlabApi.GetModelState() + + # Display compilation log into Python console + _, _, msg = RtlabApi.DisplayInformation(100) + while len(msg) > 0: + print(msg) + _, _, msg = RtlabApi.DisplayInformation(100) + + except Exception as exc: + # Ignore error 11 which is raised when RtlabApi.DisplayInformation is called whereas there is no + # pending message + info = sys.exc_info() + if info[1][0] != 11: # 'There is currently no data waiting.' + # If a exception occur: stop waiting + print("An error occurred during compilation.") + raise + + # Because we use a comma after print when forward compilation log into python log we have to ensure to + # write a carriage return when finished. + print('') + + # Get project status to check is compilation succeeded + status, _ = RtlabApi.GetModelState() + if status == RtlabApi.MODEL_LOADABLE: + print('Compilation success.') + else: + print('Compilation failed.') + + status, _ = RtlabApi.GetModelState() + if status == RtlabApi.MODEL_LOADABLE: + realTimeMode = RtlabApi.HARD_SYNC_MODE + # Other options: SIM_MODE, SOFT_SIM_MODE, SIM_W_NO_DATA_LOSS_MODE or SIM_W_LOW_PRIO_MODE + timeFactor = 1 + RtlabApi.Load(realTimeMode, timeFactor) + print("The model is loaded.") + else: + print("The model is not loadable.") + + # Run simulation + status, _ = RtlabApi.GetModelState() + if status == RtlabApi.MODEL_PAUSED: + RtlabApi.Execute(1) + modelState, realTimeMode = RtlabApi.GetModelState() + print("The model state is now %s." % RtlabApi.OP_MODEL_STATE(modelState)) + + RtlabApi.CloseProject() + ''' + + diff --git a/Lib/svpelab/hil_typhoon.py b/Lib/svpelab/hil_typhoon.py index 5c012a7..6afaeff 100644 --- a/Lib/svpelab/hil_typhoon.py +++ b/Lib/svpelab/hil_typhoon.py @@ -31,34 +31,52 @@ """ import os -import hil +from . import hil try: import typhoon - import typhoon.api.hil_control_panel as cp + import typhoon.api.hil as cp # control panel from typhoon.api.schematic_editor import model import typhoon.api.pv_generator as pv -except Exception, e: - print('Typhoon HIL API not installed. %s' % e) +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) typhoon_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], 'mode': 'Typhoon' } + def hil_info(): return typhoon_info -def params(info): + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = typhoon_info['mode'] + info.param_add_value('hil.mode', typhoon_info['mode']) - info.param_group('hil.typhoon', label='Typhoon Parameters', - active='hil.mode', active_value=['Typhoon'], glob=True) - info.param('hil.typhoon.auto_config', label='Configure HIL at beginning of test', default='Disabled', + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), + active_value=mode, glob=True) + info.param(pname('auto_config'), label='Configure HIL at beginning of test', default='Disabled', values=['Enabled', 'Disabled']) - info.param('hil.typhoon.eut_nominal_voltage', label='EUT nameplate voltage (V)', default=230.0) - info.param('hil.typhoon.eut_nominal_frequency', label='EUT nominal frequency (Hz)', default=50.0) - info.param('hil.typhoon.model_name', label='Model file name (.tse)', default=r"ASGC") - info.param('hil.typhoon.setting_name', label='Settings file name (.runx)', default=r"settings2.runx") + info.param(pname('eut_nominal_voltage'), label='EUT nameplate voltage (V)', default=230.0) + info.param(pname('eut_nominal_frequency'), label='EUT nominal frequency (Hz)', default=50.0) + + info.param(pname('model_name'), label='Model file name (.tse)', + default=r"ASGC_Closed_loop_full_model.tse") + info.param(pname('setting_name'), label='Settings file name (.runx)', + default=r"ASGC_full_settings.runx") + info.param(pname('hil_working_dir'), + label='Absolute path of working directory where the .tse and the .runx are located', + default=r"c:/Users/Public/TyphoonHIL/ModelA") + + info.param('hil.typhoon.debug', label='Debug level of HIL API', default=0) + + +GROUP_NAME = 'typhoon' + class HIL(hil.HIL): """ @@ -68,38 +86,55 @@ class HIL(hil.HIL): mode - 'Typhoon' auto_config - ['Enabled', 'Disabled'] """ + + def __stripExtension__(self, var, extention): + try: + fname = var.split('.') + if fname[-1] == extention: + fname = fname[:-1] + var = '.'.join(fname) + return var + except Exception as e: + raise hil.HILGenericException("Failed modelname parsing and formatting: %s" % e) + def __init__(self, ts): hil.HIL.__init__(self, ts) self.ts = ts - self.auto_config = ts.param_value('hil.typhoon.auto_config') self.eut_nominal_power = ts.param_value('hil.typhoon.eut_nominal_power') + self.v = ts.param_value('hil.typhoon.eut_nominal_voltage') + self.f = ts.param_value('hil.typhoon.eut_nominal_frequency') + self.model_name = ts.param_value('hil.typhoon.model_name') self.pv_name = ts.param_value('hil.typhoon.pv_name') self.settings_file_name = ts.param_value('hil.typhoon.setting_name') - self.v = ts.param_value('hil.typhoon.eut_nominal_voltage') - self.f = ts.param_value('hil.typhoon.eut_nominal_frequency') - cp.set_debug_level(level=3) + self.hil_model_dir = ts.param_value('hil.typhoon.hil_working_dir') + self.hil_model_dir = self.hil_model_dir.replace('\\', '/')+'/' + self.debug = False + + try: + self.debug_level = int(ts.param_value('hil.typhoon.debug')) + except: + self.debug_level = 0 + + if self.debug_level > 0: + self.debug = True + + if self.debug: + cp.set_debug_level(level=self.debug_level) + + # Check and remove extensions: + try: + self.model_name = self.__stripExtension__(self.model_name, 'tse') + self.settings_file_name = self.__stripExtension__(self.settings_file_name, 'runx') + except Exception as e: + raise e if self.auto_config == 'Enabled': ts.log('Configuring the Typhoon HIL Emulation Environment.') self.config() - # self.ts.log('Sources = %s' % cp.get_sources()) - # ts.log('Changing grid voltage to 250 V.') - # self.ts.log('Changed phase A: %s' % cp.prepare_source_sine_waveform('V_source_phase_A', rms=250.)) - # self.ts.log('Changed phase B: %s' % cp.prepare_source_sine_waveform('V_source_phase_B', rms=250.)) - # self.ts.log('Changed phase C: %s' % cp.prepare_source_sine_waveform('V_source_phase_C', rms=250.)) - # self.ts.log('Executing: %s' % cp.update_sources(["V_source_phase_A", "V_source_phase_B", "V_source_phase_C"], - # executeAt=None)) - # cp.wait_msec(100.0) - # - # v1 = float(cp.read_analog_signal(name='V( Vrms1 )')) - # v2 = float(cp.read_analog_signal(name='V( Vrms2 )')) - # v3 = float(cp.read_analog_signal(name='V( Vrms3 )')) - # self.ts.log('Grid voltages: %s' % [v1, v2, v3]) - def info(self): self.ts.log(' ') self.ts.log('available ambient temperatures = %s' % cp.available_ambient_temperatures()) @@ -122,6 +157,44 @@ def control_panel_info(self): self.ts.log('available analog meters = %s' % typhoon.api.ti_control_panel.available_references()) return typhoon.api.ti_control_panel.available_references() + def __buildHandler__(self): + """ + :todo check if model already built + :return: + """ + + if not os.path.exists(self.hil_model_dir + self.model_name + r" Target files/" + self.model_name + r".cpd"): + if not self.load_schematic(): + raise hil.HILModelException("Failed to load Schematic!") + + if not self.compile_model(): + raise hil.HILCompileException("Failed to compile model!") + else: + self.ts.log("Found cpd! Trying to use precompiled version") + + def __loadHandler__(self): + self.ts.sleep(1) + + try: + self.ts.log("Trying to load HIL model {}".format(self.model_name)) + for i in range(0, 4): + try: + self.__buildHandler__() + except Exception as e: + self.ts.log("Failed build with {}".format(e)) + continue + if self.load_model_on_hil(): + self.ts.log("Model loaded after {} tries".format(i)) + return True + else: + self.ts.log("Retry {}/4: Trying to load HIL Model {}".format(i,self.model_name)) + # We will delete the Entire compiler output folder + import shutil + shutil.rmtree(self.hil_model_dir + self.model_name + r" Target files/", ignore_errors=True) + raise hil.HILModelException("Failed to load the model") + except Exception as e: + raise hil.HILRuntimeException("Failed to load model! {}".format(e)) + def config(self): """ Perform any configuration for the simulation based on the previously @@ -129,46 +202,61 @@ def config(self): """ self.ts.log('Checking on HIL HW settings...') hw = model.get_hw_settings() - self.ts.log_debug('HIL hardware is %s' % (hw,)) + self.ts.log_debug('HIL hardware is %s' % hw) + # model.set_simulation_time_step(self.sim_time_step) + + try: + self.__loadHandler__() + except: + raise - self.load_schematic() - self.compile_model() - self.load_model_on_hil() self.init_sim_settings() self.ts.log("HIL simulation successfully prepared for execution.") self.ts.log("Starting Simulation...") self.start_simulation() + """ + This is a rather crude way to wait for EUT to start up! + """ # let the inverter startup sleeptime = 15 + try: + # perturb irradiance + cp.set_pv_amb_params("PV1", illumination=995.) + self.ts.sleep(1) + cp.set_pv_amb_params("PV1", illumination=1000.) + except Exception as e: + self.ts.log('Attempted to perturb PV1 irradiance to get inverter to start. This failed. %s' % e) for i in range(1, sleeptime): - print ("Waiting another %d seconds until the inverter starts. Power = %f." % - ((sleeptime-i), cp.read_analog_signal(name='Pdc'))) + print(("Waiting another %d seconds until the inverter starts." % (sleeptime-i))) self.ts.sleep(1) def load_schematic(self): ''' Load HIL simulation schematic ''' - lib_dir_raw = os.path.dirname(__file__) + os.path.sep - lib_dir = lib_dir_raw.replace("\\", "/") - model_file = r"Typhoon/" + self.model_name + r".tse" - model_dir = lib_dir + model_file - self.ts.log("Model File: %s" % model_dir) - if os.path.isfile(model_dir): + if self.model_name[-4:] == ".tse": + model_file = self.hil_model_dir + self.model_name + else: + model_file = self.hil_model_dir + self.model_name + r".tse" + + self.ts.log("Model File: %s" % model_file) + + if os.path.isfile(model_file): self.ts.log_debug("Model file exists! Starting to compile power electronic parts...") else: - self.ts.log_debug("Model file does not exist!") + self.ts.log_debug("Model file does not exist! {}".format(model_file)) status = False return status # load schematic (with default component parameters) - if not model.load(model_dir, debug=True): + if not model.load(model_file, debug=self.debug): self.ts.log_warning("Model did not load!") status = False return status + return True def compile_model(self): ''' @@ -178,38 +266,42 @@ def compile_model(self): self.ts.log_warning("Model did not compile!") status = False return status + return True def load_model_on_hil(self): ''' Load model ''' - lib_dir_raw = os.path.dirname(__file__) + os.path.sep - lib_dir = lib_dir_raw.replace("\\", "/") - hil_model_file = r"Typhoon/" + self.model_name + r" Target files/" + self.model_name + r".cpd" - hil_model_dir = lib_dir + hil_model_file - self.ts.log("Model File: %s" % hil_model_dir) - if os.path.isfile(hil_model_dir): + hil_model_file = self.hil_model_dir + self.model_name + r" Target files/" + self.model_name + r".cpd" + self.ts.log("Model File: %s" % hil_model_file) + + if os.path.isfile(hil_model_file): self.ts.log_debug("HIL model (.cpd) file exists!") else: self.ts.log_debug("HIL model (.cpd) file does not exist!") status = False return status - if not cp.load_model(file=hil_model_dir): + if not cp.load_model(file=hil_model_file): self.ts.log_warning("HIL model (.cpd) did not load!") + return False + + return True def init_sim_settings(self): ''' Configure simulation settings ''' - lib_dir_raw = os.path.dirname(__file__) + os.path.sep - lib_dir = lib_dir_raw.replace("\\", "/") - settings_file = r"Typhoon/" + self.settings_file_name - settings_file_dir = lib_dir + settings_file - self.ts.log("Model File: %s" % settings_file_dir) + if self.settings_file_name[-5:] == ".runx": + settings_file = self.hil_model_dir + self.settings_file_name + else: + settings_file = self.hil_model_dir + self.settings_file_name + r".runx" - if os.path.isfile(settings_file_dir): + + self.ts.log("Model File: %s" % settings_file) + + if os.path.isfile(settings_file): self.ts.log_debug("Settings file (.runx) file exists!") else: self.ts.log_debug("Settings file (.runx) file does not exist!") @@ -217,8 +309,10 @@ def init_sim_settings(self): return status # Open existing settings file. - if not cp.load_settings_file(file=settings_file_dir): + if not cp.load_settings_file(file=settings_file): self.ts.log_warning("Settings file (.runx) did not work did not compile!") + return False + return True def init_control_panel(self): pass @@ -236,223 +330,272 @@ def start_simulation(self): cp.start_simulation() if __name__ == "__main__": - import sys - import time - import numpy as np - import math - - sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable/Lib/site-packages') - #sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable/Scripts') - sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable') - sys.path.insert(0, r'C:/Typhoon HIL Control Center') - #sys.path.insert(0, r'C:/Typhoon HIL Control Center/typhoon/conf') - #sys.path.insert(0, r'C:/Typhoon HIL Control Center/typhoon/conf/components') - - import typhoon.api.hil_control_panel as hil - from typhoon.api.schematic_editor import model - import os - - hil.set_debug_level(level=1) - hil.stop_simulation() - - model.get_hw_settings() - #model_dir = r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/Typhoon/' - #print model_dir, os.path.isfile(model_dir) - if not model.load(r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/Typhoon/ASGC_AI.tse'): - print "Model did not load!" - - if not model.compile(): - print "Model did not compile!" - - # first we need to load model - hil.load_model(file=r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/Typhoon/ASGC_AI Target files/ASGC_AI.cpd') - - # we could also open existing settings file... - hil.load_settings_file(file=r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/Typhoon/settings.runx') - - # after setting parameter we could start simulation - hil.start_simulation() - - # let the inverter startup - sleeptime = 15 - for i in range(1, sleeptime): - print ("Waiting another %d seconds until the inverter starts. Power = %f." % - ((sleeptime-i), hil.read_analog_signal(name='Pdc'))) - time.sleep(1) - - - ''' - Setup the circuit for anti-islanding - ''' - V_nom = 230.0 - P_rating = 34500 - freq_nom = 50 - resistor = (V_nom**2)/P_rating - capacitor = P_rating/(2*np.pi*freq_nom*(V_nom**2)) - inductor = (V_nom**2)/(2*np.pi*freq_nom*P_rating) - resonance_freq = 1/(2*np.pi*math.sqrt(capacitor*inductor)) - Qf = resistor*(math.sqrt(capacitor/inductor)) - X_C = 1/(2*np.pi*freq_nom*capacitor) - X_L = (2*np.pi*freq_nom*inductor) - - print('R = %0.3f, L = %0.3f, C = %0.3f' % (resistor, capacitor, inductor)) - print('F_resonance = %0.3f, Qf = %0.3f, X_C = %0.3f, X_L = %0.3f' % (resonance_freq, Qf, X_C, X_L)) - - R3 = 0 - R4 = 0 - R5 = 0 - L1 = 0 - L2 = 0 - L3 = 0 - C3 = capacitor - C4 = capacitor - C5 = capacitor - L5 = inductor - L6 = inductor - L4 = inductor - R14 = resistor - R15 = resistor - R16 = resistor - - ''' - set_component_property(component, property, value) - Sets component property value to provided value. - - Parameters: - component - name of component. - property - name of property. - value - new property value. - Returns: - True if successful, False otherwise. - - set_simulation_time_step(time_step) - Set schematic model simulation time time_step - - Arguments: - simulation time step - time step used for simulation - Returns: - True if successful, False otherwise - ''' - - ''' - Waveform capture - ''' - simulationStep = hil.get_sim_step() - print('Simulation time step is %f' % simulationStep) - trigsamplingrate = 1./simulationStep - pretrig = 1 - posttrig = 2.5 - trigval = 0.5 - trigtimeout = 5 - trigcondition = 'Falling edge' - trigchannel = 'S1_fb' - trigacqchannels = [['V( V_DC3 )', 'I( Ipv )', 'V( V_L1 )', 'I( Ia )'], ['S1_fb']] - n_analog_channels = 4 - save_file_name = r'D:\SVP\SVP Directories 11-7-16\UL 1741 SA Dev\Results\capture_test.mat' - - # signals for capturing - channelSettings = trigacqchannels - - # cpSettings - list[decimation,numberOfChannels,numberOfSamples, enableDigitalCapture] - numberOfSamples = int(trigsamplingrate*(pretrig+posttrig)) - print('Numer of Samples is %d' % numberOfSamples) - if numberOfSamples > 32e6/len(channelSettings): - print('Number of samples is not less than 32e6/numberOfChannels!') - numberOfSamples = 32e6/n_analog_channels - print('Number of samples set to 32e6/numberOfChannels!') - elif numberOfSamples < 256: - print('Number of samples is not greater than 256!') - numberOfSamples = 256 - print('Number of samples set to 256.') - elif numberOfSamples % 2 == 1: - print('Number of samples is not even!') - numberOfSamples += 1 - print('Number of samples set to %d.' % numberOfSamples) - - captureSettings = [1, n_analog_channels, numberOfSamples, True] - - ''' - triggerSource - channel or the name of signal that will be used for triggering (int value or string value) - Note: - In case triggerType == Analog: - triggerSource (int value) - value can be > 0 and <= "numberOfChannels" if we enter channel number. - triggerSource (string value) - value is Analog signal name that we want to use for trigger source. Analog Signal - name must be one of signal names from list of signals that we want to capture ("chSettings" list, see below). - In case triggerType == Digital: - triggerSource (int value) - value must be > 0 and maximal value depends of number of digital signals in loaded model - triggerSource (string value) - value is Digital signal name that we want to use for trigger source. - - threshold - trigger threshold (float value) - Note: "threshold" is only used for "Analog" type of trigger. If you use "Digital" type of trigger, you still need to - provided this parameter (for example 0.0 ) - - edge - trigger on "Rising edge" or "Falling edge" - - triggerOffset - Define the number of samples in percentage to capture before the trigger event (for example 20, if the - numberOfSamples is 100k, 20k samples before and 80k samples after the trigger event will be captured) - ''' - # trSettings - list[triggerType,triggerSource,threshold,edge,triggerOffset] - # triggerSettings = ["Analog", 'I( Irms1 )', trigval, trigcondition, (pretrig*100.)/(pretrig+posttrig)] - # triggerSettings = ["Digital", 'S1_fb', trigval, trigcondition, (pretrig*100.)/(pretrig+posttrig)] - triggerSettings = ["Forced"] - # print('digital signals = %s' % hil.available_digital_signals()) - - # python list is used for data buffer - capturedDataBuffer = [] - - print captureSettings - print triggerSettings - print channelSettings - print('Power = %0.3f' % hil.read_analog_signal(name='Pdc')) - if hil.read_digital_signal(name='S1_fb') == 1: - print('Contactor is closed.') - else: - print('Contactor is open.') - - # start capture process... - if hil.start_capture(captureSettings, - triggerSettings, - channelSettings, - dataBuffer=capturedDataBuffer, - fileName=save_file_name, - timeout=trigtimeout): - - time.sleep(0.5) - - #print hil.available_contactors() - print("Actuating S1 Contactor") - hil.set_contactor_control_mode('S1', swControl=True) - hil.set_contactor_state('S1', swState=False, executeAt=None) # open contactor - - if hil.read_digital_signal(name='S1_fb') == 1: - print('Contactor is closed.') - else: - print('Contactor is open.') - # when capturing is finished... - while hil.capture_in_progress(): - pass - # unpack data from data buffer - (signalsNames, wfm_data, wfm_time) = capturedDataBuffer[0] + # self.auto_config = ts.param_value('hil.typhoon.auto_config') + # self.eut_nominal_power = ts.param_value('hil.typhoon.eut_nominal_power') + # self.v = ts.param_value('hil.typhoon.eut_nominal_voltage') + # self.f = ts.param_value('hil.typhoon.eut_nominal_frequency') + # + # self.model_name = ts.param_value('hil.typhoon.model_name') + # self.pv_name = ts.param_value('hil.typhoon.pv_name') + # self.settings_file_name = ts.param_value('hil.typhoon.setting_name') + # self.hil_model_dir = ts.param_value('hil.typhoon.hil_working_dir') + # self.hil_model_dir = self.hil_model_dir.replace('\\', '/')+'/' + + class ts(object): + def param_value(self, v): + if v == "hil.typhoon.hil_working_dir": return 'C:\\Users\\AblingerR\\Documents\\AITProjects\\EPRI\\Anti-Islanding' + if v == "hil.typhoon.model_name": return 'ASGC_TestSuite_AI_V6_3_YtoMP_EPRI_60Hz_50p' + if v == "hil.typhoon.hil.typhoon.setting_name": return 'ASGC_TestSuite_AI_full_settings_HIL402' + + return v - # unpack data for appropriate captured signals - V_dc = wfm_data[0] # first row for first signal and so on - i_dc = wfm_data[1] - V_ac = wfm_data[2] - i_ac = wfm_data[3] - contactor_trig = wfm_data[4] + def log(self, e): + print(("{}".format(e))) - import matplotlib.pyplot as plt - plt.plot(wfm_time, V_ac, 'b', wfm_time, i_ac, 'r', wfm_time, contactor_trig*100, 'k') - plt.show() + def log_debug(self, e): + self.log("DEBUG: {}".format(e)) - # hil.set_contactor_state('S1', swState=True, executeAt=None) + def log_warning(self, e): + self.log("WARNING: {}".format(e)) - # read the AC Power - # for i in range(1, 10): - # print hil.read_analog_signal(name='Pdc') - # time.sleep(2) + def sleep(self, n): + import time + time.sleep(n) + + pass - # stop simulation - hil.stop_simulation() + e = ts() + + + t = HIL(e) + t.config() + + + + + + + + + # import sys + # import time + # import numpy as np + # import math + + # sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable/Lib/site-packages') + # #sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable/Scripts') + # sys.path.insert(0, r'C:/Typhoon HIL Control Center/python_portable') + # sys.path.insert(0, r'C:/Typhoon HIL Control Center') + # #sys.path.insert(0, r'C:/Typhoon HIL Control Center/typhoon/conf') + # #sys.path.insert(0, r'C:/Typhoon HIL Control Center/typhoon/conf/components') + # + # import typhoon.api.hil_control_panel as hil + # from typhoon.api.schematic_editor import model + # import os + # + # hil.set_debug_level(level=1) + # hil.stop_simulation() + # + # model.get_hw_settings() + # #model_dir = r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/TyphoonASGC/' + # #print model_dir, os.path.isfile(model_dir) + # if not model.load(r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/TyphoonASGC/ASGC_AI.tse'): + # print "Model did not load!" + # + # if not model.compile(): + # print "Model did not compile!" + # + # # first we need to load model + # hil.load_model(file=r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/TyphoonASGC/ASGC_AI Target files/ASGC_AI.cpd') + # + # # we could also open existing settings file... + # hil.load_settings_file(file=r'D:/SVP/SVP Directories 11-7-16/UL 1741 SA Dev/Lib/TyphoonASGC/settings.runx') + # + # # after setting parameter we could start simulation + # hil.start_simulation() + # + # # let the inverter startup + # sleeptime = 15 + # for i in range(1, sleeptime): + # print ("Waiting another %d seconds until the inverter starts. Power = %f." % + # ((sleeptime-i), hil.read_analog_signal(name='Pdc'))) + # time.sleep(1) + # + # + # ''' + # Setup the circuit for anti-islanding + # ''' + # V_nom = 230.0 + # P_rating = 34500 + # freq_nom = 50 + # resistor = (V_nom**2)/P_rating + # capacitor = P_rating/(2*np.pi*freq_nom*(V_nom**2)) + # inductor = (V_nom**2)/(2*np.pi*freq_nom*P_rating) + # resonance_freq = 1/(2*np.pi*math.sqrt(capacitor*inductor)) + # Qf = resistor*(math.sqrt(capacitor/inductor)) + # X_C = 1/(2*np.pi*freq_nom*capacitor) + # X_L = (2*np.pi*freq_nom*inductor) + # + # print('R = %0.3f, L = %0.3f, C = %0.3f' % (resistor, capacitor, inductor)) + # print('F_resonance = %0.3f, Qf = %0.3f, X_C = %0.3f, X_L = %0.3f' % (resonance_freq, Qf, X_C, X_L)) + # + # R3 = 0 + # R4 = 0 + # R5 = 0 + # L1 = 0 + # L2 = 0 + # L3 = 0 + # C3 = capacitor + # C4 = capacitor + # C5 = capacitor + # L5 = inductor + # L6 = inductor + # L4 = inductor + # R14 = resistor + # R15 = resistor + # R16 = resistor + # + # ''' + # set_component_property(component, property, value) + # Sets component property value to provided value. + # + # Parameters: + # component - name of component. + # property - name of property. + # value - new property value. + # Returns: + # True if successful, False otherwise. + # + # set_simulation_time_step(time_step) + # Set schematic model simulation time time_step + # + # Arguments: + # simulation time step - time step used for simulation + # Returns: + # True if successful, False otherwise + # ''' + # + # ''' + # Waveform capture + # ''' + # simulationStep = hil.get_sim_step() + # print('Simulation time step is %f' % simulationStep) + # trigsamplingrate = 1./simulationStep + # pretrig = 1 + # posttrig = 2.5 + # trigval = 0.5 + # trigtimeout = 5 + # trigcondition = 'Falling edge' + # trigchannel = 'S1_fb' + # trigacqchannels = [['V( V_DC3 )', 'I( Ipv )', 'V( V_L1 )', 'I( Ia )'], ['S1_fb']] + # n_analog_channels = 4 + # save_file_name = r'D:\SVP\SVP Directories 11-7-16\UL 1741 SA Dev\Results\capture_test.mat' + # + # # signals for capturing + # channelSettings = trigacqchannels + # + # # cpSettings - list[decimation,numberOfChannels,numberOfSamples, enableDigitalCapture] + # numberOfSamples = int(trigsamplingrate*(pretrig+posttrig)) + # print('Numer of Samples is %d' % numberOfSamples) + # if numberOfSamples > 32e6/len(channelSettings): + # print('Number of samples is not less than 32e6/numberOfChannels!') + # numberOfSamples = 32e6/n_analog_channels + # print('Number of samples set to 32e6/numberOfChannels!') + # elif numberOfSamples < 256: + # print('Number of samples is not greater than 256!') + # numberOfSamples = 256 + # print('Number of samples set to 256.') + # elif numberOfSamples % 2 == 1: + # print('Number of samples is not even!') + # numberOfSamples += 1 + # print('Number of samples set to %d.' % numberOfSamples) + # + # captureSettings = [1, n_analog_channels, numberOfSamples, True] + # + # ''' + # triggerSource - channel or the name of signal that will be used for triggering (int value or string value) + # Note: + # In case triggerType == Analog: + # triggerSource (int value) - value can be > 0 and <= "numberOfChannels" if we enter channel number. + # triggerSource (string value) - value is Analog signal name that we want to use for trigger source. Analog Signal + # name must be one of signal names from list of signals that we want to capture ("chSettings" list, see below). + # In case triggerType == Digital: + # triggerSource (int value) - value must be > 0 and maximal value depends of number of digital signals in loaded model + # triggerSource (string value) - value is Digital signal name that we want to use for trigger source. + # + # threshold - trigger threshold (float value) + # Note: "threshold" is only used for "Analog" type of trigger. If you use "Digital" type of trigger, you still need to + # provided this parameter (for example 0.0 ) + # + # edge - trigger on "Rising edge" or "Falling edge" + # + # triggerOffset - Define the number of samples in percentage to capture before the trigger event (for example 20, if the + # numberOfSamples is 100k, 20k samples before and 80k samples after the trigger event will be captured) + # ''' + # # trSettings - list[triggerType,triggerSource,threshold,edge,triggerOffset] + # # triggerSettings = ["Analog", 'I( Irms1 )', trigval, trigcondition, (pretrig*100.)/(pretrig+posttrig)] + # # triggerSettings = ["Digital", 'S1_fb', trigval, trigcondition, (pretrig*100.)/(pretrig+posttrig)] + # triggerSettings = ["Forced"] + # # print('digital signals = %s' % hil.available_digital_signals()) + # + # # python list is used for data buffer + # capturedDataBuffer = [] + # + # print captureSettings + # print triggerSettings + # print channelSettings + # print('Power = %0.3f' % hil.read_analog_signal(name='Pdc')) + # if hil.read_digital_signal(name='S1_fb') == 1: + # print('Contactor is closed.') + # else: + # print('Contactor is open.') + # + # # start capture process... + # if hil.start_capture(captureSettings, + # triggerSettings, + # channelSettings, + # dataBuffer=capturedDataBuffer, + # fileName=save_file_name, + # timeout=trigtimeout): + # + # time.sleep(0.5) + # + # #print hil.available_contactors() + # print("Actuating S1 Contactor") + # hil.set_contactor_control_mode('S1', swControl=True) + # hil.set_contactor_state('S1', swState=False, executeAt=None) # open contactor + # + # if hil.read_digital_signal(name='S1_fb') == 1: + # print('Contactor is closed.') + # else: + # print('Contactor is open.') + # + # # when capturing is finished... + # while hil.capture_in_progress(): + # pass + # + # # unpack data from data buffer + # (signalsNames, wfm_data, wfm_time) = capturedDataBuffer[0] + # + # # unpack data for appropriate captured signals + # V_dc = wfm_data[0] # first row for first signal and so on + # i_dc = wfm_data[1] + # V_ac = wfm_data[2] + # i_ac = wfm_data[3] + # contactor_trig = wfm_data[4] + # + # import matplotlib.pyplot as plt + # plt.plot(wfm_time, V_ac, 'b', wfm_time, i_ac, 'r', wfm_time, contactor_trig*100, 'k') + # plt.show() + # + # # hil.set_contactor_state('S1', swState=True, executeAt=None) + # + # # read the AC Power + # # for i in range(1, 10): + # # print hil.read_analog_signal(name='Pdc') + # # time.sleep(2) + # + # # stop simulation + # hil.stop_simulation() diff --git a/Lib/svpelab/loadsim.py b/Lib/svpelab/loadsim.py index 8dc2b6a..3520c22 100644 --- a/Lib/svpelab/loadsim.py +++ b/Lib/svpelab/loadsim.py @@ -44,12 +44,12 @@ def params(info, id=None, label='Load Simulator', group_name=None, active=None, group_name += '.' + LOADSIM_DEFAULT_ID if id is not None: group_name = group_name + '_' + str(id) - print 'group_name = %s' % group_name + print('group_name = %s' % group_name) name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) - print 'name = %s' % name('mode') + print('name = %s' % name('mode')) info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) - for mode, m in loadsim_modules.iteritems(): + for mode, m in loadsim_modules.items(): m.params(info, group_name=group_name) LOADSIM_DEFAULT_ID = 'loadsim' @@ -93,6 +93,11 @@ def __init__(self, ts, group_name): self.ts = ts self.group_name = group_name + def config(self): + """ + Configure device. + """ + pass def info(self): """ @@ -112,27 +117,61 @@ def close(self): """ pass - def resistance(self, r=None, ph = None): + def resistance(self, r=None, ph=None): + """ + Set resistance, r, in ohms on phase, ph + """ pass - def inductance(self, i=None, ph=None): + def inductance(self, l=None, ph=None): + """ + Set inductance, l, in henries on phase, ph + """ pass def capacitance(self, c=None, ph=None): + """ + Set capacitance, c, in farads on phase, ph + """ pass def capacitor_q(self, q=None, ph=None): + """ + Set capacitance, q, in vars on phase, ph + """ pass def inductor_q(self, q=None, ph=None): + """ + Set inductance, q, in vars on phase, ph + """ pass def resistance_p(self, p=None, ph=None): + """ + Set resistance, p, in watts on phase, ph + """ pass def tune_current(self, i=None, ph=None): + """ + Adjust load bank to produce a certain level of current + """ + pass + + def p_q_profile(self, csv=None): + """ + Setup load banks to run a power profile from a csv file + + file format: time (sec), resistance (watts), inductance (var), capacitance (var) + """ pass + def start_profile(self): + """ + Trigger p_q_profile to start running + """ + pass def loadsim_scan(): global loadsim_modules @@ -155,7 +194,7 @@ def loadsim_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] raise LoadSimError('Error scanning module %s: %s' % (module_name, str(e))) diff --git a/Lib/svpelab/loadsim_chroma_63200.py b/Lib/svpelab/loadsim_chroma_63200.py index 0e709ce..a49c298 100644 --- a/Lib/svpelab/loadsim_chroma_63200.py +++ b/Lib/svpelab/loadsim_chroma_63200.py @@ -34,8 +34,8 @@ import time import socket import serial -import visa -import loadsim +import pyvisa as visa +from . import loadsim chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -151,7 +151,7 @@ def cmd_serial(self, cmd_str): self.conn.flushInput() self.conn.write(cmd_str) - except Exception, e: + except Exception as e: raise loadsim.LoadSimError(str(e)) def query_serial(self, cmd_str): @@ -174,7 +174,7 @@ def query_serial(self, cmd_str): raise loadsim.LoadSimError('Timeout waiting for response') except loadsim.LoadSimError: raise - except Exception, e: + except Exception as e: raise loadsim.LoadSimError('Timeout waiting for response - More data problem') return resp @@ -189,7 +189,7 @@ def cmd_tcp(self, cmd_str): # print 'cmd> %s' % (cmd_str) self.conn.send(cmd_str) - except Exception, e: + except Exception as e: raise loadsim.LoadSimError(str(e)) def query_tcp(self, cmd_str): @@ -207,7 +207,7 @@ def query_tcp(self, cmd_str): if d == '\n': #\r more_data = False break - except Exception, e: + except Exception as e: raise loadsim.LoadSimError('Timeout waiting for response') return resp @@ -218,7 +218,7 @@ def cmd(self, cmd_str): # self.ts.log_debug('cmd_str = %s' % cmd_str) try: self._cmd(cmd_str) - except Exception, e: + except Exception as e: raise loadsim.LoadSimError(str(e)) # Queries for load @@ -226,7 +226,7 @@ def query(self, cmd_str): # self.ts.log_debug('query cmd_str = %s' % cmd_str) try: resp = self._query(cmd_str).strip() - except Exception, e: + except Exception as e: raise loadsim.LoadSimError(str(e)) return resp @@ -272,7 +272,7 @@ def open(self): writeTimeout=self.write_timeout) time.sleep(2) - except Exception, e: + except Exception as e: raise loadsim.LoadSimError(str(e)) def close(self): diff --git a/Lib/svpelab/loadsim_chroma_A800067.py b/Lib/svpelab/loadsim_chroma_A800067.py index 4ba3b4e..86f76ab 100644 --- a/Lib/svpelab/loadsim_chroma_A800067.py +++ b/Lib/svpelab/loadsim_chroma_A800067.py @@ -31,8 +31,8 @@ """ import os -import loadsim -import chroma_A800067 as chroma +from . import loadsim +from . import chroma_A800067 as chroma chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -88,13 +88,13 @@ def voltset(self, v): def freqset(self, f): return self.rlc.freqset(f) - def inductance(self, ph, i=None): - if i is not None: - return self.rlc.inductance(ph, i) + def inductance(self, ph=None, l=None): + if l is not None: + return self.rlc.inductance(ph, l) - def capacitance(self, ph, i=None): - if i is not None: - return self.rlc.capacitance(ph, i) + def capacitance(self, ph=None, c=None): + if c is not None: + return self.rlc.capacitance(ph, c) else: self.ts.log('Enter the capacitive load in F.') diff --git a/Lib/svpelab/loadsim_icselect_8064.py b/Lib/svpelab/loadsim_icselect_8064.py new file mode 100644 index 0000000..fe02be8 --- /dev/null +++ b/Lib/svpelab/loadsim_icselect_8064.py @@ -0,0 +1,291 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import loadsim +from . import device_loadsim_icselect_8064 as icselect +import csv +import time + +icselect_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'ICS Electronics 8064 Banks' +} + +def loadsim_info(): + return icselect_info + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = icselect_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('banks'), label='Which Load Banks are Used?', default='RL', + values=['R', 'L', 'C', 'RL', 'RC', 'RLC']) + info.param(pname('mode'), label='Load bank operating mode:', default='Read CSV', values=['Read CSV', 'Static']) + + # info.param_group(gname('r_load'), label='Resistive Bank', active=pname('banks'), + # active_value=['R', 'RL', 'RC', 'RLC']) + info.param(pname('comm_r'), label='Communications Interface (R)', default='VXI11', values=['VXI11'], + active=pname('banks'), active_value=['R', 'RL', 'RC', 'RLC']) + info.param(pname('vxi11_device_r'), label='VXI11 IP Address for Resistive Bank', active=pname('comm_r'), + active_value=['VXI11'], default='10.1.32.63') + info.param(pname('power'), label='Power Value (W)', default=2000, active=pname('mode'), active_value=['Static']) + + info.param(pname('comm_l'), label='Communications Interface (R)', default='VXI11', values=['VXI11'], + active=pname('banks'), active_value=['L', 'RL', 'RLC']) + info.param(pname('vxi11_device_l'), label='VXI11 IP Address for Inductive Bank', active=pname('comm_l'), + active_value=['VXI11'], default='10.1.32.64') + info.param(pname('q_l'), label='Reactive Power Value (Var)', default=2000, active=pname('mode'), + active_value=['Static']) + + info.param(pname('comm_c'), label='Communications Interface (R)', default='VXI11', values=['VXI11'], + active=pname('banks'), active_value=['C', 'RC', 'RLC']) + info.param(pname('vxi11_device_c'), label='VXI11 IP Address for Inductive Bank', active=pname('comm_c'), + active_value=['VXI11'], default='10.1.32.65') + info.param(pname('q_c'), label='Reactive Power Value (Var)', default=2000, active=pname('mode'), + active_value=['Static']) + + info.param(pname('csv'), label='CSV string for load profile [time, R, L, C]:', + default='C:\\Users\detldaq\Downloads\Load_test.csv', active=pname('mode'), active_value=['Read CSV']) + + +GROUP_NAME = 'icselect' + + +class LoadSim(loadsim.LoadSim): + """ + ICS Electronics loadsim class. + """ + def __init__(self, ts, group_name): + loadsim.LoadSim.__init__(self, ts, group_name) + self.ts = ts + + self.banks = self._param_value('banks') + self.mode = self._param_value('mode') + self.r_load = None + self.l_load = None + self.c_load = None + + self.time = None # time steps in csv profile + + self.comm_r = self._param_value('comm_r') + self.vxi11_device_r = self._param_value('vxi11_device_r') + self.power = self._param_value('power') + + self.comm_l = self._param_value('comm_l') + self.vxi11_device_l = self._param_value('vxi11_device_l') + self.q_l = self._param_value('q_l') + + self.comm_c = self._param_value('comm_c') + self.vxi11_device_c = self._param_value('vxi11_device_c') + self.q_c = self._param_value('q_c') + + self.csv = self._param_value('csv') + + if 'R' in self.banks: + params = {} + params['loadbank_type'] = 'R' + params['ip_addr'] = self.vxi11_device_r + # NOTE: if the CSV file is profiled the time/target levels will be updated in the init + params['csv'] = self.csv + params['switch_map'] = {0: None, + 263: 1, + 526: 2, + 1052: 3, + 2106: 4, + 4210: 5, + 1053: 6, + 8421: 7, + 2105: 8, + 9080: 9, + 3158: 10} + self.r_load = icselect.Device(params=params) + self.r_load.open() + + if 'L' in self.banks: + params = {} + params['loadbank_type'] = 'L' + params['ip_addr'] = self.vxi11_device_l + # NOTE: if the CSV file is profiled the time/target levels will be updated in the init + params['csv'] = self.csv + params['switch_map'] = {0: None, + 197: 1, + 390: 2, + 788: 3, + 1582: 4, + 3170: 5, + 790: 6, + 6175: 7, + 1562: 8, + 9080: 9, + 2340: 10} + self.l_load = icselect.Device(params=params) + self.l_load.open() + if 'C' in self.banks: + params = {} + params['loadbank_type'] = 'C' + params['ip_addr'] = self.vxi11_device_c + # NOTE: if the CSV file is profiled the time/target levels will be updated in the init + params['csv'] = self.csv + params['switch_map'] = {0: None, + 197: 1, + 390: 2, + 788: 3, + 1582: 4, + 3170: 5, + 790: 6, + 6175: 7, + 1562: 8, + 9080: 9, + 2340: 10} + self.c_load = icselect.Device(params=params) + self.c_load.open() + + # if there is a CSV file pull the time and R, L, C data here. + if self.mode == 'Read CSV': + self.p_q_profile(csvfile=self.csv) + elif self.mode == 'Static': + if self.r_load: + self.resistance_p(p=self.power) + if self.l_load: + self.inductor_q(q=self.q_l) + if self.c_load: + self.capacitor_q(q=self.q_c) + else: + self.ts.log_warning('Loadbank mode unsupported!') + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def resistance(self, r=None, ph=None): + pass + + def inductance(self, l=None, ph=None): + pass + + def capacitance(self, c=None, ph=None): + pass + + def capacitor_q(self, q=None, ph=None): + switches, loads, error = self.c_load.set_value(q) + return switches, loads, error + + def inductor_q(self, q=None, ph=None): + switches, loads, error = self.l_load.set_value(q) + return switches, loads, error + + def resistance_p(self, p=None, ph=None): + switches, loads, error = self.r_load.set_value(p) + return switches, loads, error + + def tune_current(self, i=None, ph=None): + pass + + def start_profile(self, debug=False): + if debug: + self.ts.log_debug('time = %s, power = %s, q_l = %s, q_c = %s' % (self.time, self.power, self.q_l, self.q_c)) + if type(self.time) is not list: + self.ts.log_error('Profile not provided in load bank init.') + start = time.time() + self.ts.sleep(0.1) + i = 0 + while i < len(self.time): + now = time.time() + elapsed = now - start + if elapsed >= self.time[i]: + if self.r_load: + switches, loads, error = self.resistance_p(p=self.power[i]) + if debug: + self.ts.log_debug('Target = %s W, Total power = %s, switches: %s, loads: %s, power error = %s W' + % (self.power[i], sum(loads), switches, loads, error)) + if self.l_load: + self.inductor_q(q=self.q_l[i]) + if self.c_load: + self.capacitor_q(q=self.q_c[i]) + if debug: + self.ts.log_debug('Target = %s W, %s inductive var, %s capacitive var at time = %s s.' % + (self.power[i], self.q_l[i], self.q_c[i], round(elapsed, 1))) + i += 1 + else: + self.ts.sleep(0.05) + + def p_q_profile(self, csvfile=None): + if file is not None: + self.time = [] + self.power = [] + self.q_l = [] + self.q_c = [] + with open(csvfile) as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + for row in csv_reader: + try: + self.time.append(float(row[0])) + if self.r_load: + self.power.append(float(row[1])) + else: + self.power.append(0) + if self.l_load: + self.q_l.append(float(row[2])) + else: + self.q_l.append(0) + if self.c_load: + self.q_c.append(float(row[3])) + else: + self.q_c.append(0) + except Exception as e: + print(('Not an numerical entry...skipping data for row %s. Error: %s' % (row, e))) + + self.ts.log_debug('time = %s, power = %s, q_l = %s, q_c = %s' % (self.time, self.power, self.q_l, self.q_c)) + + def close(self): + if self.r_load: + self.r_load.close() + if self.l_load: + self.l_load.close() + if self.c_load: + self.c_load.close() + + def info(self): + r_string = None + l_string = None + c_string = None + if self.r_load: + r_string = self.r_load.info() + if self.l_load: + l_string = self.l_load.info() + if self.c_load: + c_string = self.c_load.info() + return 'Resistive Load: %s, Inductive Load: %s, Capacitive Load: %s' % (r_string, l_string, c_string) diff --git a/Lib/svpelab/loadsim_manual.py b/Lib/svpelab/loadsim_manual.py index 8757cda..73b452a 100644 --- a/Lib/svpelab/loadsim_manual.py +++ b/Lib/svpelab/loadsim_manual.py @@ -31,7 +31,7 @@ """ import os -import loadsim +from . import loadsim manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/loadsim_ni_crio_avtron_inductive.py b/Lib/svpelab/loadsim_ni_crio_avtron_inductive.py new file mode 100644 index 0000000..d2a5293 --- /dev/null +++ b/Lib/svpelab/loadsim_ni_crio_avtron_inductive.py @@ -0,0 +1,128 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import loadsim +from . import chroma_A800067 as chroma + +chroma_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Chroma A800067' +} + +def loadsim_info(): + return chroma_info + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = chroma_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('comm'), label='Communications Interface', default='VISA', values=['VISA']) + info.param(pname('visa_device'), label='VISA Device String', active=pname('comm'), + active_value=['VISA'], default='//192.168.1.231/ASRL1::INSTR') + info.param(pname('visa_path'), label='VISA Path', active=pname('comm'), + active_value=['VISA'], default='') + info.param(pname('volts'), label='Voltage', default=220) + info.param(pname('freq'), label='Frequency', default=50) + + # rio://192.168.1.231/RIO0 + # visa://192.168.1.231/ASRL1::INSTR + +GROUP_NAME = 'chroma_A800067' + + +class LoadSim(loadsim.LoadSim): + """ + Chroma loadsim class. + """ + def __init__(self, ts, group_name): + loadsim.LoadSim.__init__(self, ts, group_name) + self.visa_device = self._param_value('visa_device') + self.visa_path = self._param_value('visa_path') + self.volts = self._param_value('volts') + self.freq = self._param_value('freq') + + self.rlc = chroma.ChromaRLC(visa_device=self.visa_device, visa_path=self.visa_path, volts=self.volts, + freq=self.freq) + self.rlc.open() + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def resistance(self, ph=None, r=None): + self.rlc.resistance(ph, r) + + def voltset(self, v): + return self.rlc.voltset(v) + + def freqset(self, f): + return self.rlc.freqset(f) + + def inductance(self, ph=None, l=None): + if l is not None: + return self.rlc.inductance(ph, l) + + def capacitance(self, ph=None, c=None): + if c is not None: + return self.rlc.capacitance(ph, c) + else: + self.ts.log('Enter the capacitive load in F.') + + """def capacitor_q(self, q=None): + if q is not None: + self.ts.log('Adjust the capacitive load of the fundamental freq to %0.3f VAr.' % q) + else: + self.ts.log('Enter the capacitor reactive power in VAr.') + + def inductor_q(self, q=None): + if q is not None: + self.ts.log('Adjust the inductive load of the fundamental freq to %0.3f VAr.' % q) + else: + self.ts.log('Enter the inductor reactive power in VAr.') + + def resistance_p(self, p=None): + if p is not None: + self.ts.log('Adjust the resistive load of the fundamental freq to %0.3f W.' % p) + else: + self.ts.log('Enter the resistor power in W.') + + def tune_current(self, i=None): + if c is not None: + self.ts.log('Adjust R, L, and C until the fundamental frequency current through switch S3 is ' + 'less than %0.2f' % i) + else: + pass + """ \ No newline at end of file diff --git a/Lib/svpelab/loadsim_pass.py b/Lib/svpelab/loadsim_pass.py index 536e7c5..2c041e1 100644 --- a/Lib/svpelab/loadsim_pass.py +++ b/Lib/svpelab/loadsim_pass.py @@ -31,14 +31,14 @@ """ import os -import loadsim +from . import loadsim pass_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], 'mode': 'Pass' } -def load_info(): +def loadsim_info(): return pass_info def params(info, group_name=None): @@ -61,43 +61,43 @@ class LoadSim(loadsim.LoadSim): def __init__(self, ts, group_name): loadsim.LoadSim.__init__(self, ts, group_name) - def resistance(self, r=None): + def resistance(self, r=None, ph=None): if r is not None: self.ts.log('Adjust the resistive load to R = %0.3f Ohms.' % r) else: self.ts.log('Enter the resistive load in Ohms.') - def inductance(self, i=None): + def inductance(self, l=None, ph=None): if i is not None: - self.ts.log('Adjust the inductive load to L = %0.6f H.' % i) + self.ts.log('Adjust the inductive load to L = %0.6f H.' % l) else: self.ts.log('Enter the inductive load in H.') - def capacitance(self, c=None): + def capacitance(self, c=None, ph=None): if c is not None: self.ts.log('Adjust the capacitive load to C = %0.6f F.' % c) else: self.ts.log('Enter the capacitive load in F.') - def capacitor_q(self, q=None): + def capacitor_q(self, q=None, ph=None): if q is not None: self.ts.log('Adjust the capacitive load of the fundamental freq to %0.3f VAr.' % q) else: self.ts.log('Enter the capacitor reactive power in VAr.') - def inductor_q(self, q=None): + def inductor_q(self, q=None, ph=None): if q is not None: self.ts.log('Adjust the inductive load of the fundamental freq to %0.3f VAr.' % q) else: self.ts.log('Enter the inductor reactive power in VAr.') - def resistance_p(self, p=None): + def resistance_p(self, p=None, ph=None): if p is not None: self.ts.log('Adjust the resistive load of the fundamental freq to %0.3f W.' % p) else: self.ts.log('Enter the resistor power in W.') - def tune_current(self, i=None): + def tune_current(self, i=None, ph=None): if c is not None: self.ts.log('Adjust R, L, and C until the fundamental frequency current through switch S3 is ' 'less than %0.2f' % i) diff --git a/Lib/svpelab/loadsim_sandia.py b/Lib/svpelab/loadsim_sandia.py index 039e50d..43e3b64 100644 --- a/Lib/svpelab/loadsim_sandia.py +++ b/Lib/svpelab/loadsim_sandia.py @@ -31,16 +31,18 @@ """ import os -import loadsim +from . import loadsim sandia_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], 'mode': 'Sandia' } -def load_info(): + +def loadsim_info(): return sandia_info + def params(info, group_name=None): gname = lambda name: group_name + '.' + name pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name @@ -81,43 +83,43 @@ class LoadSim(loadsim.LoadSim): def __init__(self, ts, group_name): loadsim.LoadSim.__init__(self, ts, group_name) - def resistance(self, r=None): + def resistance(self, r=None, ph=None): if r is not None: self.ts.confirm('Adjust the resistive load to R = %0.3f Ohms.' % r) else: self.ts.log('Enter the resistive load in Ohms.') - def inductance(self, i=None): + def inductance(self, l=None, ph=None): if i is not None: - self.ts.confirm('Adjust the inductive load to L = %0.6f H.' % i) + self.ts.confirm('Adjust the inductive load to L = %0.6f H.' % l) else: self.ts.log('Enter the inductive load in H.') - def capacitance(self, c=None): + def capacitance(self, c=None, ph=None): if c is not None: self.ts.confirm('Adjust the capacitive load to C = %0.6f F.' % c) else: self.ts.log('Enter the capacitive load in F.') - def capacitor_q(self, q=None): + def capacitor_q(self, q=None, ph=None): if q is not None: self.ts.confirm('Adjust the capacitive load of the fundamental freq to %0.3f VAr.' % q) else: self.ts.log('Enter the capacitor reactive power in VAr.') - def inductor_q(self, q=None): + def inductor_q(self, q=None, ph=None): if q is not None: self.ts.confirm('Adjust the inductive load of the fundamental freq to %0.3f VAr.' % q) else: self.ts.log('Enter the inductor reactive power in VAr.') - def resistance_p(self, p=None): + def resistance_p(self, p=None, ph=None): if p is not None: self.ts.confirm('Adjust the resistive load of the fundamental freq to %0.3f W.' % p) else: self.ts.log('Enter the resistor power in W.') - def tune_current(self, i=None): + def tune_current(self, i=None, ph=None): if c is not None: self.ts.confirm('Adjust R, L, and C until the fundamental frequency current through switch S3 is ' 'less than %0.2f' % i) diff --git a/Lib/svpelab/loadsim_typhoon.py b/Lib/svpelab/loadsim_typhoon.py new file mode 100644 index 0000000..f64a808 --- /dev/null +++ b/Lib/svpelab/loadsim_typhoon.py @@ -0,0 +1,130 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import loadsim +try: + import typhoon.api.hil as cp # control panel + from typhoon.api.schematic_editor import model + import typhoon.api.pv_generator as pv +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) + +typhoon_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Typhoon' +} + + +def loadsim_info(): + return typhoon_info + + +def params(info, group_name=None): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = typhoon_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('component_name'), label='Component Name', default='Anti-islanding1') + # info.param(pname('property_name'), label='Property Name', default='resistance') + +GROUP_NAME = 'typhoon' + + +class LoadSim(loadsim.LoadSim): + """ + Template for RLC load implementations. This class can be used as a base class or + independent RLC load classes can be created containing the methods contained in this class. + """ + + def __init__(self, ts, group_name): + loadsim.LoadSim.__init__(self, ts, group_name) + self.component_name = self._param_value('component_name') + self.ts = ts + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def config(self): + pass + + def info(self): + return 'Typhoon Anti-islanding RLC Load - 1.0' + + def resistance(self, r=None, ph=None): + if r is not None: + # For setting particular property of the Anti-islanding component (resistor, capacitor and inductor values): + model.set_component_property(self.component_name, property="resistance", value=r) + # self.ts.log_debug('Resistor set to %s Ohms' % r) + else: + self.ts.log('No resistance provided.') + + def inductance(self, l=None, ph=None): + if l is not None: + # For setting particular property of the Anti-islanding component (resistor, capacitor and inductor values): + model.set_component_property(self.component_name, property="inductance", value=l) + else: + self.ts.log('No inductance provided.') + + def capacitance(self, c=None, ph=None): + if c is not None: + # For setting particular property of the Anti-islanding component (resistor, capacitor and inductor values): + model.set_component_property(self.component_name, property="capacitance", value=c) + else: + self.ts.log('No capacitance provided.') + + def capacitor_q(self, q=None, ph=None): + if q is not None: + self.ts.confirm('Adjust the capacitive load of the fundamental freq to %0.3f VAr.' % q) + else: + self.ts.log('Enter the capacitor reactive power in VAr.') + + def inductor_q(self, q=None, ph=None): + if q is not None: + self.ts.confirm('Adjust the inductive load of the fundamental freq to %0.3f VAr.' % q) + else: + self.ts.log('Enter the inductor reactive power in VAr.') + + def resistance_p(self, p=None, ph=None): + if p is not None: + self.ts.confirm('Adjust the resistive load of the fundamental freq to %0.3f W.' % p) + else: + self.ts.log('Enter the resistor power in W.') + + def tune_current(self, i=None, ph=None): + if i is not None: + self.ts.confirm('Adjust R, L, and C until the fundamental frequency current through switch S3 is ' + 'less than %0.2f' % i) + else: + pass diff --git a/Lib/svpelab/loadsimx_chroma.py b/Lib/svpelab/loadsimx_chroma.py index 92eca92..871b6fc 100644 --- a/Lib/svpelab/loadsimx_chroma.py +++ b/Lib/svpelab/loadsimx_chroma.py @@ -31,8 +31,8 @@ """ import os -import loadsim -import chroma_A800067 as chroma +from . import loadsim +from . import chroma_A800067 as chroma chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/net_pyshark.py b/Lib/svpelab/net_pyshark.py new file mode 100644 index 0000000..f25dd3c --- /dev/null +++ b/Lib/svpelab/net_pyshark.py @@ -0,0 +1,211 @@ +''' +pyshark network capture + +Initial design - 8/10/22 - jayatsandia +''' + +import os +import time +import asyncio + +try: + from . import network +except Exception as e: + print('Cannot find network.py in svpelab') +try: + import pyshark +except Exception as e: + print('Missing pyshark. Install with "pip install pyshark"') +try: + import pandas as pd +except Exception as e: + print('Missing pyshark. Install with "pip install pandas"') + + +manual_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'PyShark' +} + +def net_info(): + return manual_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = manual_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('interface'), label='Communications Interface', default='eth0') + info.param(pname('timeout'), label='Default Capture Duration', default=60.) + info.param(pname('bpf_filter'), label='Default BPF filter', default='tcp') + + +GROUP_NAME = 'pyshark' + +class NET(network.NET): + + def __init__(self, ts, group_name, support_interfaces=None): + network.NET.__init__(self, ts, group_name, support_interfaces) + self.interface = self._param_value('interface') + self.timeout = self._param_value('timeout') + self.bpf_filter = self._param_value('bpf_filter') + self.capture = None + self.packet_list = [] + + # save locally if not running with SVP GUI + if not hasattr(self, 'net_dir'): + self.net_dir = os.path.realpath(__file__) + print('net_dir = %s' % self.net_dir) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def info(self): + """ + Return information string for the NET device. + """ + return 'PyShark Network Capture' + + def pkt_callback(self, pkt): + self.packet_list.append(pkt) + try: + if 'ipv6' in [l.layer_name for l in pkt.layers]: + src_addr = pkt.ipv6.src + dst_addr = pkt.ipv6.dst + src_port = pkt.tcp.srcport + dst_port = pkt.tcp.dstport + size = int(pkt.ipv6.plen) + else: + src_addr = pkt.ip.src + dst_addr = pkt.ip.dst + src_port = pkt.tcp.srcport + dst_port = pkt.tcp.dstport + size = int(pkt.ip.get_field('Len')) + + timestamp = float(pkt.sniff_timestamp) - self.start_time + protocol = pkt.transport_layer # protocol type + + # self.ts.log("%0.4f %s:%s <-> %s:%s (%s)" % (timestamp, src_addr, src_port, dst_addr, dst_port, protocol)) + except AttributeError as e: + pass # ignore some packets + + + def net_capture(self, interface=None, timeout=None, bpf_filter=None, filename=None): + """ + Get data capture. + + :param interface: NIC interface, e.g., 'eth0' + :param timeout: duration of the capture, e.g., 60 seconds + :param filter: Berkeley packet filter, e.g., "tcp and port 80" + """ + if interface is None: + interface = self.interface + if timeout is None: + timeout = self.timeout + if bpf_filter is None: + bpf_filter = self.bpf_filter + if bpf_filter is None or bpf_filter == 'None': + bpf_filter = '' # None is an invalid filter + if bpf_filter is None: + bpf_filter = self.bpf_filter + if filename is None: + filename = time.asctime(time.localtime()).replace(':', '-').replace(' ', '_') + if filename[-5:] != '.pcap': + filename += '.pcap' + + output_file = os.path.join(self.net_dir, filename) + self.ts.log_debug('Interface = %s, Timeout = %s, Filter = %s, output_file = %s' % (interface, timeout, bpf_filter, output_file)) + + # Configure Capture + self.capture = pyshark.LiveCapture(interface=interface, bpf_filter=bpf_filter, output_file=output_file, custom_parameters='') + self.capture.set_debug() # printed to %USERPROFILE%\.sunspec\sunssvp_script.log + if self.ts is not None: + self.ts.log('Capturing network traffic for %0.2f seconds' % timeout) + else: + print('Capturing network traffic for %0.2f seconds' % timeout) + + print('Capture = %s' % self.capture) + # Begin Capture + self.start_time = time.time() + try: + print('Starting capture') + self.capture.apply_on_packets(self.pkt_callback, timeout=5) + # capture.sniff(timeout=5) # this wasn't working... + except asyncio.TimeoutError: + pass + + self.ts.log('Capture Complete with %s packets' % len(self.packet_list)) + + def print_capture(self, n_packets=None): + """ + Print n_packets from capture. + """ + if n_packets is None or len(self.packet_list) < n_packets: + n_packets = len(self.packet_list) + self.ts.log('Printing all %s packets from network capture.' % n_packets) + else: + self.ts.log('Printing first %s packets from network capture.' % n_packets) + + # attribute_list = [] + # for packet in self.packet_list[:n_packets]: + # try: + # packet_version = packet.layers[1].version + # layer_name = packet.layers[2].layer_name + # attribute_list.append([packet_version, layer_name, packet.length, str(packet.sniff_time)]) + # except AttributeError: + # pass + + # df = pd.DataFrame(attribute_list, columns=['packet version', 'layer type', 'length', 'capture time']) + # self.ts.log(df) + + count = 0 + for pkt in self.packet_list[:n_packets]: + count += 1 + try: + if 'ipv6' in [l.layer_name for l in pkt.layers]: + src_addr = pkt.ipv6.src + dst_addr = pkt.ipv6.dst + src_port = pkt.tcp.srcport + dst_port = pkt.tcp.dstport + size = int(pkt.ipv6.plen) + else: + src_addr = pkt.ip.src + dst_addr = pkt.ip.dst + src_port = pkt.tcp.srcport + dst_port = pkt.tcp.dstport + size = int(pkt.ip.get_field('Len')) + + timestamp = float(pkt.sniff_timestamp) - self.start_time + protocol = pkt.transport_layer # protocol type + + self.ts.log("%4d t=%0.4f %s:%s <-> %s:%s (%s)" % (count, timestamp, src_addr, src_port, + dst_addr, dst_port, protocol)) + except AttributeError as e: + pass # ignore some packets + except Exception as e: + self.ts.log_warning('Unable to print packet. %s' % e) + +if __name__ == "__main__": + + capture = pyshark.LiveCapture(interface='Local Area Connection', bpf_filter='', output_file='test.pcap') + capture.sniff(timeout=5) + + for packet in capture.sniff_continuously(packet_count=10): + try: + # get timestamp + localtime = time.asctime(time.localtime(time.time())) + + # get packet content + protocol = packet.transport_layer # protocol type + src_addr = packet.ip.src # source address + src_port = packet[protocol].srcport # source port + dst_addr = packet.ip.dst # destination address + dst_port = packet[protocol].dstport # destination port + + # output packet info + print ("%s IP %s:%s <-> %s:%s (%s)" % (localtime, src_addr, src_port, dst_addr, dst_port, protocol)) + except AttributeError as e: + # ignore packets other than TCP, UDP and IPv4 + pass \ No newline at end of file diff --git a/Lib/svpelab/net_scapy_for_wind_ecat.py b/Lib/svpelab/net_scapy_for_wind_ecat.py new file mode 100644 index 0000000..0f221ce --- /dev/null +++ b/Lib/svpelab/net_scapy_for_wind_ecat.py @@ -0,0 +1,565 @@ +''' +EtherCAT parser for SVP + +Initial implementation - jayatsandia - 12/13/2022 +''' + +import os +import time +import asyncio + +try: + from . import network +except Exception as e: + print('Cannot find network.py in svpelab') + +try: + from scapy.all import * + from scapy.contrib.ethercat import * +except Exception as e: + print('Missing scapy. Install with "pip install scapy"') + +try: + import pandas as pd +except Exception as e: + print('Missing pyshark. Install with "pip install pandas"') + +try: + from collections import Counter +except Exception as e: + print('Missing collections. Install with "pip install collections"') + +try: + import numpy as np +except Exception as e: + print('Missing numpy. Install with "pip install numpy"') + +# TEXT COLOR CODE TEXT STYLE CODE BACKGROUND COLOR CODE +# Black 30 No effect 0 Black 40 +# Red 31 Bold 1 Red 41 +# Green 32 Underline 2 Green 42 +# Yellow 33 Negative1 3 Yellow 43 +# Blue 34 Negative2 5 Blue 44 +# Purple 35 Purple 45 +# Cyan 36 Cyan 46 +# White 37 White 47 +# e.g., 'green_on_black' = '\033[1;32;40m Text', + +RESET = '\033[0m' +UNDERLINE = '\033[2m' +BOLD = '\033[1m' +RED = '\033[31m' +GREEN = '\033[32m' +BLUE = '\033[34m' +MAGENTA = '\033[35m' +CYAN = '\033[36m' +GREEN_ON_BLACK = '\033[1;32;40m' + +INBOUND_SRC = '02:80:2f:17:a7:79' +OUTBOUND_SRC = '00:80:2f:17:a7:79' + + +''' +Signal Description Direction ECAT Start ECAT End Byte Length Type Scale Units Bit +Controller has opened Safety Circuit when FALSE Outbound 26 26 1 Bool 7 +Commanding gear oil cooler to run when TRUE Outbound 26 26 1 Bool 6 +Commanding hydraulic pump to run when TRUE Outbound 26 26 1 Bool 4 +HSS brake off when TRUE Outbound 26 26 1 Bool 3 +Pitch proportional valve active when TRUE Outbound 26 26 1 Bool 2 +Commanding yaw counter-clockwise when TRUE Outbound 26 26 1 Bool 1 +Commanding yaw clockwise when TRUE Outbound 26 26 1 Bool 0 +Commanding generator fan to run when TRUE Outbound 27 27 1 Bool 1 +Commanding fine filter to run when TRUE Outbound 27 27 1 Bool 0 +Pitch velocity command voltage Outbound 30 31 2 INT 10.72 Volts +Heartbeat to VFD Outbound 122 122 1 Bool 3 +Reset controller watchdog Outbound 122 122 1 Bool 2 +Heartbeat to controller watchdog Outbound 122 122 1 Bool 1 +Remote switching of main contactor Outbound 122 122 1 Bool 0 +Command the VFD to change states Outbound 154 155 2 UINT 10.72 Volts +Command VFD to produce this amount of torque Outbound 158 159 2 INT 10.72 lb-ft? +Command VFD to produce this amount of Q Outbound 162 163 2 INT 10.72 Var? +''' + +# Name, ecat_start, ecat_stop, length, type, scaling/bit, offset (INT/UINT) +OUTBOUND = [ +['Controller has opened Safety Circuit when FALSE', 26, 26, 1, 'Bool', 7], +['Commanding gear oil cooler to run when TRUE', 26, 26, 1, 'Bool', 6], +['Commanding hydraulic pump to run when TRUE', 26, 26, 1, 'Bool', 4], +['HSS brake off when TRUE', 26, 26, 1, 'Bool', 3], +['Pitch proportional valve active when TRUE', 26, 26, 1, 'Bool', 2], +['Commanding yaw counter-clockwise when TRUE', 26, 26, 1, 'Bool', 1], +['Commanding yaw clockwise when TRUE', 26, 26, 1, 'Bool', 0], +['Commanding generator fan to run when TRUE', 27, 27, 1, 'Bool', 1], +['Commanding fine filter to run when TRUE', 27, 27, 1, 'Bool', 0], +['Pitch velocity command voltage', 30, 31, 2, 'INT', 10.72, 0], +['Heartbeat to VFD', 122, 122, 1, 'Bool', 3], +['Reset controller watchdog', 122, 122, 1, 'Bool', 2], +['Heartbeat to controller watchdog', 122, 122, 1, 'Bool', 1], +['Remote switching of main contactor', 122, 122, 1, 'Bool', 0], +['Command the VFD to change states', 154, 155, 2, 'UINT', 10.72, 0], +['Command VFD to produce this amount of torque', 158, 159, 2, 'INT', 10.72, 0], +['Command VFD to produce this amount of Q', 162, 163, 2, 'INT', 10.72, 0], +] + +''' +Signal Description Direction ECAT Start ECAT End Byte Length Type Scale Units Bit +Gearbox HSS non-drive-end bearing temperature Inbound 26 28 3 INT 10.72 Deg C +Gearbox HSS drive-end bearing temperature Inbound 29 31 3 INT 10.72 Deg C +Generator drive-end bearing temperature Inbound 32 32 1 INT 10.72 Deg C +Generator non-drive-end bearing temperature Inbound 35 37 3 INT 10.72 Deg C +Hydraulic fluid temperature Inbound 42 44 3 INT 10.72 Deg C +Ambient air temperature Inbound 45 47 3 INT 10.72 Deg C +Gearbox oil temperature Inbound 48 50 3 INT 10.72 Deg C +Nacelle air temperature Inbound 51 53 3 INT 10.72 Deg C +Hydraulic system pressure, voltage positive Inbound 54 55 2 INT 10.72 Volts +Blade pitch linear position sensor, voltage + Inbound 56 57 2 INT 10.72 Volts +Hydraulic system pressure, voltage common Inbound 70 71 2 INT 10.72 Volts +Blade pitch lin. position sensor, voltage comm Inbound 72 73 2 INT 10.72 Volts +Current overload on yaw motor 2 when FALSE Inbound 118 118 1 Bool 7 +Current overload on yaw motor 1 when FALSE Inbound 118 118 1 Bool 6 +Yaw twist pulse Inbound 118 118 1 Bool 5 +Yaw twist nominal range Inbound 118 118 1 Bool 4 +Yaw twist counter-clockwise Inbound 118 118 1 Bool 3 +Yaw twist clockwise Inbound 118 118 1 Bool 2 +Yawing counter-clockwise when TRUE Inbound 118 118 1 Bool 1 +Yawing clockwise when TRUE Inbound 118 118 1 Bool 0 +Safety Circuit is open when FALSE Inbound 119 119 1 Bool 7 +Radial vibration (knock) sensor trig. if FALSE Inbound 119 119 1 Bool 5 +Current overload on hydraulic pump motor if FALSE Inbound 119 119 1 Bool 4 +Brakedisk temp opened Safety Circuit if FALSE Inbound 119 119 1 Bool 3 +Brake pressure is below safety threshold if FALSE Inbound 119 119 1 Bool 2 +Hydraulic filter may be clogged if FALSE Inbound 119 119 1 Bool 1 +Hydraulic pump running when TRUE Inbound 119 119 1 Bool 0 +Gearbox oil cooler running when TRUE Inbound 120 120 1 Bool 7 +Current overload on generator fan motor when FALSE Inbound 120 120 1 Bool 6 +Generator fan running when TRUE Inbound 120 120 1 Bool 5 +Current overload on fine filter pump motor if FALSE Inbound 120 120 1 Bool 4 +Fine filter running when TRUE Inbound 120 120 1 Bool 3 +Current overload on gearbox oil cooler when FALSE Inbound 120 120 1 Bool 1 +Hydraulic fluid level is low when FALSE Inbound 120 120 1 Bool 0 +"rotor" speed sensor is >~22 rpm when FALSE Inbound 121 121 1 Bool 7 +"shaft" speed sensor is >~22 rpm when FALSE Inbound 121 121 1 Bool 6 +Malfunction in rotor overspeed protection if FALSE Inbound 121 121 1 Bool 5 +Stop Circuit is open when FALSE Inbound 121 121 1 Bool 4 +UPS is on battery when FALSE Inbound 121 121 1 Bool 3 +Malfunction in shaft overspeed protection if FALSE Inbound 121 121 1 Bool 2 +Overspeed detected on "shaft" speed sensor if FALSE Inbound 121 121 1 Bool 1 +Three-axis vibration opened Stop Circuit if FALSE Inbound 121 121 1 Bool 0 +UPS is on battery when FALSE Inbound 122 122 1 Bool 7 +UPS is on battery when FALSE Inbound 122 122 1 Bool 6 +Grid monitor has opended Safety Circuit when FALSE Inbound 122 122 1 Bool 5 +Grid monitor has opended Safety Circuit when FALSE Inbound 122 122 1 Bool 4 +Controller watchdog opened Safety Circuit if FALSE Inbound 122 122 1 Bool 3 +Main contactor Q8 Is closed when TRUE Inbound 122 122 1 Bool 0 +Brake chopper is actively consuming power when TRUE Inbound 123 123 1 Bool 1 +Brake chopper controller not ready when FALSE Inbound 123 123 1 Bool 0 +Indicates the converter's operating state Inbound 154 155 2 UINT 10.72 state +Estimated generator speed Inbound 156 157 2 INT 10.72 rpm +Read-only version of ControlWord actually received Inbound 158 159 2 UINT 10.72 ControlWord +Generator torque Inbound 160 161 2 INT 10.72 lb-ft +Generator power Inbound 162 163 2 INT 10.72 W +Generator frequency Inbound 164 165 2 INT 10.72 Hz +Power converter dc bus voltage Inbound 166 167 2 INT 10.72 Volts +Power converter temperature Inbound 168 169 2 INT 10.72 Deg C +Power converter alarm code Inbound 170 171 2 UINT +Power converter fault code Inbound 172 173 2 UINT +Line converter unit actual signal 1 Inbound 174 175 2 INT 10.72 Deg C +Line converter unit actual signal 2 Inbound 176 177 2 INT 10.72 Deg C +''' + +# Name, ecat_start, ecat_stop, length, type, scaling/bit offset +INBOUND = [ +['Gearbox HSS non-drive-end bearing temperature', 26, 28, 3, 'INT', 1278, 255], +['Gearbox HSS drive-end bearing temperature', 29, 31, 3, 'INT', 1278, 255], +['Generator drive-end bearing temperature', 32, 34, 3, 'INT', 1278, 255], +['Generator non-drive-end bearing temperature', 35, 37, 3, 'INT', 1278, 255], +['Hydraulic fluid temperature', 42, 44, 3, 'INT', 1278, 255], +['Ambient air temperature', 45, 47, 3, 'INT', 1278, 255], +['Gearbox oil temperature', 48, 50, 3, 'INT', 1278, 255], +['Nacelle air temperature', 51, 53, 3, 'INT', 1278, 255], +['Hydraulic system pressure, voltage positive', 54, 55, 2, 'INT', 10.72, 0], +['Blade pitch linear position sensor, voltage pos', 56, 57, 2, 'INT', 10.72, 0], +['Hydraulic system pressure, voltage common', 70, 71, 2, 'INT', 10.72, 0], +['Blade pitch lin. position sensor, voltage comm', 72, 73, 2, 'INT', 10.72, 0], +['Current overload on yaw motor 2 when FALSE', 118, 118, 1, 'Bool', 7], +['Current overload on yaw motor 1 when FALSE', 118, 118, 1, 'Bool', 6], +['Yaw twist pulse', 118, 118, 1, 'Bool', 5], +['Yaw twist nominal range', 118, 118, 1, 'Bool', 4], +['Yaw twist counter-clockwise', 118, 118, 1, 'Bool', 3], +['Yaw twist clockwise ', 118, 118, 1, 'Bool', 2], +['Yawing counter-clockwise when TRUE', 118, 118, 1, 'Bool', 1], +['Yawing clockwise when TRUE', 118, 118, 1, 'Bool', 0], +['Safety Circuit is open when FALSE', 119, 119, 1, 'Bool', 7], +['Radial vibration (knock) sensor trig. if FALSE', 119, 119, 1, 'Bool', 5], +['Current overload on hydraulic pump motor if FALSE', 119, 119, 1, 'Bool', 4], +['Brakedisk temp opened Safety Circuit if FALSE', 119, 119, 1, 'Bool', 3], +['Brake pressure is below safety threshold if FALSE', 119, 119, 1, 'Bool', 2], +['Hydraulic filter may be clogged if FALSE', 119, 119, 1, 'Bool', 1], +['Hydraulic pump running when TRUE', 119, 119, 1, 'Bool', 0], +['Gearbox oil cooler running when TRUE', 120, 120, 1, 'Bool', 7], +['Current overload on generator fan motor when FALSE', 120, 120, 1, 'Bool', 6], +['Generator fan running when TRUE', 120, 120, 1, 'Bool', 5], +['Current overload on fine filter pump motor if FALSE', 120, 120, 1, 'Bool', 4], +['Fine filter running when TRUE', 120, 120, 1, 'Bool', 3], +['Current overload on gearbox oil cooler when FALSE', 120, 120, 1, 'Bool', 1], +['Hydraulic fluid level is low when FALSE', 120, 120, 1, 'Bool', 0], +['"rotor" speed sensor is >~22 rpm when FALSE', 121, 121, 1, 'Bool', 7], +['"shaft" speed sensor is >~22 rpm when FALSE', 121, 121, 1, 'Bool', 6], +['Malfunction in rotor overspeed protection if FALSE', 121, 121, 1, 'Bool', 5], +['Stop Circuit is open when FALSE', 121, 121, 1, 'Bool', 4], +['UPS is on battery when FALSE', 121, 121, 1, 'Bool', 3], +['Malfunction in shaft overspeed protection if FALSE', 121, 121, 1, 'Bool', 2], +['Overspeed detected on "shaft" speed sensor if FALSE', 121, 121, 1, 'Bool', 1], +['Three-axis vibration opened Stop Circuit if FALSE', 121, 121, 1, 'Bool', 0], +['UPS is on battery when FALSE', 122, 122, 1, 'Bool', 7], +['UPS is on battery when FALSE', 122, 122, 1, 'Bool', 6], +['Grid monitor has opended Safety Circuit when FALSE', 122, 122, 1, 'Bool', 5], +['Grid monitor has opended Safety Circuit when FALSE', 122, 122, 1, 'Bool', 4], +['Controller watchdog opened Safety Circuit if FALSE', 122, 122, 1, 'Bool', 3], +['Main contactor Q8 Is closed when TRUE', 122, 122, 1, 'Bool', 0], +['Brake chopper is actively consuming power when TRUE', 123, 123, 1, 'Bool', 1], +['Brake chopper controller not ready when FALSE', 123, 123, 1, 'Bool', 0], +['Indicates the converter\'s operating state', 154, 155, 2, 'UINT', 10.72, 0], +['Estimated generator speed', 156, 157, 2, 'INT', 10.72, 0], +['Read-only version of ControlWord actually received', 158, 159, 2, 'UINT', 10.72, 0], +['Generator torque', 160, 161, 2, 'INT', 10.72, 0], +['Generator power', 162, 163, 2, 'INT', 10.72, 0], +['Generator frequency', 164, 165, 2, 'INT', 10.72, 0], +['Power converter dc bus voltage', 166, 167, 2, 'INT', 10.72, 0], +['Power converter temperature', 168, 169, 2, 'INT', 10.72, 0], +['Power converter alarm code', 170, 171, 2, 'UINT', 10.72, 0], +['Power converter fault code', 172, 173, 2, 'UINT', 10.72, 0], +['Line converter unit actual signal 1', 174, 175, 2, 'INT', 10.72, 0], +['Line converter unit actual signal 2', 176, 177, 2, 'INT', 10.72, 0], +] + + +manual_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Scapy for EtherCAT' +} + +def net_info(): + return manual_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = manual_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('interface'), label='Communications Interface', default='eth0') + info.param(pname('timeout'), label='Default Capture Duration', default=60.) + info.param(pname('bpf_filter'), label='Default BPF filter', default='tcp') + +def twos_comp(int_val, bits): + if (int_val & (1 << (bits-1))) != 0: # check sign bit + int_val = int_val - (1 << bits) # neg value + return int_val + +GROUP_NAME = 'scapy_for_ecat' + +class NET(network.NET): + + def __init__(self, ts, group_name, support_interfaces=None): + network.NET.__init__(self, ts, group_name, support_interfaces) + self.ts = ts + self.iface = self._param_value('interface') + self.timeout = self._param_value('timeout') + self.bpf_filter = self._param_value('bpf_filter') + self.mode = "online" + + # save locally if not running with SVP GUI + if not hasattr(self, 'net_dir'): + self.net_dir = os.path.realpath(__file__) + print('net_dir = %s' % self.net_dir) + + # Initialize traffic packets and inbound/outbound objects + self.packets = [] + # self.get_packets() # collect online packets + # self.inbound_packets = [packet for packet in self.packets if packet.src==INBOUND_SRC] + # self.outbound_packets = [packet for packet in self.packets if packet.src==OUTBOUND_SRC] + + # Initialize signal data + self.signal_data = {} + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def info(self): + """ + Return information string for the NET device. + """ + return 'Scapy EtherCAT Network Capture for Vestas V27 Wind Turbines with Sandia NI Controller' + + def print_signals(self): + """ + Print the key value pairs for self.signal_data + """ + + for k, v in self.signal_data.items(): + if self.ts is not None: + self.ts.log('%s: %s' % (k, v)) + else: + print('%s: %s' % (k, v)) + + def get_packets(self, count=4): + """ + Get packets from PCAP file or live from iface interface + + :param iface: interface name for online capture + :param count: number of packets to collect + :return packets: scapy packet data + """ + + if self.mode == "offline": + print('Loading pcap = %s' % self.pcap_file) + self.packets = rdpcap(self.pcap_file, count=count) + print('pcap loaded. packets = %s' % len(packets)) + + else: # online + # Can add prn=self.packet_summary if live debugging is necessary + self.packets = sniff(iface=self.iface, count=count, filter=self.bpf_filter, timeout=self.timeout) + # self.print_capture() + + def print_capture(self): + """ + Save pcap to net_dir directory with timestamp + """ + + self.ts.log(self.packets) + for pkt in self.packets: + try: + if IP in pkt: + ip_src=pkt[IP].src + ip_dst=pkt[IP].dst + + if TCP in pkt: + tcp_sport=pkt[TCP].sport + tcp_dport=pkt[TCP].dport + + self.ts.log(str(ip_src) + ":" + str(tcp_sport) + ' -> ' + str(ip_dst) + ":" + str(tcp_dport)) + else: + self.ts.log(str(ip_src) + ' -> ' + str(ip_dst)) + else: + # self.ts.log_debug('No IP in packet') + if EtherCatLRW in pkt[Ether][EtherCat]: + # self.ts.log_debug(pkt[Ether][EtherCat][EtherCatLRW].data) + self.ts.log_debug('EtherCatLRW with lenth: %s' % len(pkt[Ether][EtherCat][EtherCatLRW].data)) + elif EtherCatBWR in pkt[Ether][EtherCat]: + self.ts.log_debug('EtherCatBWR with lenth: %s' % len(pkt[Ether][EtherCat][EtherCatBWR].data)) + elif EtherCatFPRD in pkt[Ether][EtherCat]: + self.ts.log_debug('EtherCatFPRD with lenth: %s' % len(pkt[Ether][EtherCat][EtherCatFPRD].data)) + else: + self.ts.log_debug('EtherCat with lenth: %s' % len(pkt[Ether][EtherCat].data)) + + except Exception as e: + self.ts.log_warning('Error printing packet information: %s' % e) + + #self.ts.log('%s' % self.packets.summary()) + + def save_packets(self): + """ + Save pcap to net_dir directory with timestamp + """ + + path = os.path.join(self.net_dir, str(time.time()) + '.pcap') + if self.ts: + self.ts.log('Saving pcap data to %s' % path) + else: + print('Saving pcap data to path = %s' % path) + wrpcap(path, self.packets) + + def filter_packets(self): + """ + Apply filter on the captured packets + """ + pass + + def get_signal_data(self): + """ + Populate dict with values based on pcap data and scaling factors + + :return signal_data: dict, with name:value for each signal + """ + + for p in self.packets: + # self.ts.log_debug('Raw packet = %s' % raw(p)) + + if EtherCat in p[Ether]: + if EtherCatLRW in p[Ether][EtherCat]: + # self.ts.log_debug(p[Ether][EtherCat][EtherCatLRW].data) + # self.ts.log_debug('lenth: %s' % len(raw(p[Ether]))) + # self.ts.log_debug('lenth: %s' % len(raw(p[Ether][EtherCat]))) + # self.ts.log_debug('lenth: %s' % len(p[Ether][EtherCat][EtherCatLRW].data)) + + hex_str = '' + for pkt_data in p[Ether][EtherCat][EtherCatLRW].data: + hex_str += "{:02x}".format(pkt_data) + + # self.ts.log_debug('hex: %s' % hex_str) + # self.ts.log_debug('hex len: %s' % len(hex_str)) + + if p.src == OUTBOUND_SRC: + params = OUTBOUND + # self.ts.log('Packet is Outbound') + else: + params = INBOUND + # self.ts.log('Packet is Inbound') + self.ts.log_debug('hex: %s' % hex_str[0:50]) + + self.signal_data = {} + for i in range(len(params)): + bool_byte = 0 + header_length = 26 # bytes - this is added to bytes in lists above (subtract to get data index) + start = (params[i][1] - header_length) * 2 # in nibbles because this is the hex string + end = (params[i][2] - header_length) * 2 + 2 # in nibbles because this is the hex string + bit_or_scaling = params[i][5] + + hex_slice = hex_str[start:end] + + # if params[i][0] == 'Blade pitch linear position sensor, voltage pos': + # self.ts.log('hex data: %s' % hex_slice) + # self.ts.log(hex_str[38-26:41-26+1]) + # self.ts.log(hex_str[24:32]) + # if params[i][0] == 'Gearbox HSS non-drive-end bearing temperature': + # self.ts.log('hex data: %s' % hex_slice) + # little_endian = '%s%s%s' % (hex_slice[4:6], hex_slice[2:4], hex_slice[0:2]) + # self.ts.log('Reordered: %s' % (little_endian)) + # unscaled = int(little_endian, 16) + # self.ts.log('Unscaled: %s' % unscaled) + # scaled = params[i][4] * (twos_comp(unscaled, params[i][3]) / 2**((params[i][3]*8)-1) - params[i][5] + # self.ts.log('Scaled: %s' % scaled) + + if params[i][4] == 'INT': + offset = params[i][6] + if params[i][3] == 1: + unscaled = twos_comp(int(hex_slice, 16), 8) # 2's compliment + elif params[i][3] == 2: + little_endian = '%s%s' % (hex_slice[2:4], hex_slice[0:2]) # little endian conversion + unscaled = twos_comp(int(little_endian, 16), 16) # 2's compliment + else: # params[i][3] == 3: + little_endian = '%s%s%s' % (hex_slice[4:6], hex_slice[2:4], hex_slice[0:2]) + unscaled = twos_comp(int(little_endian, 16), 24) # 2's compliment + conversion = 2**((params[i][2] * 8) - 1) # to put on a [-1, 1] range + self.signal_data[params[i][0]] = (bit_or_scaling * (unscaled / conversion)) + offset + # self.ts.log_debug('Found %s [INT]: %s' % (params[i][0], self.signal_data[params[i][0]])) + + if params[i][4] == 'UINT': + offset = params[i][6] + if params[i][3] == 1: + unscaled = int(hex_slice, 16) + elif params[i][3] == 2: + little_endian = '%s%s' % (hex_slice[2:4], hex_slice[0:2]) # little endian conversion + unscaled = int(little_endian, 16) + else: # params[i][3] == 3: + little_endian = '%s%s%s' % (hex_slice[4:6], hex_slice[2:4], hex_slice[0:2]) # little endian conversion + unscaled = int(little_endian, 16) + conversion = 2**(params[i][2] * 8) # to put on a [0, 1] range + self.signal_data[params[i][0]] = (bit_or_scaling * (unscaled / conversion)) + offset + # self.ts.log_debug('Found %s [UINT]: %s' % (params[i][0], self.signal_data[params[i][0]])) + + if params[i][4] == 'Bool': + val = int(hex_slice, 16) + + if val & (1 << bit_or_scaling) == (1 << bit_or_scaling): + self.signal_data[params[i][0]] = 'True' + else: + self.signal_data[params[i][0]] = 'False' + # self.ts.log_debug('Found %s [Bool]: %s' % (params[i][0], self.signal_data[params[i][0]])) + else: + # self.ts.log_warning('Packet did not contain LWR (Logical WRite)') + pass + else: + # self.ts.log_warning('Packet did not contain EtherCAT') + pass + + return self.signal_data + + ''' + Baseline functions were initially used to compare EtherCAT hex packet data to a baseline + ''' + def get_baselines(self): + """ + UNUSED - Collect the outbound and inbound baseline hex strings + + :return: outbound hex string, inbound hex string + """ + outbound = None + inbound = None + for p in self.packets: + if EtherCatLRW in p[Ether][EtherCat]: + hex_str = '' + for pkt_data in p[Ether][EtherCat][EtherCatLRW].data: + hex_str += hex(pkt_data)[2:] + + if outbound is None: + if p.src == OUTBOUND_SRC: + outbound = hex_str + + if inbound is None: + if p.src == INBOUND_SRC: + inbound = hex_str + + if inbound is not None and outbound is not None: + return outbound, inbound + + def compare_to_baseline(): + """ + UNUSED - Color the hex data based on differences from baseline dataset + """ + + for p in self.packets: + # print('Raw packet = %s' % raw(p)) + + if EtherCatLRW in p[Ether][EtherCat]: + + color_hex_str = '' + if p.src == OUTBOUND_SRC: + if len(self.outbound) == len(hex_str): + for i in range(len(self.outbound)): + if self.outbound[i] != hex_str[i]: + color_hex_str += self.color_blue(hex_str[i]) + else: + color_hex_str += hex_str[i] + else: + print('New outbount length. Baseline = %s, packet = %s' % (len(self.outbound),len(hex_str))) + print(hex_str) + + elif p.src == INBOUND_SRC: + # print(len(self.inbound), len(hex_str)) + if len(self.inbound) == len(hex_str): + for i in range(len(self.inbound)): + if self.inbound[i] != hex_str[i]: + color_hex_str += self.color_red(hex_str[i]) + else: + color_hex_str += hex_str[i] + else: + print('New inbound length. Baseline = %s, packet = %s' % (len(self.inbound),len(hex_str))) + print(hex_str) + + print(color_hex_str) + + def packet_summary(self, pkt): + """ + scapy print function during the sniff + """ + if IP in pkt: + ip_src=pkt[IP].src + ip_dst=pkt[IP].dst + if TCP in pkt: + tcp_sport=pkt[TCP].sport + tcp_dport=pkt[TCP].dport + + self.ts.log(str(ip_src) + ":" + str(tcp_sport) + ' -> ' + str(ip_dst) + ":" + str(tcp_dport)) + + def color_red(self, msg=None): + return RED + msg + RESET + + def color_blue(self, msg=None): + return BLUE + msg + RESET + +if __name__ == "__main__": + pass + diff --git a/Lib/svpelab/network.py b/Lib/svpelab/network.py new file mode 100644 index 0000000..f79c896 --- /dev/null +++ b/Lib/svpelab/network.py @@ -0,0 +1,171 @@ + +import sys +import os +import glob +import importlib + + +''' +The network module is designed to capture network traffic + +Initial design - 8/10/22 - jayatsandia +''' + +NET_modules = {} + +def params(info, id=None, label='Network Capture System', group_name=None, active=None, active_value=None): + if group_name is None: + group_name = NET_DEFAULT_ID + else: + group_name += '.' + NET_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + name = lambda name: group_name + '.' + name + info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) + info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) + for mode, m in NET_modules.items(): + m.params(info, group_name=group_name) + +NET_DEFAULT_ID = 'NET' + + +def net_init(ts, id=None, group_name=None, support_interfaces=None): + """ + Function to create specific NET implementation instances. + """ + if group_name is None: + group_name = NET_DEFAULT_ID + else: + group_name += '.' + NET_DEFAULT_ID + if id is not None: + group_name = group_name + '_' + str(id) + mode = ts.param_value(group_name + '.' + 'mode') + sim = None + if mode != 'Disabled': + sim_module = NET_modules.get(mode) + print(sim_module) + if sim_module is not None: + sim = sim_module.NET(ts, group_name, support_interfaces=support_interfaces) + else: + raise NETError('Unknown network acquisition system mode: %s' % mode) + + return sim + + +class NETError(Exception): + """ + Exception to wrap all NET generated exceptions. + """ + pass + + +class NET(object): + + def __init__(self, ts, group_name, support_interfaces=None): + """ + Initialize the NET object with the following parameters + + :param ts: test script with logging capability + :param group_name: name used when there are multiple instances + :param support_interfaces: dictionary with keys 'pvsim', 'gridsim', 'hil', etc. + """ + self.ts = ts + self.group_name = group_name + self.capture = None + + # Probably will never be used, but you never know if there are is a need + # to have access to the pvsim, gridsim, or hil in the network capture module + self.hil = None + if support_interfaces is not None: + if support_interfaces.get('hil') is not None: + self.hil = support_interfaces.get('hil') + + self.gridsim = None + if support_interfaces is not None: + if support_interfaces.get('gridsim') is not None: + self.gridsim = support_interfaces.get('gridsim') + + self.pvsim = None + if support_interfaces is not None: + if support_interfaces.get('pvsim') is not None: + self.pvsim = support_interfaces.get('pvsim') + + # determine SVP Result directory path + self.net_dir = os.path.join(self.ts._results_dir, self.ts._result_dir) + # self.ts.log_debug('Network data will be saved to %s' % self.net_dir) + + def info(self): + """ + Return information string for the NET device. + """ + if self.device is None: + raise NETError('NET device not initialized') + return self.device.info() + + def get_packets(self, iface=None, timeout=None, bpf_filter=None, filename=None, count=None): + """ + Start data capture and collect packets. + + :param interface: NIC interface, e.g., 'eth0' + :param timeout: duration of the capture, e.g., 60 seconds + :param bpf_filter: Berkeley packet filter, e.g., "tcp and port 80" + :param filename: name of the file to save to the manifest + :param count: number of packets to capture + """ + pass + + def print_capture(self, n_packets=None): + pass + + def get_signal_data(self): + """ + Convert packets into dict of information for the given application. + + :return: dict with signal data + """ + return {} + + def save_packets(self): + """ + Save pcap to net_dir directory + """ + pass + + def filter_packets(self): + """ + Apply filter on the captured packets + """ + pass + +def net_scan(): + global NET_modules + # scan all files in current directory that match net_*.py + package_name = '.'.join(__name__.split('.')[:-1]) + files = glob.glob(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'net_*.py')) + for f in files: + module_name = None + try: + module_name = os.path.splitext(os.path.basename(f))[0] + # print(module_name) + if package_name: + module_name = package_name + '.' + module_name + m = importlib.import_module(module_name) + if hasattr(m, 'net_info'): + info = m.net_info() + mode = info.get('mode') + # place module in module dict + if mode is not None: + NET_modules[mode] = m + else: + if module_name is not None and module_name in sys.modules: + del sys.modules[module_name] + except Exception as e: + if module_name is not None and module_name in sys.modules: + del sys.modules[module_name] + print(NETError('Error scanning module %s: %s' % (module_name, str(e)))) + +# scan for NET modules on import +net_scan() + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/pv_curve_generation.py b/Lib/svpelab/pv_curve_generation.py new file mode 100644 index 0000000..0badc06 --- /dev/null +++ b/Lib/svpelab/pv_curve_generation.py @@ -0,0 +1,201 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import math +import numpy as np + +# T: absolute ambient temperature [K] +# Tmod: module temperature [K] +# G: irradiance [W/m2] +# TPV: computed PV generator temperature; +# Tamb: ambient temperature; +# T0: correction temperature (T0 = -3 deg C); +# k: irradiance gain (k = 0,03 km2/W); +# tau: time constant (? = 5 min); +# alpha: temperature coefficient of the current; +# beta: temperature coefficient of the voltage; +# CR, CV, CG: technology depending correction factor + +class PVCurveError(Exception): + pass + + +class PVCurve(object): + + def __init__(self, tech='cSi', Pmpp=3000., Vmpp=460., Tpv=25., n_points=1000, v_max=600.): + """ + Create an I-V curve of n_points number of points based on a simple model from EN 50530 + :param tech: type of module technology - crystalline silicon or thin film + :param Pmpp: power at the maximum power point (W), at STC + :param Vmpp: voltage at the maximum power point (V), at STC + :param Tpv: PV temperature (deg C) + :param n_points: number of (I, V) points in the curve + :param v_max: maximum voltage of the I-V curve points + :return: dictionary with i and v lists + """ + + if tech == 'cSi': + self.FFU = 0.8 + self.FFI = 0.9 + self.CG = 2.514E-03 # W/m2 + self.CV = 8.593E-02 + self.CR = 1.088E-04 # m2/W + self.vL2H = 0.95 # ratio from VMPP at an irradiance of 200 W/m2 to VMPP at an irradiance of 1000 W/m2 + self.alpha = 0.0004 # 1/K (converted from %/K) + self.beta = -0.004 # 1/K (converted from %/K) + elif tech == 'thin film': + self.FFU = 0.72 + self.FFI = 0.8 + self.CG = 1.252E-03 # W/m2 + self.CV = 8.419E-02 + self.CR = 1.476E-04 # m2/W + self.vL2H = 0.98 # ratio from VMPP at an irradiance of 200 W/m2 to VMPP at an irradiance of 1000 W/m2 + self.alpha = 0.0002 # 1/K (converted from %/K) + self.beta = -0.002 # 1/K (converted from %/K) + else: + raise PVCurveError('Incorrect PV Module Technology') + + self.G = 1000. # initial irradiance. + self.Gstc = 1000. + + # Temperature of the PV (dynamic) + # Tpv = Tamb + T0 + (k*G)/(1 + tau*s) + + self.Tpv = Tpv + self.Tstc = 25. + + # STC values + self.Voc_stc = Vmpp/self.FFU + self.Impp_stc = Pmpp/Vmpp + self.Isc_stc = self.Impp_stc/self.FFI + + # Calculate CAQ constant + self.CAQ = (self.FFU-1)/(math.log(1-self.FFI)) + + self.v_points = list(np.linspace(0, v_max, n_points)) + self.i_points = [] + self.p_points = [] + self.Io = 0. + self.Isc = 0. + self.Voc = 0. + self.curve = {} + self.calc_curve() + + self.p_mpp_index = np.argmax(self.curve['p']) + self.p_mpp = self.curve['p'][self.p_mpp_index] + self.v_mpp = self.curve['v'][self.p_mpp_index] + self.i_mpp = self.curve['i'][self.p_mpp_index] + + def calc_curve(self): + """ + calculates new I-V curve based on updates to self.G and self.Tpv + """ + if self.G > 0: + self.Io = self.Isc_stc*((1 - self.FFI)**(1/(1-self.FFU)))*(self.G/self.Gstc) # Irradiance dependent current + self.Isc = self.Isc_stc*(self.G/self.Gstc)*(1 + self.alpha*(self.Tpv-self.Tstc)) + self.Voc = self.Voc_stc*(1 + self.beta*(self.Tpv-self.Tstc)) * \ + (math.log((self.G/self.CG) + 1.)*self.CV - self.CR*self.G) + else: + self.Io = 0. + self.Isc = 0. + self.Voc = self.Voc_stc*(1 + self.beta*(self.Tpv-self.Tstc)) * \ + (math.log((self.G/self.CG) + 1.)*self.CV - self.CR*self.G) + + # Generate I-V curve points + self.i_points = [] + self.p_points = [] + for v in self.v_points: + if self.G > 0: + current_pt = self.Isc - self.Io*(math.exp(v/(self.Voc*self.CAQ))-1.) + else: + current_pt = 0 + i_pt = max(current_pt, 0.) # disallow negative current points + self.i_points.append(i_pt) + self.p_points.append(v*i_pt) + + # create curve dict + self.curve = {'v': self.v_points, 'i': self.i_points, 'p': self.p_points} + + self.p_mpp_index = np.argmax(self.curve['p']) + self.p_mpp = self.curve['p'][self.p_mpp_index] + self.v_mpp = self.curve['v'][self.p_mpp_index] + self.i_mpp = self.curve['i'][self.p_mpp_index] + + def get_voc(self): + return self.Voc + + def get_isc(self): + return self.Isc + + def get_curve(self): + return self.curve + + def irradiance(self, irradiance): + if irradiance is not None: + if irradiance > 0: + self.G = irradiance + self.calc_curve() + else: + self.G = 0 + self.calc_curve() + return self.G + + def temperature(self, temp): + if temp is not None: + self.Tpv = temp + self.calc_curve() + return self.Tpv + + +if __name__ == "__main__": + + import matplotlib.pyplot as plt + + iv = PVCurve(tech='cSi', Pmpp=3000, Vmpp=450, n_points=1000) + + # plt.plot(iv.curve['v'], iv.curve['p'], label='1000 W/m^2') + # plt.show() + + irradiance_list = [1000, 900, 700, 500, 300, 100, 1000, 1000, -5, 0] + temperature_list = [25, 25, 25, 25, 25, 25, 15, 50, 25, 25] + fig, ax = plt.subplots() + for i in range(len(irradiance_list)): + iv.irradiance(irradiance_list[i]) + iv.temperature(temperature_list[i]) + ax.plot(iv.curve['v'], iv.curve['i'], label='%0.1f W/m^2, T=%0.2f' % (irradiance_list[i], temperature_list[i])) + print('%0.1f W/m^2, T=%0.2f, Pmp=%0.1f, Vmp=%0.1f, Imp=%0.1f, ' + % (irradiance_list[i], temperature_list[i],iv.p_mpp, iv.v_mpp, iv.i_mpp)) + ax.legend(loc='lower left') + plt.show() + + + diff --git a/Lib/svpelab/pvsim.py b/Lib/svpelab/pvsim.py index 38125f0..71ea0c4 100644 --- a/Lib/svpelab/pvsim.py +++ b/Lib/svpelab/pvsim.py @@ -47,12 +47,12 @@ def params(info, id=None, label='PV Simulator', group_name=None, active=None, ac name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) - for mode, m in pvsim_modules.iteritems(): + for mode, m in pvsim_modules.items(): m.params(info, group_name=group_name) PVSIM_DEFAULT_ID = 'pvsim' -def pvsim_init(ts, id=None, group_name=None): +def pvsim_init(ts, id=None, group_name=None,support_interfaces=None): """ Function to create specific pv simulator implementation instances. @@ -71,7 +71,7 @@ def pvsim_init(ts, id=None, group_name=None): if mode != 'Disabled': sim_module = pvsim_modules.get(mode) if sim_module is not None: - sim = sim_module.PVSim(ts, group_name) + sim = sim_module.PVSim(ts, group_name,support_interfaces=support_interfaces) else: raise PVSimError('Unknown PV simulation mode: %s' % mode) @@ -83,30 +83,84 @@ class PVSimError(Exception): class PVSim(object): - def __init__(self, ts, group_name, params=None): + def __init__(self, ts, group_name, params=None, support_interfaces=None): self.ts = ts self.group_name = group_name + self.params = params + self.hil = None + if support_interfaces is not None: + if support_interfaces.get('hil') is not None: + self.hil = support_interfaces.get('hil') def close(self): + """ + Close the communication connection to the PVSim + + :return: None + """ pass def info(self): + """ + Get the type of PVSim. Typically this is done with a *IDN? command. + + :return: string of the information from the device + """ pass def irradiance_set(self, irradiance=1000): + """ + Set irradiance level for the PVSim channels (individual power supplies that produce the I-V curves) + + :return: None + """ + pass + + def iv_curve_config(self, pmp, vmp): + """ + Configure the I-V curves on the channels (individual power supplies that produce the I-V curves) + + Typically this is done using the EN50530 standard. Pointwise EN50530 curves can be created using + pv_curve_generation.py if the PV simulator cannot generate the EN50530 curve directly + + :param pmp: Maximum Power Point (MPP) Power in watts + :param vmp: Maximum Power Point (MPP) Voltage in volts + :return: None + """ pass def power_set(self, power): + """ + Set the maximum power of the I-V curve by adjusting the irradiance on the PVSim channels (or some other means) + + :param power: maximum power in watts + :return: None + """ pass def profile_load(self, profile_name): - # use pv_profiles.py to create profile + """ + Rarely used function to load an irradiance vs time profile + + :param profile_name: a string with the pv_profiles.py profile that is being used for the irradiance vs time + :return: None + """ pass def power_on(self): + """ + Energizes the output of the PVSimulator + + :return: None + """ pass def profile_start(self): + """ + Starts the profile that was loaded in profile_load() + + :return: None + """ pass @@ -131,10 +185,10 @@ def pvsim_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - raise PVSimError('Error scanning module %s: %s' % (module_name, str(e))) + print(PVSimError('Error scanning module %s: %s' % (module_name, str(e)))) # scan for gridsim modules on import pvsim_scan() diff --git a/Lib/svpelab/pvsim_chroma.py b/Lib/svpelab/pvsim_chroma.py index 68d25ef..a2c82dc 100644 --- a/Lib/svpelab/pvsim_chroma.py +++ b/Lib/svpelab/pvsim_chroma.py @@ -31,7 +31,7 @@ """ import os -import pvsim +from . import pvsim chroma_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -91,7 +91,7 @@ def __init__(self, ts, group_name): try: - import chromapv + from . import chromapv #self.ipaddr = ts._param_value('ipaddr') self.pmp = ts._param_value('pmp') diff --git a/Lib/svpelab/pvsim_keysightAPV.py b/Lib/svpelab/pvsim_keysightAPV.py new file mode 100644 index 0000000..80c916e --- /dev/null +++ b/Lib/svpelab/pvsim_keysightAPV.py @@ -0,0 +1,214 @@ +""" +Copyright (c) 2019, Sandia National Laboratories, SunSpec Alliance, and Tecnalia +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_keysightAPV as keysightAPV +from . import pvsim + +keysightAPV_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'keysightAPV' +} + +def pvsim_info(): + return keysightAPV_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = keysightAPV_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('ipaddr'), label='IP Address', default='192.168.120.101') + info.param(pname('curve_type'), label='IV Curve Type', default='SASCURVE', + values=['SASCURVE', 'TABLE']) + + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=660.0) + + + info.param(pname('vmp'), label='SASCURVE MPP Voltage (V)', default=460.0, + active=pname('curve_type'), active_value='SASCURVE') + + info.param(pname('filename'), label='IV Curve Name', default='BP Solar - BP 3230T (60 cells)', + active=pname('curve_type'), active_value='TABLE') + + info.param(pname('voc'), label='Voc (V)', default=540, + active=pname('curve_type'), active_value='SASCURVE') + info.param(pname('isc'), label='Isc (A)', default=7.3, + active=pname('curve_type'), active_value='SASCURVE') + + info.param(pname('imp'), label='MPP Current (A)', default=6.6, + active=pname('curve_type'), active_value='SASCURVE') + + info.param(pname('channel'), label='keysightAPV channel(s)', default='1', + desc='Channels are a string: 1 or 1,2,4,5') + +GROUP_NAME = 'keysightAPV' + + +class PVSim(pvsim.PVSim): + + def __init__(self, ts, group_name): + pvsim.PVSim.__init__(self, ts, group_name) + + self.ts = ts + self.ksas = None + + try: + + self.ipaddr = self._param_value('ipaddr') + self.curve_type = self._param_value('curve_type') + self.v_overvoltage = self._param_value('overvoltage') + self.vmp = self._param_value('vmp') + self.imp = self._param_value('imp') + + self.filename = self._param_value('filename') + if self.filename is None: + self.filename = keysightAPV.SVP_CURVE + self.voc = self._param_value('voc') + self.isc = self._param_value('isc') + self.channel = [] + chans = str(self._param_value('channel')).split(',') + for c in chans: + try: + self.channel.append(int(c)) + except ValueError: + raise pvsim.PVSimError('Invalid channel number: %s' % c) + + self.profile_name = None + self.ksas = keysightAPV.KeysightAPV(ipaddr=self.ipaddr) + self.ksas.scan() + + for c in self.channel: + channel = self.ksas.channels[c] + if self.curve_type == 'SASCURVE': + # re-add SASCURVE curve with active parameters + self.ts.log('Initializing PV Simulator with imp = %d and Vmp = %d.' % (self.imp, self.vmp)) + self.ksas.curve_SAS(imp=self.imp, vmp=self.vmp,isc=self.isc,voc=self.voc) + # channel.curve_set(keysightAPV.SAS_CURVE) + elif self.curve_type == 'TABLE': + self.ksas.curve(filename=self.filename) + channel.curve_set(self.filename) + else: + raise pvsim.PVSimError('Invalid curve type: %s' % self.curve_type) + + channel.overvoltage_protection_set(voltage=self.v_overvoltage) + channel.irradiance_set(irradiance=700) + + + except Exception: + if self.ksas is not None: + self.ksas.close() + raise + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def close(self): + if self.ksas is not None: + self.ksas.close() + self.ksas = None + + def info(self): + return self.ksas.info() + + def irradiance_set(self, irradiance=1000): + if self.ksas is not None: + # spread across active channels + count = len(self.channel) + if count > 1: + irradiance = irradiance/count + for c in self.channel: + if c is not None: + channel = self.ksas.channels[c] + channel.irradiance_set(irradiance=irradiance) + self.ts.log('KeysightSAS irradiance changed to %0.2f on channel %d.' % (irradiance, c)) + else: + raise pvsim.PVSimError('Simulation irradiance not specified because there is no channel specified.') + else: + raise pvsim.PVSimError('Irradiance was not changed.') + + def power_set(self, power): + if self.ksas is not None: + # spread across active channels + count = len(self.channel) + if count > 1: + power = power/count + channel=self.ksas.channels[0] + data = self.ksas.curve_SAS_read() + self.pmp = float(data[0])*float(data[2]) + self.ts.log('Maximum Power %d' % self.pmp) + if power > self.pmp: + self.ts.log_warning('Requested power > Pmp so irradiance will be > 1000 W/m^2)') + # convert to irradiance for now + irradiance = (power * 1000)/self.pmp + self.ts.log('Irradiance %d' % irradiance) + for c in self.channel: + if c is not None: + channel = self.ksas.channels[c] + channel.irradiance_set(irradiance=irradiance) + # self.ts.log('TerraSAS power output changed to %0.2f on channel %d.' % (power, c)) + else: + raise pvsim.PVSimError('Power was not changed.') + + def profile_load(self, profile_name): + self.ts.log('Function not available. No irradiance profile loaded') + + def power_on(self): + if self.ksas is not None: + for c in self.channel: + channel = self.ksas.channels[c] + # turn on output if off + if not channel.output_is_on(): + channel.output_set_on() + self.ts.log('KeysightAPV channel %d turned on' % c) + else: + raise pvsim.PVSimError('Not initialized') + + def power_off(self): + if self.ksas is not None: + for c in self.channel: + channel = self.ksas.channels[c] + # turn off output if on + if channel.output_is_on(): + channel.output_set_off() + self.ts.log('KeysightAPV channel %d turned off' % c) + else: + raise pvsim.PVSimError('Not initialized') + + def profile_start(self): + self.ts.log('Function not available. No irradiance profile started') + +if __name__ == "__main__": + pass diff --git a/Lib/svpelab/pvsim_manual.py b/Lib/svpelab/pvsim_manual.py index c4cdd01..147cd71 100644 --- a/Lib/svpelab/pvsim_manual.py +++ b/Lib/svpelab/pvsim_manual.py @@ -32,7 +32,7 @@ import os -import pvsim +from . import pvsim manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -47,15 +47,91 @@ def params(info, group_name): pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name mode = manual_info['mode'] info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('ipaddr'), label='IP Address', default='192.168.0.167') + info.param(pname('curve_type'), label='IV Curve Type', default='EN50530', + values=['EN50530', 'Name', 'Fill Factor', 'Vmp/Imp']) + + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=660.0) + + info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=3000.0, + active=pname('curve_type'), active_value='EN50530') + info.param(pname('vmp'), label='EN50530 MPP Voltage (V)', default=460.0, + active=pname('curve_type'), active_value='EN50530') + + info.param(pname('filename'), label='IV Curve Name', default='BP Solar - BP 3230T (60 cells)', + active=pname('curve_type'), active_value='Name') + + info.param(pname('voc'), label='Voc (V)', default=65.0, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('isc'), label='Isc (A)', default=2.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + + # can choose between Vmp/Imp or Fill Factor + info.param(pname('vmp2'), label='MPP Voltage (V)', default=50.0, + active=pname('curve_type'), active_value='Vmp/Imp') + info.param(pname('imp'), label='MPP Current (A)', default=2.3, + active=pname('curve_type'), active_value='Vmp/Imp') + + info.param(pname('form_factor'), label='Form Factor (Fill Factor)', default=0.71, + active=pname('curve_type'), active_value=['Fill Factor']) + + info.param(pname('beta_v'), label='Beta V (%/K)', default=-0.36, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('beta_p'), label='Beta P (%/K)', default=-0.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_voltage'), label='K Factor V1 (V)', default=60.457, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_irradiance'), label='K Factor E1 (W/m^2)', default=200, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + + info.param(pname('channel'), label='TerraSAS channel(s)', default='1', + desc='Channels are a string: 1 or 1,2,4,5') GROUP_NAME = 'manual' class PVSim(pvsim.PVSim): - def __init__(self, ts, group_name): + def __init__(self, ts, group_name, support_interfaces): pvsim.PVSim.__init__(self, ts, group_name) + self.ts = ts + + self.ipaddr = self._param_value('ipaddr') + self.curve_type = self._param_value('curve_type') + self.v_overvoltage = self._param_value('overvoltage') + self.pmp = self._param_value('pmp') + self.vmp = self._param_value('vmp') + if self.vmp is None: + self.vmp = self._param_value('vmp2') # it can only be one of the vmp's + self.imp = self._param_value('imp') + self.filename = self._param_value('filename') + self.voc = self._param_value('voc') + self.isc = self._param_value('isc') + self.form_factor = self._param_value('form_factor') + self.beta_v = self._param_value('beta_v') + self.beta_p = self._param_value('beta_p') + self.kfactor_voltage = self._param_value('kfactor_voltage') + self.kfactor_irradiance = self._param_value('kfactor_irradiance') + + self.channel = [] + self.irr_start = self._param_value('irr_start') + chans = str(self._param_value('channel')).split(',') + for c in chans: + try: + self.channel.append(int(c)) + except ValueError: + raise pvsim.PVSimError('Invalid channel number: %s' % c) + + self.profile_name = None + + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + def irradiance_set(self, irradiance=1000): if self.ts.confirm('Please change the irradiance to %0.1f W/m^2.' % irradiance) is False: raise pvsim.PVSimError('Aborted PV simulation') diff --git a/Lib/svpelab/pvsim_pass.py b/Lib/svpelab/pvsim_pass.py index 53b1571..19a6617 100644 --- a/Lib/svpelab/pvsim_pass.py +++ b/Lib/svpelab/pvsim_pass.py @@ -31,8 +31,7 @@ """ import os - -import pvsim +from . import pvsim pass_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/pvsim_regatron_topcon_quadro.py b/Lib/svpelab/pvsim_regatron_topcon_quadro.py new file mode 100644 index 0000000..c7e387e --- /dev/null +++ b/Lib/svpelab/pvsim_regatron_topcon_quadro.py @@ -0,0 +1,171 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from svpelab import device_regatron_topcon_quadro as regatron +from . import pvsim + +regatron_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'regatron' +} + +def pvsim_info(): + return regatron_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = regatron_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('ipaddr'), label='IP Address', default='10.0.0.4') + info.param(pname('curve_type'), label='IV Curve Type', default='EN50530', + values=['EN50530', 'Name', 'Fill Factor', 'Vmp/Imp']) + + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=660.0) + + info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=3000.0, + active=pname('curve_type'), active_value='EN50530') + info.param(pname('vmp'), label='EN50530 MPP Voltage (V)', default=460.0, + active=pname('curve_type'), active_value='EN50530') + + info.param(pname('filename'), label='IV Curve Name', default='BP Solar - BP 3230T (60 cells)', + active=pname('curve_type'), active_value='Name') + + info.param(pname('voc'), label='Voc (V)', default=65.0, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('isc'), label='Isc (A)', default=2.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + + # can choose between Vmp/Imp or Fill Factor + info.param(pname('vmp2'), label='MPP Voltage (V)', default=50.0, + active=pname('curve_type'), active_value='Vmp/Imp') + info.param(pname('imp'), label='MPP Current (A)', default=2.3, + active=pname('curve_type'), active_value='Vmp/Imp') + + info.param(pname('form_factor'), label='Form Factor (Fill Factor)', default=0.71, + active=pname('curve_type'), active_value=['Fill Factor']) + + info.param(pname('beta_v'), label='Beta V (%/K)', default=-0.36, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('beta_p'), label='Beta P (%/K)', default=-0.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_voltage'), label='K Factor V1 (V)', default=60.457, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_irradiance'), label='K Factor E1 (W/m^2)', default=200, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + + info.param(pname('channel'), label='regatron channel(s)', default='1', + desc='Channels are a string: 1 or 1,2,4,5') + +GROUP_NAME = 'regatron' + + +class PVSim(pvsim.PVSim): + + def __init__(self, ts, group_name): + pvsim.PVSim.__init__(self, ts, group_name) + + self.ts = ts + self.regatron = None + + try: + self.ipaddr = self._param_value('ipaddr') + self.curve_type = self._param_value('curve_type') + self.v_overvoltage = self._param_value('overvoltage') + self.pmp = self._param_value('pmp') + self.vmp = self._param_value('vmp') + if self.vmp is None: + self.vmp = self._param_value('vmp2') # it can only be one of the vmp's + self.imp = self._param_value('imp') + self.filename = self._param_value('filename') + if self.filename is None: + self.filename = regatron.SVP_CURVE + self.voc = self._param_value('voc') + self.isc = self._param_value('isc') + self.form_factor = self._param_value('form_factor') + self.beta_v = self._param_value('beta_v') + self.beta_p = self._param_value('beta_p') + self.kfactor_voltage = self._param_value('kfactor_voltage') + self.kfactor_irradiance = self._param_value('kfactor_irradiance') + + self.irr_start = self._param_value('irr_start') + self.regatron = regatron.regatron(ipaddr=self.ipaddr) + + except Exception: + if self.regatron is not None: + self.regatron.close() + raise + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def close(self): + if self.regatron is not None: + self.regatron.close() + self.regatron = None + + def info(self): + return self.regatron.info() + + def irradiance_set(self, irradiance=1000): + if self.regatron is not None: + self.regatron.irradiance_set(irradiance=irradiance) + else: + raise pvsim.PVSimError('Irradiance was not changed.') + + def power_set(self, power): + if self.regatron is not None: + if power > self.pmp: + self.ts.log_warning('Requested power > Pmp so irradiance will be > 1000 W/m^2)') + irradiance = (power * 1000)/self.pmp # convert power to irradiance + self.irradiance_set(irradiance=irradiance) + else: + raise pvsim.PVSimError('Power was not changed.') + + def power_on(self): + if self.regatron is not None: + self.regatron.output_set_on() + else: + raise pvsim.PVSimError('Not initialized') + + def power_off(self): + if self.regatron is not None: + self.regatron.output_set_off() + else: + raise pvsim.PVSimError('Not initialized') + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/Lib/svpelab/pvsim_sim.py b/Lib/svpelab/pvsim_sim.py index fd2f48d..5889a17 100644 --- a/Lib/svpelab/pvsim_sim.py +++ b/Lib/svpelab/pvsim_sim.py @@ -32,7 +32,7 @@ import os -import pvsim +from . import pvsim sim_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -57,16 +57,16 @@ def __init__(self, ts, group_name): pvsim.PVSim.__init__(self, ts, group_name) def irradiance_set(self, irradiance=1000): - self.ts.log('Setting PV irradiance to %0.1f W/m^2.' % irradiance) + self.ts.log('Simulation mode in use, no PV irradiance has been set') def power_set(self, power): - self.ts.log('Setting PV power to %0.1f W.' % power) + self.ts.log('Simulation mode in use, no PV power has been set') def power_on(self): - self.ts.log('Powering on PV simulator to give EUT DC power.') + self.ts.log('No PV Powering on since in Simulation mode') def profile_start(self): - self.ts.log('Starting PV simulator profile.') + self.ts.log('No Starting PV simulator profile since in Simulation mode') if __name__ == "__main__": pass diff --git a/Lib/svpelab/pvsim_sps.py b/Lib/svpelab/pvsim_sps.py new file mode 100644 index 0000000..99f1d86 --- /dev/null +++ b/Lib/svpelab/pvsim_sps.py @@ -0,0 +1,218 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import device_pvsim_sps as sps +from . import pvsim + +sps_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'SPS' +} + +def pvsim_info(): + return sps_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = sps_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + + info.param(pname('comm'), label='Communication Interface', default='VISA', values=['Network', 'VISA']) + info.param(pname('visa_id'), label='VISA ID', default='GPIB1::19::INSTR', + active=pname('comm'), active_value='VISA') + info.param(pname('ipaddr'), label='IP Address', default='192.168.0.167', + active=pname('comm'), active_value='Network') + + info.param(pname('curve_type'), label='IV Curve Type', default='EN50530', + values=['Diode Model', 'EN50530', 'Vmp/Imp']) + + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=660.0) + + info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=3000.0, + active=pname('curve_type'), active_value='EN50530') + info.param(pname('vmp'), label='EN50530 MPP Voltage (V)', default=460.0, + active=pname('curve_type'), active_value='EN50530') + + info.param(pname('voc'), label='Voc (V)', default=65.0, active=pname('curve_type'), active_value=['Vmp/Imp']) + info.param(pname('isc'), label='Isc (A)', default=2.5, active=pname('curve_type'), active_value=['Vmp/Imp']) + + # Vmp/Imp parameters + info.param(pname('vmp2'), label='MPP Voltage (V)', default=50.0, + active=pname('curve_type'), active_value='Vmp/Imp') + info.param(pname('imp'), label='MPP Current (A)', default=2.3, + active=pname('curve_type'), active_value='Vmp/Imp') + + info.param(pname('beta_v'), label='Beta V (%/K)', default=-0.36, + active=pname('curve_type'), active_value=['Vmp/Imp']) + info.param(pname('beta_p'), label='Beta P (%/K)', default=-0.5, + active=pname('curve_type'), active_value=['Vmp/Imp']) + info.param(pname('kfactor_voltage'), label='K Factor V1 (V)', default=60.457, + active=pname('curve_type'), active_value=['Vmp/Imp']) + info.param(pname('kfactor_irradiance'), label='K Factor E1 (W/m^2)', default=200, + active=pname('curve_type'), active_value=['Vmp/Imp']) + +GROUP_NAME = 'sps' + + +class PVSim(pvsim.PVSim): + + def __init__(self, ts, group_name): + pvsim.PVSim.__init__(self, ts, group_name) + + self.ts = ts + self.sps = None + + try: + self.comm = self._param_value('comm') + self.visa_id = self._param_value('visa_id') + self.ipaddr = self._param_value('ipaddr') + + self.curve_type = self._param_value('curve_type') + self.v_overvoltage = self._param_value('overvoltage') + self.pmp = self._param_value('pmp') + self.vmp = self._param_value('vmp') + if self.vmp is None: + self.vmp = self._param_value('vmp2') # it can only be one of the vmp's + self.imp = self._param_value('imp') + + self.voc = self._param_value('voc') + self.isc = self._param_value('isc') + self.form_factor = self._param_value('form_factor') + self.beta_v = self._param_value('beta_v') + self.beta_p = self._param_value('beta_p') + self.kfactor_voltage = self._param_value('kfactor_voltage') + self.kfactor_irradiance = self._param_value('kfactor_irradiance') + + self.profile_name = None + self.sps = sps.SPS(comm=self.comm, visa_id=self.visa_id, ipaddr=self.ipaddr) + + if self.sps.profile_is_active(): + self.sps.profile_abort() + + if self.curve_type == 'Diode Model': # Not implemented yet + pass + elif self.curve_type == 'EN50530': + # re-add EN50530 curve with active parameters + self.ts.log('Initializing PV Simulator with Pmp = %d and Vmp = %d.' % (self.pmp, self.vmp)) + self.sps.curve_en50530(pmp=self.pmp, vmp=self.vmp) + self.sps.curve_set(sps.EN_50530_CURVE) + elif self.curve_type == 'Vmp/Imp': + curve_name = self.sps.curve(voc=self.voc, isc=self.isc, vmp=self.vmp, imp=self.imp, + beta_v=self.beta_v, beta_p=self.beta_p, kfactor_voltage=self.kfactor_voltage, + kfactor_irradiance=self.kfactor_irradiance) + self.ts.log('Created and saved new IV curve with filename: "%s"' % curve_name) + self.sps.curve_set(curve_name) # Add new IV curve to the channel + else: + raise pvsim.PVSimError('Invalid curve type: %s' % self.curve_type) + + self.sps.overvoltage_protection_set(voltage=self.v_overvoltage) + + except Exception: + if self.sps is not None: + self.sps.close() + raise + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + + def close(self): + if self.sps is not None: + self.sps.close() + self.sps = None + + def info(self): + return self.sps.info() + + def irradiance_set(self, irradiance=1000): + if self.sps is not None: + self.sps.irradiance_set(irradiance=irradiance) + self.ts.log('SPS irradiance changed to %0.2f' % irradiance) + else: + raise pvsim.PVSimError('Irradiance was not changed.') + + def power_set(self, power): + if self.sps is not None: + if power > self.pmp: + self.ts.log_warning('Requested power > Pmp so irradiance will be > 1000 W/m^2)') + # convert to irradiance for now + irradiance = (power * 1000.)/self.pmp + self.sps.irradiance_set(irradiance=irradiance) + else: + raise pvsim.PVSimError('Power was not changed.') + + def profile_load(self, profile_name): + if profile_name != 'None' and profile_name is not None: + self.ts.log('Loading irradiance profile %s' % profile_name) + self.profile_name = profile_name + profiles = self.sps.profiles_get() + if profile_name not in profiles: + self.sps.profile(profile_name) + + if self.sps is not None: + self.sps.profile_set(profile_name) + self.ts.log('SPS Profile is configured.') + else: + raise pvsim.PVSimError('SPS Profile was not changed.') + else: + self.ts.log('No irradiance profile loaded') + + def power_on(self): + if self.sps is not None: + if not self.sps.output_is_on(): + self.sps.output_set_on() + self.ts.log('SPS turned on') + else: + raise pvsim.PVSimError('Not initialized') + + def power_off(self): + if self.sps is not None: + if self.sps.output_is_on(): + self.sps.output_set_off() + self.ts.log('SPS channel %d turned off') + else: + raise pvsim.PVSimError('Not initialized') + + def profile_start(self): + if self.sps is not None: + profile_name = self.profile_name + if profile_name != 'None' and profile_name is not None: + self.profile_start() + self.ts.log('Starting PV profile') + else: + raise pvsim.PVSimError('PV Sim not initialized') + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/Lib/svpelab/pvsim_terrasas.py b/Lib/svpelab/pvsim_terrasas.py index 567c878..f6d41c4 100644 --- a/Lib/svpelab/pvsim_terrasas.py +++ b/Lib/svpelab/pvsim_terrasas.py @@ -31,9 +31,8 @@ """ import os - -import terrasas -import pvsim +from svpelab import device_terrasas as terrasas +from . import pvsim terrasas_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -52,8 +51,42 @@ def params(info, group_name): active=gname('mode'), active_value=mode, glob=True) info.param(pname('ipaddr'), label='IP Address', default='192.168.0.167') - info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=3000.0) - info.param(pname('vmp'), label='EN50530 MPP Voltage (V)', default=460.0) + info.param(pname('curve_type'), label='IV Curve Type', default='EN50530', + values=['EN50530', 'Name', 'Fill Factor', 'Vmp/Imp']) + + info.param(pname('overvoltage'), label='Overvoltage Protection Level (V)', default=660.0) + + info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=3000.0, + active=pname('curve_type'), active_value='EN50530') + info.param(pname('vmp'), label='EN50530 MPP Voltage (V)', default=460.0, + active=pname('curve_type'), active_value='EN50530') + + info.param(pname('filename'), label='IV Curve Name', default='BP Solar - BP 3230T (60 cells)', + active=pname('curve_type'), active_value='Name') + + info.param(pname('voc'), label='Voc (V)', default=65.0, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('isc'), label='Isc (A)', default=2.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + + # can choose between Vmp/Imp or Fill Factor + info.param(pname('vmp2'), label='MPP Voltage (V)', default=50.0, + active=pname('curve_type'), active_value='Vmp/Imp') + info.param(pname('imp'), label='MPP Current (A)', default=2.3, + active=pname('curve_type'), active_value='Vmp/Imp') + + info.param(pname('form_factor'), label='Form Factor (Fill Factor)', default=0.71, + active=pname('curve_type'), active_value=['Fill Factor']) + + info.param(pname('beta_v'), label='Beta V (%/K)', default=-0.36, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('beta_p'), label='Beta P (%/K)', default=-0.5, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_voltage'), label='K Factor V1 (V)', default=60.457, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('kfactor_irradiance'), label='K Factor E1 (W/m^2)', default=200, + active=pname('curve_type'), active_value=['Vmp/Imp', 'Fill Factor']) + info.param(pname('channel'), label='TerraSAS channel(s)', default='1', desc='Channels are a string: 1 or 1,2,4,5') @@ -62,17 +95,33 @@ def params(info, group_name): class PVSim(pvsim.PVSim): - def __init__(self, ts, group_name): - pvsim.PVSim.__init__(self, ts, group_name) + def __init__(self, ts, group_name, support_interfaces=None): + pvsim.PVSim.__init__(self, ts, group_name, support_interfaces=support_interfaces) self.ts = ts self.tsas = None + self.support_interfaces = support_interfaces try: - self.ipaddr = self._param_value('ipaddr') + self.curve_type = self._param_value('curve_type') + self.v_overvoltage = self._param_value('overvoltage') self.pmp = self._param_value('pmp') self.vmp = self._param_value('vmp') + if self.vmp is None: + self.vmp = self._param_value('vmp2') # it can only be one of the vmp's + self.imp = self._param_value('imp') + self.filename = self._param_value('filename') + if self.filename is None: + self.filename = terrasas.SVP_CURVE + self.voc = self._param_value('voc') + self.isc = self._param_value('isc') + self.form_factor = self._param_value('form_factor') + self.beta_v = self._param_value('beta_v') + self.beta_p = self._param_value('beta_p') + self.kfactor_voltage = self._param_value('kfactor_voltage') + self.kfactor_irradiance = self._param_value('kfactor_irradiance') + self.channel = [] self.irr_start = self._param_value('irr_start') chans = str(self._param_value('channel')).split(',') @@ -83,7 +132,6 @@ def __init__(self, ts, group_name): raise pvsim.PVSimError('Invalid channel number: %s' % c) self.profile_name = None - self.ts.log('Initializing PV Simulator with Pmp = %d and Vmp = %d.' % (self.pmp, self.vmp)) self.tsas = terrasas.TerraSAS(ipaddr=self.ipaddr) self.tsas.scan() @@ -92,15 +140,59 @@ def __init__(self, ts, group_name): if channel.profile_is_active(): channel.profile_abort() - # re-add EN50530 curve with active parameters - self.tsas.curve_en50530(pmp=self.pmp, vmp=self.vmp) - channel.curve_set(terrasas.EN_50530_CURVE) + if self.curve_type == 'EN50530': + # re-add EN50530 curve with active parameters + self.ts.log('Initializing PV Simulator with Pmp = %d and Vmp = %d.' % (self.pmp, self.vmp)) + self.tsas.curve_en50530(pmp=self.pmp, vmp=self.vmp) + channel.curve_set(terrasas.EN_50530_CURVE) + elif self.curve_type == 'Name': + self.tsas.curve(filename=self.filename) + channel.curve_set(self.filename) + elif self.curve_type == 'Fill Factor': + curve_name = self.tsas.curve(voc=self.voc, isc=self.isc, form_factor=self.form_factor, + beta_v=self.beta_v, beta_p=self.beta_p, kfactor_voltage=self.kfactor_voltage, + kfactor_irradiance=self.kfactor_irradiance) + self.ts.log('Created and saved new IV curve with filename: "%s"' % curve_name) + channel.curve_set(curve_name) # Add new IV curve to the channel + elif self.curve_type == 'Vmp/Imp': + curve_name = self.tsas.curve(voc=self.voc, isc=self.isc, vmp=self.vmp, imp=self.imp, + beta_v=self.beta_v, beta_p=self.beta_p, kfactor_voltage=self.kfactor_voltage, + kfactor_irradiance=self.kfactor_irradiance) + self.ts.log('Created and saved new IV curve with filename: "%s"' % curve_name) + channel.curve_set(curve_name) # Add new IV curve to the channel + else: + raise pvsim.PVSimError('Invalid curve type: %s' % self.curve_type) + + channel.overvoltage_protection_set(voltage=self.v_overvoltage) except Exception: if self.tsas is not None: self.tsas.close() raise + def iv_curve_config(self, pmp, vmp): + if self.tsas is not None: + self.pmp = pmp # total power + self.vmp = vmp + count = len(self.channel) + if count > 1: + pmp = self.pmp/count # power per output + for c in self.channel: + channel = self.tsas.channels[c] + if channel.profile_is_active(): + channel.profile_abort() + + if self.curve_type == 'EN50530': + # re-add EN50530 curve with active parameters + self.ts.log('Initializing PV Simulator (Channel %s) with Pmp = %d and Vmp = %d.' % + (c, pmp, self.vmp)) + self.tsas.curve_en50530(pmp=pmp, vmp=self.vmp) + channel.curve_set(terrasas.EN_50530_CURVE) + else: + raise pvsim.PVSimError('Invalid curve type: %s' % self.curve_type) + + channel.overvoltage_protection_set(voltage=self.v_overvoltage) + def _param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) @@ -115,9 +207,6 @@ def info(self): def irradiance_set(self, irradiance=1000): if self.tsas is not None: # spread across active channels - count = len(self.channel) - if count > 1: - irradiance = irradiance/count for c in self.channel: if c is not None: channel = self.tsas.channels[c] @@ -128,6 +217,40 @@ def irradiance_set(self, irradiance=1000): else: raise pvsim.PVSimError('Irradiance was not changed.') + def measurements_get(self): + """ + Measure the voltage, current, and power of all channels - calculate the average voltage, total current, and + total power + + :return: dictionary with dc power data with keys: 'DC_V', 'DC_I', 'DC_P', 'MPPT_Accuracy' + """ + + voltage = 0. + current = 0. + power = 0. + mppt_accuracy = 0. + n_channels = 0 + + if self.tsas is not None: + # spread across active channels + for c in self.channel: + n_channels += 1 + if c is not None: + channel = self.tsas.channels[c] + meas = channel.measurements_get() + voltage += meas['DC_V'] + current += meas['DC_I'] + power += meas['DC_P'] + mppt_accuracy += meas['MPPT_Accuracy']/n_channels + else: + raise pvsim.PVSimError('No measurement data because there is no channel specified.') + avg_voltage = voltage/float(n_channels) + else: + raise pvsim.PVSimError('Could not collect the current, voltage, or power from the TerraSAS.') + + total_meas = {'DC_V': avg_voltage, 'DC_I': current, 'DC_P': power, 'MPPT_Accuracy': mppt_accuracy} + return total_meas + def power_set(self, power): if self.tsas is not None: # spread across active channels @@ -158,7 +281,7 @@ def profile_load(self, profile_name): for c in self.channel: channel = self.tsas.channels[c] channel.profile_set(profile_name) - self.ts.log('TerraSAS Profile is configured.') + self.ts.log('TerraSAS Profile is configured on Channel %d' % c) else: raise pvsim.PVSimError('TerraSAS Profile was not changed.') else: @@ -193,9 +316,50 @@ def profile_start(self): for c in self.channel: channel = self.tsas.channels[c] channel.profile_start() - self.ts.log('Starting PV profile') + self.ts.log('Starting PV profile on Channel %d' % c) + else: + raise pvsim.PVSimError('PV Sim not initialized') + + def profile_stop(self): + if self.tsas is not None: + for c in self.channel: + channel = self.tsas.channels[c] + if channel.profile_is_active(): + channel.profile_abort() + self.ts.log('Stopping PV profile on Channel %d' % c) + else: + self.ts.log('Did not stop PV profile because it was not running on Channel %d' % c) else: raise pvsim.PVSimError('PV Sim not initialized') + def measure_power(self): + """ + Get the current, voltage, and power from the TerraSAS + returns: dictionary with power data with keys: 'DC_V', 'DC_I', and 'DC_P' + """ + dc_power_data = {'DC_I': 0., 'DC_V': 0., 'DC_P': 0.} + if self.tsas is not None: + for c in self.channel: + channel = self.tsas.channels[c] + chan_data = channel.measurements_get() + # self.ts.log_debug('chan_data: %s' % chan_data) + dc_power_data['DC_I'] += chan_data['DC_I'] + dc_power_data['DC_V'] += chan_data['DC_V'] + dc_power_data['DC_P'] += chan_data['DC_P'] + + return dc_power_data + else: + raise pvsim.PVSimError('PV Sim not initialized') + + def clear_faults(self): + """ + Clear overvoltage and overcurrent faults on the channels + """ + if self.tsas is not None: + for c in self.channel: + channel = self.tsas.channels[c] + channel.clear_protection_faults() + + if __name__ == "__main__": pass \ No newline at end of file diff --git a/Lib/svpelab/pvsim_typhoon.py b/Lib/svpelab/pvsim_typhoon.py index afdc6ef..1210654 100644 --- a/Lib/svpelab/pvsim_typhoon.py +++ b/Lib/svpelab/pvsim_typhoon.py @@ -31,16 +31,17 @@ """ import os - -import pv_profiles -import pvsim +from . import pv_curve_generation +import time +from . import pv_profiles +from . import pvsim try: - import typhoon.api.hil_control_panel as cp + import typhoon.api.hil as cp # control panel from typhoon.api.schematic_editor import model import typhoon.api.pv_generator as pv -except Exception, e: - print('Typhoon HIL API not installed. %s' % e) +except Exception as e: + print(('Typhoon HIL API not installed. %s' % e)) typhoon_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -58,9 +59,11 @@ def params(info, group_name): info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, glob=True) # info.param(pname('pmp'), label='EN50530 MPP Power (W)', default=34500.0) - info.param(pname('vmp'), label='EN50530 MPP Voc (V)', default=997.) + info.param(pname('voc'), label='EN50530 MPP Voc (V)', default=997.) info.param(pname('isc'), label='EN50530 MPP Isc (A)', default=50.) - info.param(pname('pv_name'), label='PV file name (.ipvx)', default=r"pv_model4.ipvx") + info.param(pname('pv_name'), label='PV file name (.ipvx)', default=r"init.ipvx") + info.param(pname('pv_directory'), label='Absolute path to .ipvx file', + default=r"D:/SVP/1547.1 (5-10-19)/Lib/svpelab/ASGC_Closed_loop_full_model/") info.param(pname('irr_start'), label='Irradiance at the start of the test (W/m^2)', default=1000.) info.param(pname('profile_name'), label='Irradiance Profile Name', default='STPsIrradiance', desc='Typically the Sandia Test Protocols\' (STPs) Irradiance will be used for the profile.') @@ -76,7 +79,7 @@ def __init__(self, ts, group_name): try: # self.pmp = ts.param_value('pvsim.typhoon.pmp') - self.voc = self._param_value('vmp') + self.voc = self._param_value('voc') self.isc = self._param_value('isc') self.irr_start = self._param_value('irr_start') self.profile_name = self._param_value('profile_name') @@ -84,7 +87,8 @@ def __init__(self, ts, group_name): self.settings_file = None self.pv_name = self._param_value('pv_name') - self.pv_file = None # set in config + self.pv_directory = self._param_value('pv_directory') + self.pv_file = self.pv_directory.replace("\\", "/") + self.pv_name # PV is configured with the .runx file in hil.typhoon # self.ts.log('Configuring PV simulation in Typhoon environment...') @@ -93,7 +97,6 @@ def __init__(self, ts, group_name): # update the pmp after setting up I-V curve self.pmp = self.pv_pmp_get() - except Exception: raise @@ -103,7 +106,10 @@ def _param_value(self, name): def config(self): lib_dir = os.path.dirname(__file__) + os.path.sep - model_file = r"Typhoon/" + self.pv_name + if self.pv_name[-5:] == ".ipvx": + model_file = r"Typhoon/" + self.pv_name + else: + model_file = r"Typhoon/" + self.pv_name + r".ipvx" self.pv_file = lib_dir.replace("\\", "/") + model_file self.ts.log("PV model (.ipvx) file: %s" % self.pv_file) @@ -132,6 +138,32 @@ def config(self): return True # PV configured correctly + def set_pv_curve(self, pv_curve_path): + + # generating PV file + self.ts.log("Generating new PV curve...") + pv_params = {"Voc_ref": self.voc, # Open-circuit voltage (Voc [V]) + "Isc_ref": self.isc, # Short-circuit current (Isc [A]) + "pv_type": pv.EN50530_PV_TYPES[0], # "cSi" pv type ("cSi" or "Thin film") + "neg_current": False} # allow negative current + (status, msg) = pv.generate_pv_settings_file(pv.PV_MT_EN50530, pv_curve_path, pv_params) + if not status: + self.ts.log_error("Error during generating PV curve. Error: %s" % msg) + return status + + if os.path.isfile(pv_curve_path): + self.ts.log_debug("PV model (.ipvx) file exists! Setting curve in Typhoon environment...") + + if not cp.set_pv_input_file('PV1', file=pv_curve_path, illumination=self.irr_start, temperature=25.0): + self.ts.log_error("Error during setting PV curve (%s)." % pv_curve_path) + status = False + return status + else: + self.ts.log_debug("PV model (.ipvx) file does not exist! Did not set new PV curve. ") + return False + + return True + def close(self): pass @@ -157,6 +189,54 @@ def irradiance_set(self, irradiance=1000.): cp.set_pv_amb_params("PV1", illumination=irradiance) # cp.wait_msec(50.0) + ''' + def iv_curve_config(self, pmp=None, vmp=None): + """ + Hack method to generate I-V curves in typhoon that the ASGC plays nicely with. File names + are, e.g., 20Prated.ipvx, 60Prated.ipvx, and 100Prated.ipvx. + """ + tol = 0.02 + asgc_power = 34500. + for p_rated_ratio in [0.2, 0.6, 1.0]: + if asgc_power*(p_rated_ratio - tol) <= pmp <= asgc_power*(p_rated_ratio + tol): + new_pv_file = '%s%sPrated.ipvx' % (self.pv_file[:-5], int(p_rated_ratio*100.)) + self.ts.log_debug('New PV file name: %s' % new_pv_file) + + # set pv curve in Typhoon + self.set_pv_curve(new_pv_file) + self.ts.sleep(2) + return True + + self.ts.log_error('Did not update I-V Curve in the Typhoon environment!') + return False + ''' + + def iv_curve_config(self, pmp=None, vmp=None): + """ + Configure EN50530 curve based on Pmp and Vmp inputs + """ + if pmp is None: + pmp = self.pv_pmp_get() + if vmp is None: + vmp = self.pv_vmp_get() + + self.ts.log_debug("Creating new EN50530 curve based on Pmp and Vmp...") + pv_curve = pv_curve_generation.PVCurve(tech='cSi', Pmpp=pmp, Vmpp=vmp, Tpv=25, n_points=1000, v_max=self.voc) + self.voc = pv_curve.Voc + self.isc = pv_curve.Isc + + # Create new pv_file + self.ts.log_debug('Current PV file name: %s' % self.pv_file) + # new_pv_file = '%s%s.ipvx' % (self.pv_file[:-5], time.time()) + new_pv_file = '%s_new.ipvx' % (self.pv_file[:-5]) + self.ts.log_debug('New PV file name: %s' % new_pv_file) + + # set pv curve in Typhoon + self.set_pv_curve(new_pv_file) + self.ts.sleep(2) + + return True + def profile_load(self, profile_name): if profile_name != 'None' and profile_name is not None: self.ts.log('Loading irradiance profile %s' % profile_name) diff --git a/Lib/svpelab/result.py b/Lib/svpelab/result.py index 5b6f612..eb4731d 100644 --- a/Lib/svpelab/result.py +++ b/Lib/svpelab/result.py @@ -1,6 +1,8 @@ - import os import xml.etree.ElementTree as ET +import csv +import math +import xlsxwriter RESULT_TYPE_RESULT = 'result' RESULT_TYPE_SUITE = 'suite' @@ -21,9 +23,10 @@ PARAM_TYPE_STR = 'string' PARAM_TYPE_INT = 'int' PARAM_TYPE_FLOAT = 'float' +PARAM_TYPE_BOOL = 'bool' -param_types = {'int': int, 'float': float, 'string': str, - int: 'int', float: 'float', str: 'string'} +param_types = {'int': int, 'float': float, 'string': str, 'bool': bool, + int: 'int', float: 'float', str: 'string', bool: 'bool'} RESULT_TAG = 'result' RESULT_ATTR_NAME = 'name' @@ -36,6 +39,40 @@ RESULT_PARAM_ATTR_TYPE = 'type' RESULT_RESULTS = 'results' +INDEX_COL_FILE = 0 +INDEX_COL_DESC = 1 +INDEX_COL_NOTES = 2 + +index_hdr = [('File', 30), + ('Description', 80), + ('Notes', 80)] + +XL_COL_WIDTH_DEFAULT = 10 + +def xl_col(index): + return chr(index + 65) + +def find_result(results_dir, result_dir, ts=None): + r_target = None + rlt_name = os.path.split(results_dir)[1] + rlt_file = os.path.join(results_dir, rlt_name) + '.rlt' + path = os.path.normpath(result_dir) + path = path.split(os.sep) + r = Result() + r.from_xml(filename=rlt_file) + r_target = r.find(path, ts) + return r_target + +def result_workbook(file, results_dir, result_dir, index=True, ts=None): + + r = find_result(results_dir, result_dir, ts) + + if r is not None: + r.to_xlsx(filename=os.path.join(results_dir, result_dir, file), results_dir=results_dir, index=index, + index_row=0, ts=ts) + else: + raise ResultError('Error creating summary workbook - resource not found: %s %s' % (results_dir, result_dir)) + class ResultError(Exception): pass @@ -43,14 +80,16 @@ class ResultError(Exception): class Result(object): - def __init__(self, name=None, type=None, status=None, filename=None, params=None): + def __init__(self, name=None, type=None, status=None, filename=None, params=None, result_path=None, ts=None): self.name = name self.type = type self.status = status self.filename = filename self.params = [] + self.result_path = result_path self.ref = None self.results_index = 0 + self.ts = ts if params is not None: self.params = params else: @@ -60,6 +99,16 @@ def __init__(self, name=None, type=None, status=None, filename=None, params=None def __str__(self): return self.to_str() + def find(self, path, ts=None): + result = None + for r in self.results: + if r.name == path[0] or r.name== path[0].replace('__','/'): + if len(path) > 1: + result = r.find(path[1:],ts) + else: + result = r + return result + def next_result(self): if self.results_index < len(self.results): result = self.results[self.results_index] @@ -84,6 +133,7 @@ def to_str(self, indent=''): def from_xml(self, element=None, filename=None): if element is None and filename is not None: element = ET.ElementTree(file=filename).getroot() + self.result_path, file = os.path.split(filename) if element is None: raise ResultError('No xml document element') if element.tag != RESULT_TAG: @@ -109,7 +159,7 @@ def from_xml(self, element=None, filename=None): elif e.tag == RESULT_RESULTS: for e_param in e.findall('*'): if e_param.tag == RESULT_TAG: - result = Result() + result = Result(result_path=self.result_path) self.results.append(result) result.from_xml(e_param) @@ -173,10 +223,309 @@ def to_xml_file(self, filename=None, pretty_print=True, replace_existing=True): f.write(xml) f.close() else: - print xml + print(xml) + + def to_xlsx(self, wb=None, filename=None, results_dir=None, index=True, index_row=0, ts=None): + print('to_xlsx: %s %s' % (wb, filename)) + result_wb = wb + if result_wb is None: + result_wb = ResultWorkbook(filename=filename, ts=self.ts) + if index: + result_wb.add_index() + index_row = 1 + if self.type == RESULT_TYPE_FILE: + name, ext = os.path.splitext(self.filename) + if ext == '.csv': + index_row = result_wb.add_csv_file(os.path.join(results_dir, self.filename), self.name, + relative_value_names=['TIME'], params=self.params, + index_row=index_row) + print('results = %s' % self.results) + for r in self.results: + print('result in: %s' % (self.filename)) + index_row = r.to_xlsx(wb=result_wb, results_dir=results_dir, index=index, index_row=index_row) + print('result out: %s' % (self.filename)) + if wb is None: + result_wb.close() + + return index_row + + +class ResultWorkbook(object): + + def __init__(self, filename, ts=None): + self.wb = xlsxwriter.Workbook(filename) + self.ts = ts + self.ws_index = None + self.hdr_format = self.wb.add_format() + self.link_format = self.wb.add_format({'color': 'blue', 'underline': 1}) + + self.hdr_format.set_text_wrap() + self.hdr_format.set_align('center') + self.hdr_format.set_align('vcenter') + self.hdr_format.set_bold() + + self.link_format.set_align('center') + self.link_format.set_align('vcenter') + + def add_index(self): + print('add_index') + self.ws_index = self.wb.add_worksheet('Index') + col = 0 + for i in range(len(index_hdr)): + width = index_hdr[i][1] + if width: + self.ws_index.set_column(i, i, width) + self.ws_index.write(0, col, index_hdr[i][0], self.hdr_format) + col += 1 + + def add_index_entry(self, title, index_row, desc=None, notes=None): + print('add_index_entry: %s' % (title)) + self.ws_index.write_url(index_row, INDEX_COL_FILE, 'internal:%s!A1' % (title), + string=title) + if desc is not None: + self.ws_index.write(index_row, INDEX_COL_DESC, desc) + if notes is not None: + self.ws_index.write(index_row, INDEX_COL_NOTES, notes) + return index_row + 1 + + def add_chart(self, ws, params=None, index_row=None): + print('add chart') + # get fieldnames in first row of worksheet + colors = ['blue', 'green', 'purple', 'orange', 'red', 'brown', 'yellow'] + color_idx = 0 + point_names = params.get('plot.point_names', []) + + x_points = [] + y_points = [] + y2_points = [] + if params is not None: + points = params.get('plot.x.points') + if points is not None: + x_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y.points') + if points is not None: + y_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y2.points') + if points is not None: + y2_points = [x.strip() for x in points.split(',')] + + title = params.get('plot.title', '') + # if the excel sheet name is greater than 31 char it can't be added to excel. Truncate it here. + if len(title) > 31: + title = title[:31] + + # chartsheet = self.wb.add_chartsheet(title) + ws_chart = self.wb.add_worksheet(title) + if index_row is not None: + index_row = self.add_index_entry(title, index_row) + + chart = self.wb.add_chart({'type': 'scatter', 'subtype': 'straight'}) + # chartsheet.set_chart(chart) + ws_chart.insert_chart('A1', chart, {'x_offset': 25, 'y_offset': 10}) + + chart.set_title({'name': title}) + chart.set_size({'width': 1200, 'height': 600}) + chart.set_x_axis({'name': params.get('plot.x.title', '')}) + chart.set_y_axis({'name': params.get('plot.y.title', '')}) + chart.set_y2_axis({'name': params.get('plot.y2.title', '')}) + chart.set_style(2) + print('ws name = %s' % (ws.get_name())) + + # chart.x_axis.title = params.get('plot.x.title', '') + # chart.y_axis.title = params.get('plot.y.title', '') + + count = params.get('plot.point_value_count', 1) + ws_name = ws.get_name() + categories = [] + + if len(x_points) > 0: + # only support one x point for now + name = x_points[0] + try: + # col = point_names.index(name) + 1 + # categories = [ws_name, 2, 0, count + 1, 0] + col_index = point_names.index(name) + col = xl_col(col_index) + categories = '=%s!$%s$%s:$%s$%s' % (ws_name, col, 2, col, count + 1) + except ValueError: + print('Value error for x point: %s' % (name)) + + if len(y_points) > 0: + for name in y_points: + try: + min_error = params.get('plot.%s.min_error' % name) + max_error = params.get('plot.%s.max_error' % name) + print('min_error, max_error = %s %s' % (min_error, max_error)) + col = point_names.index(name) + line_color = params.get('plot.%s.color' % name, colors[color_idx]) + point = params.get('plot.%s.point' % name, 'False') + if point == 'True': + marker = {'type': 'circle', + 'size': 5, + # 'fill': {'color': line_color} + } + else: + marker = {} + series = { + 'name': name, + 'categories': categories, + 'values': [ws_name, 2, col, count, col], + # 'line': {'color': line_color, 'width': 1.5}, + 'line': {'width': 1.5}, + 'marker': marker, + 'categories_data': [], + 'values_data': [] + } + if min_error and max_error: + min_col = point_names.index(min_error) + max_col = point_names.index(max_error) + series['y_error_bars'] = { + 'type': 'custom', + 'direction': 'both', + # 'value': 10 + 'plus_values': [ws_name, 2, max_col, count, max_col], + 'minus_values': [ws_name, 2, min_col, count, min_col], + 'categories_data': [], + 'values_data': [] + } + print('series = %s' % series) + chart.add_series(series) + color_idx += 1 + + except ValueError: + print('Value error for y1 point: %s' % (name)) + + if len(y2_points) > 0: + for name in y2_points: + try: + col = point_names.index(name) + line_color = params.get('plot.%s.color' % name, colors[color_idx]) + point = params.get('plot.%s.point' % name, 'False') + if point == 'True': + marker = {'type': 'circle', + 'size': 5, + # 'fill': {'color': line_color} + } + else: + marker = {} + chart.add_series({ + 'name': name, + 'categories': categories, + 'values': [ws_name, 2, col, count, col], + # 'line': {'color': line_color, 'width': 1.5}, + 'line': {'width': 1.5}, + 'marker': marker, + 'y2_axis': 1, + 'categories_data': [], + 'values_data': [] + }) + + except ValueError: + print('Value error for y2 point: %s' % (name)) + + return index_row + + def add_csv_file(self, filename, title, relative_value_names=None, params=None, index_row=None): + print('add_csv_file: %s' % (title)) + col_width = [] + line = 1 + # if the excel sheet name is greater than 31 char it can't be added to excel. Truncate it here. + if len(title) > 31: + title = title[:31] + ws = self.wb.add_worksheet(title) + if index_row is not None: + index_row = self.add_index_entry(title, index_row) + f = None + relative_value_index = [] + relative_value_start = [] + if relative_value_names is None: + relative_value_names = [] + if params is None: + params = {} + try: + f = open(filename) + ''' + reader = csv.reader(f, skipinitialspace=True) + print 'reader = %s %s' % (filename, reader) + for row in reader: + ''' + print('filename = %s %s' % (filename, f)) + for rec in f: + row = [x.strip() for x in rec.split(',')] + # print 'row = %s' % (row) + for i in range(len(row)): + try: + v = float(row[i]) + if math.isnan(v) or math.isinf(v): + row[i] = '' + else: + row[i] = v + except ValueError: + pass + # adjust column width if necessary + width = len(str(row[i])) + 4 + if width < XL_COL_WIDTH_DEFAULT: + width = XL_COL_WIDTH_DEFAULT + try: + curr_width = col_width[i] + except IndexError: + curr_width = 0 + if width > curr_width: + col_width.insert(i, width) + ws.set_column(i, i, width) + # find fields to be treated as relative value + if line == 1: + params['plot.point_names'] = row + for i in range(len(row)): + width = len(row[i]) + 4 + if width < XL_COL_WIDTH_DEFAULT: + width = XL_COL_WIDTH_DEFAULT + ws.set_column(i, i, width) + if relative_value_names is not None: + for name in relative_value_names: + try: + index = row.index(name) + relative_value_index.append(index) + except ValueError: + print('Value error for relative value name: %s' % (name)) + # get initial value for relative value fields + elif line == 2: + for index in relative_value_index: + relative_value_start.append(row[index]) + row[index] = 0 + else: + for index in relative_value_index: + row[index] = row[index] - relative_value_start[index] + ws.write_row(line - 1, 0, row) + line += 1 + params['plot.point_value_count'] = line - 1 + + if title[-4:] == '.csv': + chart_title = title[:-4] + else: + chart_title = title + '_chart' + + print('params - plot: %s - %s' % (params, params.get('plot.title'))) + if params is not None and params.get('plot.title') is not None: + index_row = self.add_chart(ws, params=params, index_row=index_row) + + except Exception as e: + print('add_csv_file error: %s' % (str(e))) + raise + finally: + if f: + f.close() + + return index_row + + def save(self, filename=None): + pass + + def close(self): + if self.wb is not None: + self.wb.close() """ Simple XML pretty print support function - """ def xml_indent(elem, level=0): i = "\n" + level*" " @@ -204,11 +553,11 @@ def xml_indent(elem, level=0): result.results.append(result2) xml_str = result.to_xml_str(pretty_print=True) - print xml_str - print result - print '-------------------' + print(xml_str) + print(result) + print('-------------------') result_xml = Result() root = ET.fromstring(xml_str) result_xml.from_xml(root) - print result_xml + print(result_xml) diff --git a/Lib/svpelab/rt_profile.py b/Lib/svpelab/rt_profile.py index 13ba53d..2700c9f 100644 --- a/Lib/svpelab/rt_profile.py +++ b/Lib/svpelab/rt_profile.py @@ -101,8 +101,8 @@ def freq_rt_profile(p_n, p_t, t_f, t_h, t_r, t_d, n): return profile -print voltage_rt_profile(100, 80, 2, 5, 2, 5, 1) -print freq_rt_profile(100, 80, 2, 5, 2, 5, 3) +print(voltage_rt_profile(100, 80, 2, 5, 2, 5, 1)) +print(freq_rt_profile(100, 80, 2, 5, 2, 5, 3)) ''' Each region shall have the applicable ride-through magnitudes and durations verified. diff --git a/Lib/svpelab/sunspec_device_1547.json b/Lib/svpelab/sunspec_device_1547.json new file mode 100644 index 0000000..023819f --- /dev/null +++ b/Lib/svpelab/sunspec_device_1547.json @@ -0,0 +1,704 @@ +{ + "name": "device_1547", + "models": [ + { + "ID": 1, + "Mn": "SunSpecTest", + "Md": "Test-1547-1", + "Opt": "opt_a_b_c", + "Vr": "1.2.3", + "SN": "sn-123456789", + "DA": 1, + "Pad": 0 + }, + { + "ID": 701, + "L": null, + "ACType": 3, + "St": 2, + "InvSt": 4, + "ConnSt": 2, + "Alrm": 11397, + "W": 9800, + "VA": 10000, + "Var": 200, + "PF": 985, + "A": 411, + "LLV": 2400, + "LNV": 2400, + "Hz": 60010, + "TotWhInj": 150, + "TotWhAbs": 0, + "TotVarhInj": 9, + "TotVarhAbs": 0, + "TmpAmb": 450, + "TmpCab": 550, + "TmpSnk": 650, + "TmpTrns": 500, + "TmpSw": 400, + "TmpOt": 420, + "WL1": 3200, + "VAL1": 3333, + "VarL1": 80, + "PFL1": 984, + "AL1": 137, + "VL1L2": 2080, + "VL1": 1200, + "TotWhInjL1": 49, + "TotWhAbsL1": 0, + "TotVarhInjL1": 2, + "TotVarhAbsL1": 0, + "WL2": 3300, + "VAL2": 3333, + "VarL2": 80, + "PFL2": 986, + "AL2": 136, + "VL2L3": 2080, + "VL2": 1200, + "TotWhInjL2": 50, + "TotWhAbsL2": 0, + "TotVarhInjL2": 3, + "TotVarhAbsL2": 0, + "WL3": 3500, + "VAL3": 3333, + "VarL3": 40, + "PFL3": 987, + "AL3": 138, + "VL3L1": 2080, + "VL3": 1200, + "TotWhInjL3": 51, + "TotWhAbsL3": 0, + "TotVarhInjL3": 4, + "TotVarhAbsL3": 0, + "A_SF": -1, + "V_SF": -1, + "Hz_SF": -3, + "W_SF": 0, + "PF_SF": -3, + "VA_SF": 0, + "Var_SF": 0, + "TotWh_SF": 3, + "TotVarh_SF": 3, + "Tmp_SF": -1, + "MnAlrmInfo": "Sample Mfr Alarm" + }, + { + "ID": 702, + "L": null, + "WMaxRtg": 10000, + "WOvrExtRtg": 10000, + "WOvrExtRtgPF": 1000, + "WUndExtRtg": 10000, + "WUndExtRtgPF": 1000, + "VAMaxRtg": 11000, + "VarMaxInjRtg": 2500, + "VarMaxAbsRtg": 0, + "WChaRteMaxRtg": 0, + "WDisChaRteMaxRtg": 0, + "VAChaRteMaxRtg": 0, + "VADisChaRteMaxRtg": 0, + "VNomRtg": 240, + "VMaxRtg": 270, + "VMinRtg": 210, + "AMaxRtg": 50, + "PFOvrExtRtg": 850, + "PFUndExtRtg": 850, + "ReactSusceptRtg": 0.5, + "NorOpCatRtg": 2, + "AbnOpCatRtg": 3, + "CtrlModes": 32767, + "IntIslandCatRtg": 14, + "WMax": 10000, + "WMaxOvrExt": null, + "WOvrExtPF": null, + "WMaxUndExt": null, + "WUndExtPF": null, + "VAMax": 10000, + "AMax": null, + "Vnom": 240, + "VRefOfs": null, + "VMax": 270, + "VMin": 210, + "VarMaxInj": 4400, + "VarMaxAbs": 4400, + "WChaRteMax": 8000, + "WDisChaRteMax": 8000, + "VAChaRteMax": 8000, + "VADisChaRteMax": 8000, + "IntIslandCat": 2, + "W_SF": 0, + "PF_SF": -3, + "VA_SF": 0, + "Var_SF": 0, + "V_SF": 0, + "A_SF": 0, + "S_SF": 0 + }, + { + "ID": 703, + "ES": 1, + "ESVHi": 1050, + "ESVLo": 917, + "ESHzHi": 6010, + "ESHzLo": 5950, + "ESDlyTms": 300, + "ESRndTms": 100, + "ESRmpTms": 60, + "V_SF": -3, + "Hz_SF": -2 + }, + { + "ID": 704, + "L": null, + "PFWInjEna": 0, + "PFWInjRvrtEna": 10, + "PFWInjRvrtTms": 10, + "PFWInjRvrtRem": null, + "PFWAbsEna": 0, + "PFWAbsRvrtEna": 1, + "PFWAbsRvrtTms": 100, + "PFWAbsRvrtRem": null, + "WMaxLimPctEna": 0, + "WMaxLimPct": 1000, + "WMaxLimPctRvrt": 100, + "WMaxLimPctRvrtEna": 0, + "WMaxLimPctRvrtTms": 10, + "WMaxLimPctRvrtRem": null, + "WSetEna": 0, + "WSetMod": 1, + "WSet": 5000, + "WSetRvrt": 4000, + "WSetPct": 100, + "WSetPctRvrt": 100, + "WSetRvrtEna": 1, + "WSetRvrtTms": 10, + "WSetRvrtRem": null, + "VarSetEna": 0, + "VarSetMod": null, + "VarSetPri": null, + "VarSet": null, + "VarSetRvrt": null, + "VarSetPct": null, + "VarSetPctRvrt": null, + "VarSetRvrtTms": null, + "VarSetRvrtRem": null, + "RGra": null, + "PF_SF": -3, + "WMaxLim_SF": -1, + "WSet_SF": 0, + "WSetPct_SF": 0, + "VarSet_SF": 0, + "VarSetPct_SF": 0, + "PFWInj": { + "PF": 950, + "Ext": 1 + }, + "PFWInjRvrt": { + "PF": 920, + "Ext": 0 + }, + "PFWAbs": { + "PF": 85, + "Ext": 1 + }, + "PFWAbsRvrt": { + "PF": 85, + "Ext": 0 + } + }, + { + "ID": 705, + "Ena": 1, + "AdptCrvReq": 0, + "AdptCrvRslt": 1, + "NPt": 4, + "NCrv": 3, + "RvrtTms": 0, + "RvrtRem": 0, + "RvrtCrv": 0, + "V_SF": -2, + "DeptRef_SF": -2, + "RspTms_SF": -2, + "Crv": [ + { + "ActPt": 4, + "DeptRef": 1, + "Pri": 1, + "VRef": 1, + "VRefAuto": 0, + "VRefTms": 5, + "RspTms": 6, + "ReadOnly": 1, + "Pt": [ + { + "V": 9200, + "Var": 3000 + }, + { + "V": 9670, + "Var": 0 + }, + { + "V": 10300, + "Var": 0 + }, + { + "V": 10700, + "Var": -3000 + } + ] + }, + { + "ActPt": 4, + "DeptRef": 1, + "Pri": 1, + "VRef": 1, + "VRefAuto": 0, + "VRefTms": 5, + "RspTms": 6, + "ReadOnly": 0, + "Pt": [ + { + "V": 9300, + "Var": 3000 + }, + { + "V": 9570, + "Var": 0 + }, + { + "V": 10200, + "Var": 0 + }, + { + "V": 10600, + "Var": -4000 + } + ] + }, + { + "ActPt": 4, + "DeptRef": 1, + "Pri": 1, + "VRef": 1, + "VRefAuto": 0, + "VRefTms": 5, + "RspTms": 6, + "ReadOnly": 0, + "Pt": [ + { + "V": 9400, + "Var": 2000 + }, + { + "V": 9570, + "Var": 0 + }, + { + "V": 10500, + "Var": 0 + }, + { + "V": 10800, + "Var": -2000 + } + ] + } + ] + }, + { + "ID": 706, + "Ena": 0, + "AdptCrvReq": 0, + "AdptCrvRslt": 1, + "NPt": 2, + "NCrv": 2, + "RvrtTms": null, + "RvrtRem": null, + "RvrtCrv": null, + "V_SF": 0, + "DeptRef_SF": 0, + "RspTms_SF": -1, + "Crv": [ + { + "ActPt": 2, + "DeptRef": 1, + "RspTms": 10, + "ReadOnly": 1, + "Pt": [ + { + "V": 106, + "W": 100 + }, + { + "V": 110, + "W": 0 + } + ] + }, + { + "ActPt": 2, + "DeptRef": 1, + "RspTms": 5, + "ReadOnly": 0, + "Pt": [ + { + "V": 105, + "W": 100 + }, + { + "V": 109, + "W": 0 + } + ] + } + ] + }, + { + "ID": 707, + "L": null, + "Ena": 1, + "AdptCrvReq": null, + "AdptCrvRslt": 1, + "NPt": 3, + "NCrvSet": 1, + "V_SF": -2, + "Tms_SF": 0, + "RspTms_SF": -1, + "Crv": [ + { + "MustTrip": { + "ActPt": 1, + "Pt": [ + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + }, + "MayTrip": { + "ActPt": 1, + "Pt": [ + { + "V": 7000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + }, + "MomCess": { + "ActPt": 1, + "Pt": [ + { + "V": 6000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + } + } + ] + }, + { + "ID": 708, + "L": null, + "Ena": 1, + "AdptCrvReq": null, + "AdptCrvRslt": 1, + "NPt": 3, + "NCrvSet": 1, + "V_SF": -2, + "Tms_SF": 0, + "Crv": [ + { + "MustTrip": { + "ActPt": 1, + "Pt": [ + { + "V": 12000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + }, + "MayTrip": { + "ActPt": 1, + "Pt": [ + { + "V": 10000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + }, + "MomCess": { + "ActPt": 1, + "Pt": [ + { + "V": 10000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + }, + { + "V": 5000, + "Tms": 5 + } + ] + } + } + ] + }, + { + "ID": 709, + "L": null, + "Ena": 1, + "AdptCrvReq": null, + "AdptCrvRslt": 1, + "NPt": 2, + "NCrvSet": 1, + "Freq_SF": -2, + "Tms_SF": -2, + "Hz_SF": -3, + "Crv": [ + { + "MustTrip": { + "ActPt": 1, + "Pt": [ + { + "Hz": 53000, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + }, + "MayTrip": { + "ActPt": 1, + "Pt": [ + { + "Hz": 58500, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + }, + "MomCess": { + "ActPt": 1, + "Pt": [ + { + "Hz": 58500, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + } + } + ] + }, + { + "ID": 710, + "L": null, + "Ena": null, + "AdptCrvReq": null, + "AdptCrvRslt": 1, + "NPt": 2, + "NCrvSet": 1, + "Hz_SF": -3, + "Tms_SF": -2, + "Crv": [ + { + "MustTrip": { + "ActPt": 1, + "Pt": [ + { + "Hz": 65000, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + }, + "MayTrip": { + "ActPt": 1, + "Pt": [ + { + "Hz": 60500, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + }, + "MomCess": { + "ActPt": 1, + "Pt": [ + { + "Hz": 60500, + "Tms": 5 + }, + { + "Hz": 53000, + "Tms": 5 + } + ] + } + } + ] + }, + { + "ID": 711, + "L": null, + "Ena": null, + "AdptCtlReq": null, + "AdptCtlRslt": 1, + "NCtl": 1, + "RvrtTms": 0, + "RvrtRem": 0, + "RvrtCrv": 0, + "Db_SF": -3, + "K_SF": -2, + "RspTms_SF": 0, + "Ctl": [ + { + "DbOf": 60030, + "DbUf": 59970, + "KOf": 40, + "KUf": 40, + "RspTms": 600 + } + ] + }, + { + "ID": 712, + "L": null, + "Ena": 0, + "AdptCrvReq": null, + "AdptCrvRslt": 1, + "NPt": 6, + "NCrv": 1, + "RvrtTms": 0, + "RvrtRem": 0, + "RvrtCrv": 0, + "DeptRef_SF": -2, + "W_SF": -2, + "Crv": [ + { + "ActPt": 6, + "DeptRef": 1, + "Pri": null, + "ReadOnly": null, + "Pt": [ + { + "W": -10000, + "Var": 4400 + }, + { + "W": -5000, + "Var": 0 + }, + { + "W": -2000, + "Var": 0 + }, + { + "W": 2000, + "Var": 0 + }, + { + "W": 5000, + "Var": 0 + }, + { + "W": 10000, + "Var": -4400 + } + ] + } + ] + }, + { + "ID": 713, + "L": null, + "PrtAlrms": null, + "NPrt": 1, + "DCA": null, + "DCW": null, + "DCWhInj": null, + "DCWhAbs": null, + "DCA_SF": -2, + "DCV_SF": -1, + "DCW_SF": -1, + "DCWH_SF": -1, + "Prt": [ + { + "PrtTyp": null, + "ID": null, + "IDStr": null, + "DCA": null, + "DCV": null, + "DCW": null, + "DCWhInj": null, + "DCWhAbs": null, + "Tmp": null, + "DCSt": null, + "DCAlrm": null + } + ] + }, + { + "ID": 65535, + "L": 0 + } + ] +} \ No newline at end of file diff --git a/Lib/svpelab/svp_ext_result.py b/Lib/svpelab/svp_ext_result.py index 57f3378..23c4825 100644 --- a/Lib/svpelab/svp_ext_result.py +++ b/Lib/svpelab/svp_ext_result.py @@ -2,13 +2,383 @@ import os import wxmplot import numpy +import openpyxl +from . import result as rslt +import csv +import xlsxwriter +''' def menu(result, result_dir, result_name): if result is not None and result.filename is not None: ext = os.path.splitext(result.filename)[1] if ext == '.csv': rm = ResultMenu(result, result_dir, result_name) return rm.menu_items +''' + +def menu(result, result_dir, result_name): + if result is not None: + rm = ResultMenu(result, result_dir, result_name) + return rm.menu_items + + +class ResultWorkbook(object): + + def __init__(self, filename): + self.wb = xlsxwriter.Workbook(filename) + + ''' + # remove initial sheet that is added at creation + self.wb.remove_sheet(self.wb.active) + ''' + + def add_chart(self, ws, params=None): + # get fieldnames in first row of worksheet + colors = ['blue', 'green', 'purple', 'orange', 'red', 'brown', 'yellow'] + color_idx = 0 + point_names = params.get('plot.point_names', []) + + x_points = [] + y_points = [] + y2_points = [] + if params is not None: + points = params.get('plot.x.points') + if points is not None: + x_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y.points') + if points is not None: + y_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y2.points') + if points is not None: + y2_points = [x.strip() for x in points.split(',')] + + title = params.get('plot.title', '') + chartsheet = self.wb.add_chartsheet(title) + chart = self.wb.add_chart({'type': 'scatter', 'subtype': 'straight'}) + chartsheet.set_chart(chart) + + chart.set_title({'name': title}) + chart.set_x_axis({'name': params.get('plot.x.title', '')}) + chart.set_y_axis({'name': params.get('plot.y.title', '')}) + chart.set_y2_axis({'name': params.get('plot.y2.title', '')}) + chart.set_style(1) + print('ws name = %s' % (ws.get_name())) + + # chart.x_axis.title = params.get('plot.x.title', '') + # chart.y_axis.title = params.get('plot.y.title', '') + + count = params.get('plot.point_value_count', 1) + ws_name = ws.get_name() + categories = [] + + if len(x_points) > 0: + # only support one x point for now + name = x_points[0] + try: + col = point_names.index(name) + 1 + categories = [ws_name, 3, 0, count, 0] + except ValueError: + pass + + if len(y_points) > 0: + for name in y_points: + try: + col = point_names.index(name) + print('col = %s' % col) + line_color = params.get('plot.%s.color' % name, colors[color_idx]) + point = params.get('plot.%s.point' % name, 'False') + if point == 'True': + marker = {'type': 'circle', + 'size': 6, + 'fill': {'color': line_color} + } + else: + marker = {} + chart.add_series({ + 'name': name, + 'categories': categories, + 'values': [ws_name, 3, col, count, col], + 'line': {'color': line_color, 'width': 1.5}, + 'marker' : marker + }) + color_idx += 1 + + except ValueError: + pass + + if len(y2_points) > 0: + for name in y2_points: + try: + col = point_names.index(name) + print('col = %s' % col) + line_color = params.get('plot.%s.color' % name, colors[color_idx]) + point = params.get('plot.%s.point' % name, 'False') + if point == 'True': + marker = {'type': 'circle', + 'size': 6, + 'fill': {'color': line_color} + } + else: + marker = {} + chart.add_series({ + 'name': name, + 'categories': categories, + 'values': [ws_name, 3, col, count, col], + 'line': {'color': line_color, 'width': 1.5}, + 'marker' : marker, + 'y2_axis': 1 + }) + + except ValueError: + pass + + ''' + idx = self.wb.sheetnames.index(ws.title) - 1 + if idx < 0: + idx = 0 + cs = self.wb.create_chartsheet(title=params.get('plot.title', None), index=idx) + cs.add_chart(chart) + ''' + + def add_csv_file(self, filename, title, relative_value_names=None, params=None): + line = 1 + ws = self.wb.add_worksheet(title) + f = None + relative_value_index = [] + relative_value_start = [] + if relative_value_names is None: + relative_value_names = [] + if params is None: + params = {} + try: + f = open(filename) + reader = csv.reader(f, skipinitialspace=True) + for row in reader: + for i in range(len(row)): + try: + row[i] = float(row[i]) + except ValueError: + pass + # find fields to be treated as relative value + if line == 1: + params['plot.point_names'] = row + if relative_value_names is not None: + for name in relative_value_names: + try: + index = row.index(name) + relative_value_index.append(index) + except ValueError: + pass + # get initial value for relative value fields + elif line == 2: + for index in relative_value_index: + relative_value_start.append(row[index]) + row[index] = 0 + else: + for index in relative_value_index: + row[index] = row[index] - relative_value_start[index] + line += 1 + ws.write_row(line - 1, 0, row) + params['plot.point_value_count'] = line - 1 + + if title[-4:] == '.csv': + chart_title = title[:-4] + else: + chart_title = title + '_chart' + + print('params - plot: %s - %s' % (params, params.get('plot.title'))) + if params is not None and params.get('plot.title') is not None: + self.add_chart(ws, params=params) + + ''' + self.add_chart(ws, params={'plot.title': chart_title, + 'plot.x.title': 'Time (secs)', + 'plot.x.points': 'TIME', + 'plot.y.points': 'AC_VRMS_1', + 'plot.y.title': 'Voltage (V)', + 'plot.y2.points': 'AC_IRMS_1', + 'plot.y2.title': 'Current (A)'}) + ''' + + except Exception as e: + raise + finally: + if f: + f.close() + + def save(self, filename=None): + if filename: + self.filename = filename + self.wb.save(self.filename) + + def close(self): + if self.wb is not None: + self.wb.close() + + +class ResultWorkbookOPX(object): + + def __init__(self, filename=None): + self.wb = openpyxl.Workbook() + self.filename = None + + # remove initial sheet that is added at creation + self.wb.remove_sheet(self.wb.active) + + def add_chart(self, ws, params=None): + # get fieldnames in first row of worksheet + colors = ['blue', 'green', 'purple', 'orange', 'red'] + color_idx = 0 + point_names = [] + for c in ws.rows[0]: + point_names.append(c.value) + + x_points = [] + y_points = [] + y2_points = [] + if params is not None: + points = params.get('plot.x.points') + if points is not None: + x_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y.points') + if points is not None: + y_points = [x.strip() for x in points.split(',')] + points = params.get('plot.y2.points') + if points is not None: + y2_points = [x.strip() for x in points.split(',')] + + chart = openpyxl.chart.ScatterChart(scatterStyle='line') + chart.title = params.get('plot.title', None) + chart.style = 13 + chart.x_axis.title = params.get('plot.x.title', '') + chart.y_axis.title = params.get('plot.y.title', '') + + x_values = None + if len(x_points) > 0: + # only support one x point for now + name = x_points[0] + try: + col = point_names.index(name) + 1 + print('x: %s %s' % (col, ws.max_row)) + x_values = openpyxl.chart.Reference(ws, min_col=col, min_row=2, max_row=ws.max_row) + except ValueError: + pass + + if len(y_points) > 0: + for name in y_points: + try: + col = point_names.index(name) + 1 + values = openpyxl.chart.Reference(ws, min_col=col, min_row=2, max_row=ws.max_row) + series = openpyxl.chart.Series(values, x_values, title=name) + + # lineProp = drawing.line.LineProperties(prstDash='dash') + lineProp = openpyxl.drawing.line.LineProperties( + solidFill = openpyxl.drawing.colors.ColorChoice(prstClr=colors[color_idx])) + color_idx += 1 + series.graphicalProperties.line = lineProp + series.graphicalProperties.line.width = 20000 # width in EMUs + chart.series.append(series) + + except ValueError: + pass + + if len(y2_points) > 0: + for name in y2_points: + try: + col = point_names.index(name) + 1 + values = openpyxl.chart.Reference(ws, min_col=col, min_row=2, max_row=ws.max_row) + series = openpyxl.chart.Series(values, x_values, title=name) + + # lineProp = drawing.line.LineProperties(prstDash='dash') + lineProp = openpyxl.drawing.line.LineProperties( + solidFill = openpyxl.drawing.colors.ColorChoice(prstClr=colors[color_idx])) + color_idx += 1 + series.graphicalProperties.line = lineProp + series.graphicalProperties.line.width = 20000 # width in EMUs + chart2 = openpyxl.chart.ScatterChart(scatterStyle='line') + chart2.style = 13 + # chart.y_axis.title = params.get('plot.y.title', '') + chart2.series.append(series) + chart2.y_axis.axId = 200 + chart2.y_axis.title = params.get('plot.y2.title', '') + chart.y_axis.crosses = "max" + chart += chart2 + + except ValueError: + pass + + idx = self.wb.sheetnames.index(ws.title) - 1 + if idx < 0: + idx = 0 + cs = self.wb.create_chartsheet(title=params.get('plot.title', None), index=idx) + cs.add_chart(chart) + + def add_csv_file(self, filename, title, relative_value_names=None, params=None): + line = 1 + ws = self.wb.create_sheet(title=title) + f = None + relative_value_index = [] + relative_value_start = [] + if relative_value_names is None: + relative_value_names = [] + try: + f = open(filename) + reader = csv.reader(f, skipinitialspace=True) + for row in reader: + for i in range(len(row)): + try: + row[i] = float(row[i]) + except ValueError: + pass + # find fields to be treated as relative value + if line == 1: + line += 1 + if relative_value_names is not None: + for name in relative_value_names: + try: + index = row.index(name) + relative_value_index.append(index) + except ValueError: + pass + # get initial value for relative value fields + elif line == 2: + line += 1 + for index in relative_value_index: + relative_value_start.append(row[index]) + row[index] = 0 + else: + for index in relative_value_index: + row[index] = row[index] - relative_value_start[index] + ws.append(row) + + if title[-4:] == '.csv': + chart_title = title[:-4] + else: + chart_title = title + '_chart' + + print('params - plot: %s - %s' % (params, params.get('plot.title'))) + if params is not None and params.get('plot.title') is not None: + self.add_chart(ws, params=params) + + """ + self.add_chart(ws, params={'plot.title': chart_title, + 'plot.x.title': 'Time (secs)', + 'plot.x.points': 'TIME', + 'plot.y.points': 'AC_VRMS_1', + 'plot.y.title': 'Voltage (V)', + 'plot.y2.points': 'AC_IRMS_1', + 'plot.y2.title': 'Current (A)'}) + """ + except Exception as e: + raise + finally: + if f: + f.close() + + def save(self, filename=None): + if filename: + self.filename = filename + self.wb.save(self.filename) class ResultMenu(object): @@ -22,6 +392,9 @@ def __init__(self, result, result_dir, result_name): ('Pyplot', '', None, self.plot_pyplot, None)] self.menu_items = [('Open with', '', self.menu_open_items, None, None), + ('', '', None, None, None), + ('Create Excel Workbook (.xlsx)', '', None, self.create_xlsx, None), + ('Create Excel Workbook Alt(.xlsx)', '', None, self.create_xlsx_alt, None), ('', '', None, None, None), ('Other', '', None, None, None)] @@ -29,6 +402,65 @@ def __init__(self, result, result_dir, result_name): def result_other(self, arg=None): pass + def create_xlsx(self, arg=None): + filename = os.path.join(self.result_dir, self.result_name, self.result_name + '.xlsx') + print('creating result: %s %s %s' % (self.result_dir, self.result_name, self.result_name)) + self.to_xlsx(self.result, filename=filename) + + def to_xlsx(self, r, wb=None, filename=None): + ''' + self.params={'plot.title': self.name, + 'plot.x.title': 'Time (secs)', + 'plot.x.points': 'TIME', + 'plot.y.points': 'AC_VRMS_1', + 'plot.y.title': 'Voltage (V)', + 'plot.y2.points': 'AC_IRMS_1', + 'plot.y2.title': 'Current (A)'} + ''' + + result_wb = wb + if result_wb is None: + result_wb = ResultWorkbook(filename=filename) + if r.type == rslt.RESULT_TYPE_FILE: + name, ext = os.path.splitext(r.filename) + if ext == '.csv': + result_wb.add_csv_file(os.path.join(self.result_dir, self.result_name, r.filename), r.name, + relative_value_names = ['TIME'], params=r.params) + for result in r.results: + self.to_xlsx(result, wb=result_wb) + if wb is None: + result_wb.close() + + def create_xlsx_alt(self, arg=None): + filename = os.path.join(self.result_dir, self.result_name, self.result_name + '.xlsx') + print('creating result: %s %s %s' % (self.result_dir, self.result_name, self.result_name)) + self.to_xlsx_alt(self.result, filename=filename) + + def to_xlsx_alt(self, r, wb=None, filename=None): + ''' + self.params={'plot.title': self.name, + 'plot.x.title': 'Time (secs)', + 'plot.x.points': 'TIME', + 'plot.y.points': 'AC_VRMS_1', + 'plot.y.title': 'Voltage (V)', + 'plot.y2.points': 'AC_IRMS_1', + 'plot.y2.title': 'Current (A)'} + ''' + + result_wb = wb + if result_wb is None: + result_wb = ResultWorkbookOPX(filename=filename) + if r.type == rslt.RESULT_TYPE_FILE: + name, ext = os.path.splitext(r.filename) + if ext == '.csv': + result_wb.add_csv_file(os.path.join(self.result_dir, self.result_name, r.filename), r.name, + relative_value_names = ['TIME'], params=r.params) + for result in r.results: + self.to_xlsx(result, wb=result_wb) + if wb is None: + print('saving') + result_wb.save(filename=filename) + def plot_wxmplot(self, arg=None): frame = wxmplot.PlotFrame() filename = os.path.join(self.result_dir, self.result_name, self.result.filename) @@ -50,8 +482,8 @@ def plot_wxmplot(self, arg=None): try: v = float(values[i]) value_arrays[i].append(v) - except Exception, e: - value_arrays[i].append('nan') + except Exception as e: + value_arrays[i].append(0) pass time_array = numpy.array(value_arrays[0]) @@ -78,6 +510,21 @@ def plot_wxmplot(self, arg=None): frame.Show() def plot_pyplot(self, arg=None): - print 'plot_pyplot' + print('plot_pyplot') + + +if __name__ == "__main__": + params={'plot.title': 'title_name', + 'plot.x.title': 'Time (secs)', + 'plot.x.points': 'TIME', + 'plot.y.points': 'AC_Q_1, Q_TARGET', + 'plot.Q_TARGET.point': 'True', + 'plot.y.title': 'Reactive Power (var)', + 'plot.y2.points': 'AC_VRMS_1', + 'plot.y2.title': 'Voltage (V)' + } + wb = ResultWorkbook('worktest.xlsx') + wb.add_csv_file('vv.csv', 'title', relative_value_names=['TIME'], params=params) + wb.close() diff --git a/Lib/svpelab/switch.py b/Lib/svpelab/switch.py index 0b56ad9..809c4e8 100644 --- a/Lib/svpelab/switch.py +++ b/Lib/svpelab/switch.py @@ -41,6 +41,7 @@ switch_modules = {} + def params(info, id=None, label='Switch Controller', group_name=None, active=None, active_value=None): if group_name is None: group_name = SWITCH_DEFAULT_ID @@ -48,16 +49,17 @@ def params(info, id=None, label='Switch Controller', group_name=None, active=Non group_name += '.' + SWITCH_DEFAULT_ID if id is not None: group_name += '_' + str(id) - print 'group_name = %s' % group_name + print('group_name = %s' % group_name) name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) - print 'name = %s' % name('mode') + print('name = %s' % name('mode')) info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) - for mode, m in switch_modules.iteritems(): + for mode, m in switch_modules.items(): m.params(info, group_name=group_name) SWITCH_DEFAULT_ID = 'switch' + def switch_init(ts, id=None, group_name=None): """ Function to create specific switch implementation instances. @@ -69,15 +71,18 @@ def switch_init(ts, id=None, group_name=None): if id is not None: group_name = group_name + '_' + str(id) mode = ts.param_value(group_name + '.' + 'mode') + # ts.log_debug('group_name, %s, mode: %s' % (group_name, mode)) sim = None if mode != 'Disabled': + # ts.log_debug('mode, %s' % (mode)) switch_module = switch_modules.get(mode) + # ts.log_debug('switch_module, %s, switch_modules: %s' % (switch_module, switch_modules)) if switch_module is not None: - sm = switch_module.Switch(ts, group_name) + sim = switch_module.Switch(ts, group_name) else: raise SwitchError('Unknown switch controller mode: %s' % mode) - return sm + return sim class SwitchError(Exception): @@ -172,7 +177,7 @@ def switch_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] raise SwitchError('Error scanning module %s: %s' % (module_name, str(e))) diff --git a/Lib/svpelab/switch_manual.py b/Lib/svpelab/switch_manual.py index bcaa6a1..700083d 100644 --- a/Lib/svpelab/switch_manual.py +++ b/Lib/svpelab/switch_manual.py @@ -31,8 +31,8 @@ """ import os -import switch -import device_switch_manual +from . import switch +from . import device_switch_manual manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], diff --git a/Lib/svpelab/switch_typhoon.py b/Lib/svpelab/switch_typhoon.py new file mode 100644 index 0000000..604fd71 --- /dev/null +++ b/Lib/svpelab/switch_typhoon.py @@ -0,0 +1,66 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import os +from . import switch +from . import device_switch_typhoon + +typhoon_info = { + 'name': os.path.splitext(os.path.basename(__file__))[0], + 'mode': 'Typhoon' +} + +def switch_info(): + return typhoon_info + +def params(info, group_name): + gname = lambda name: group_name + '.' + name + pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name + mode = typhoon_info['mode'] + info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, + active=gname('mode'), active_value=mode, glob=True) + info.param(pname('name'), label='Switch Name', default='Anti-islanding1.Grid') + +GROUP_NAME = 'typhoon' + + +class Switch(switch.Switch): + + def __init__(self, ts, group_name): + switch.Switch.__init__(self, ts, group_name) + self.params['name'] = self._param_value('name') + self.params['ts'] = ts + self.device = device_switch_typhoon.Device(self.params) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) diff --git a/Lib/svpelab/vxi11.py b/Lib/svpelab/vxi11.py index 074bf30..7312bff 100644 --- a/Lib/svpelab/vxi11.py +++ b/Lib/svpelab/vxi11.py @@ -26,7 +26,7 @@ import random import re -import vxi11_rpc as rpc +from . import vxi11_rpc as rpc # VXI-11 RPC constants diff --git a/Lib/svpelab/vxi11_rpc.py b/Lib/svpelab/vxi11_rpc.py index e61e64b..faebbef 100644 --- a/Lib/svpelab/vxi11_rpc.py +++ b/Lib/svpelab/vxi11_rpc.py @@ -703,7 +703,7 @@ def session(self, connection): except EOFError: break except socket.error: - print('socket error:', sys.exc_info()[0]) + print(('socket error:', sys.exc_info()[0])) break reply = self.handle(call) if reply is not None: diff --git a/Lib/svpelab/waveform.py b/Lib/svpelab/waveform.py index 77c186e..33f8971 100644 --- a/Lib/svpelab/waveform.py +++ b/Lib/svpelab/waveform.py @@ -41,7 +41,7 @@ class WaveformError(Exception): class Waveform(object): - def __init__(self): + def __init__(self, ts=None): self.start_time = 0 # waveform start time self.sample_count = 0 # size of waveform/per channel self.sample_rate = 0 # samples/second @@ -49,6 +49,7 @@ def __init__(self): self.channels = [] # channel names self.channel_data = [] # waveform curves self.rms_data = {} # rms data calculated from waveform data + self.ts = ts def from_csv(self, filename, sep=','): f = open(filename, 'r') @@ -71,11 +72,18 @@ def from_csv(self, filename, sep=','): for i in range(chan_count): self.channel_data.append(chans[i]) + def from_dataset(self, ds=None): + if ds is not None: + self.start_time = ds.start_time # waveform start time + self.sample_rate = ds.sample_rate # samples/second + self.channels = ds.points + self.channel_data = ds.data + def to_csv(self, filename): f = open(filename, 'w') chan_count = len(self.channels) f.write('%s\n' % ','.join(self.channels)) - for i in xrange(len(self.channel_data[0])): + for i in range(len(self.channel_data[0])): data = [] for c in range(chan_count): data.append(self.channel_data[c][i]) @@ -98,7 +106,14 @@ def compute_cycle_rms(self, chan_id): c = chan_id chan_index = self.channels.index(c) except Exception: - raise WaveformError('Channel not found: %s' % (c)) + try: + c = 'TIME' + time_index = self.channels.index(c) + c = chan_id + chan_index = self.channels.index(c) + except Exception: + raise WaveformError('Channel not found: %s' % (c)) + time_chan = self.channel_data[time_index] data_chan = self.channel_data[chan_index] scanning = False @@ -141,15 +156,15 @@ def compute_rms_data(self, phase): if __name__ == "__main__": - wf= Waveform() - wf.from_csv('c:\users\\bob\\waveforms\\sandia\\capture_1.csv') + wf = Waveform() + wf.from_csv('c:\\users\\bob\\waveforms\\sandia\\capture_1.csv') ''' rms_time, rms_data = wf.compute_cycle_rms('AC_V_1') print rms_time print rms_data ''' wf.compute_rms_data(1) - print wf.rms_data + print(wf.rms_data) diff --git a/Lib/svpelab/waveform_analysis.py b/Lib/svpelab/waveform_analysis.py index 293bb64..95d868a 100644 --- a/Lib/svpelab/waveform_analysis.py +++ b/Lib/svpelab/waveform_analysis.py @@ -34,27 +34,27 @@ Questions can be directed to support@sunspec.org """ -from __future__ import division + try: from prettytable import PrettyTable -except Exception, e: +except Exception as e: print('Error: prettytable python package not found!') # This will appear in the SVP log file. try: from matplotlib.mlab import find import matplotlib.pyplot as plt -except Exception, e: +except Exception as e: print('Error: matplotlib python package not found!') # This will appear in the SVP log file. try: import numpy as np -except Exception, e: +except Exception as e: print('Error: numpy python package not found!') # This will appear in the SVP log file. try: import math -except Exception, e: +except Exception as e: print('Error: math python package not found!') # This will appear in the SVP log file. def calc_ride_through_duration(wfmtime, ac_current, ac_voltage=None, grid_trig=None, v_window=20., trip_thresh=3.): @@ -153,7 +153,7 @@ def freq_from_crossings(wfmtime, sig, fs): """ try: from scipy import signal - except Exception, e: + except Exception as e: print('Error: scipy python package not found!') # This will appear in the SVP log file. # FILTER THE WAVEFORM WITH LOWPASS BUTTERWORTH FILTER @@ -194,8 +194,8 @@ def freq_from_crossings(wfmtime, sig, fs): time_steps = np.diff(crossings) avg_freq = fs / np.average(time_steps) - freqs = [fs/time_steps[i] for i in xrange(0, len(cross_times)-1)] #interpolate - freq_times = [(cross_times[i]+cross_times[i+1])/2 for i in xrange(0, len(freqs)-1)] #interpolate + freqs = [fs/time_steps[i] for i in range(0, len(cross_times)-1)] #interpolate + freq_times = [(cross_times[i]+cross_times[i+1])/2 for i in range(0, len(freqs)-1)] #interpolate # plt.plot(wfmtime, sig, color='red', label='Original') # plt.plot(wfmtime, sig_ff, color='blue', label='Filtered data') diff --git a/Lib/svpelab/wavegen.py b/Lib/svpelab/wavegen.py index b17c5ac..94afb1e 100644 --- a/Lib/svpelab/wavegen.py +++ b/Lib/svpelab/wavegen.py @@ -44,12 +44,12 @@ def params(info, id=None, label='Waveform Generator', group_name=None, active=No group_name += '.' + WAVEGEN_DEFAULT_ID if id is not None: group_name += '_' + str(id) - print 'group_name = %s' % group_name + print('group_name = %s' % group_name) name = lambda name: group_name + '.' + name info.param_group(group_name, label='%s Parameters' % label, active=active, active_value=active_value, glob=True) - print 'name = %s' % name('mode') + print('name = %s' % name('mode')) info.param(name('mode'), label='Mode', default='Disabled', values=['Disabled']) - for mode, m in wavegen_modules.iteritems(): + for mode, m in wavegen_modules.items(): m.params(info, group_name=group_name) WAVEGEN_DEFAULT_ID = 'wavegen' @@ -120,14 +120,11 @@ def close(self): raise WavegenError('Wavegen device not initialized') self.device.close() - def load_config(self, params): + def load_config(self,sequence): """ - Enable channels - :param params: dict containing following possible elements: - 'sequence_filename': - :return: + Load configuration """ - self.device.load_config(params=params) + self.device.load_config(sequence=sequence) def start(self): """ @@ -143,21 +140,46 @@ def stop(self): """ self.device.stop() - def chan_enable(self, chans): + def chan_state(self, chans): """ Enable channels :param chans: list of channels to enable :return: """ - self.device.chan_enable(chans=chans) + self.device.chan_state(chans=chans) + + - def chan_disable(self, chans): + def voltage(self, voltage, channel): """ - Disable channels - :param chans: list of channels to disable - :return: + Change the voltage value of individual channel + :param voltage: The amplitude of the waveform + :param channel: Channel to configure + """ + self.device.voltage(voltage=voltage, channel=channel) + + def frequency(self, frequency): + """ + Change the voltage value of individual channel + :param frequency: The frequency of the waveform on all channels + """ + self.device.frequency(frequency=frequency) + + def phase(self, phase, channel): + """ + Change the voltage value of individual channel + :param phase: This command sets the phase on selected channel + :param channel: Channel(s) to configure + """ + self.device.phase(phase=phase, channel=channel) + + def config_asymmetric_phase_angles(self, mag=None, angle=None): + """ + :param mag: list of voltages for the imbalanced test, e.g., [277.2, 277.2, 277.2] + :param angle: list of phase angles for the imbalanced test, e.g., [0, 120, -120] + :returns: voltage list and phase list """ - self.device.chan_enable(chans=chans) + return None, None def wavegen_scan(): global wavegen_modules @@ -180,7 +202,7 @@ def wavegen_scan(): else: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] - except Exception, e: + except Exception as e: if module_name is not None and module_name in sys.modules: del sys.modules[module_name] raise WavegenError('Error scanning module %s: %s' % (module_name, str(e))) diff --git a/Lib/svpelab/wavegen_awg400.py b/Lib/svpelab/wavegen_awg400.py index 5b57667..f498f34 100644 --- a/Lib/svpelab/wavegen_awg400.py +++ b/Lib/svpelab/wavegen_awg400.py @@ -33,8 +33,8 @@ import os import script -import device_awg400 -import wavegen +from . import device_awg400 +from . import wavegen awg400_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -51,16 +51,9 @@ def params(info, group_name): info.param_add_value(gname('mode'), mode) info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode,active=gname('mode'), active_value=mode, glob=True) info.param(pname('comm'), label='Communications Interface', default='VISA', values=['Network','VISA', 'GPIB']) - + info.param(pname('gen_mode'), label='Function Generator mode', default='ON', values=['ON', 'OFF']) info.param(pname('visa_address'), label='VISA address', active=pname('comm'), active_value=['VISA'],default='GPIB0::10::INSTR') - - info.param(pname('ip_addr'), label='IP Address',active=pname('comm'), active_value=['Network'], default='192.168.0.10') - - info.param(pname('sequence_filename'), label='Sequence File', default='') - - info.param(pname('chan_1'), label='Channel 1', default='Enabled', values=['Enabled', 'Disabled']) - info.param(pname('chan_2'), label='Channel 2', default='Enabled', values=['Enabled', 'Disabled']) - info.param(pname('chan_3'), label='Channel 3', default='Enabled', values=['Enabled', 'Disabled']) + info.param(pname('ip_addr'), label='IP Address',active=pname('comm'), active_value=['Network'], default='10.0.0.115') GROUP_NAME = 'awg400' @@ -73,13 +66,10 @@ class Wavegen(wavegen.Wavegen): def __init__(self, ts, group_name, points=None): wavegen.Wavegen.__init__(self, ts, group_name) - + self.params['comm'] = self._param_value('comm') + self.params['gen_mode'] = self._param_value('gen_mode') self.params['ip_addr'] = self._param_value('ip_addr') self.params['visa_address'] = self._param_value('visa_address') - self.params['sequence_filename'] = self._param_value('sequence_filename') - self.params['chan_1'] = self._param_value('chan_1') - self.params['chan_2'] = self._param_value('chan_2') - self.params['chan_3'] = self._param_value('chan_3') self.device = device_awg400.Device(self.params) @@ -87,7 +77,6 @@ def _param_value(self, name): return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) - if __name__ == "__main__": pass diff --git a/Lib/svpelab/wavegen_manual.py b/Lib/svpelab/wavegen_manual.py index 5f93d85..36aa900 100644 --- a/Lib/svpelab/wavegen_manual.py +++ b/Lib/svpelab/wavegen_manual.py @@ -32,8 +32,8 @@ import os -import device_wavegen_manual -import wavegen +from . import device_wavegen_manual +from . import wavegen manual_info = { 'name': os.path.splitext(os.path.basename(__file__))[0], @@ -48,6 +48,14 @@ def params(info, group_name): pname = lambda name: group_name + '.' + GROUP_NAME + '.' + name mode = manual_info['mode'] info.param_add_value(gname('mode'), mode) + info.param_group(gname(GROUP_NAME), label='%s Parameters' % mode, active=gname('mode'), active_value=mode, + glob=True) + info.param(pname('comm'), label='Communications Interface', default='VISA', values=['Network', 'VISA', 'GPIB']) + info.param(pname('gen_mode'), label='Function Generator mode', default='ON', values=['ON', 'OFF']) + info.param(pname('visa_address'), label='VISA address', active=pname('comm'), active_value=['VISA'], + default='GPIB0::10::INSTR') + info.param(pname('ip_addr'), label='IP Address', active=pname('comm'), active_value=['Network'], + default='10.0.0.115') GROUP_NAME = 'manual' @@ -55,4 +63,13 @@ class Wavegen(wavegen.Wavegen): def __init__(self, ts, group_name, points=None): wavegen.Wavegen.__init__(self, ts, group_name) - self.device = device_wavegen_manual.Device() + self.params['comm'] = self._param_value('comm') + self.params['gen_mode'] = self._param_value('gen_mode') + self.params['ip_addr'] = self._param_value('ip_addr') + self.params['visa_address'] = self._param_value('visa_address') + + self.device = device_wavegen_manual.Device(self.params) + + def _param_value(self, name): + return self.ts.param_value(self.group_name + '.' + GROUP_NAME + '.' + name) + diff --git a/README.md b/README.md index 9a064f8..feb5968 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ -# svp_energy_lab -SVP library that enables communication with energy lab equipment. +# Description +System Validation Platform (SVP) library that enables communication with energy lab equipment. + +# Installation and Configuration +To get the SVP operational, download the following repositories: +* [The SVP graphical user interface](https://github.com/jayatsandia/svp) +* [The SVP energy lab code](https://github.com/jayatsandia/svp_energy_lab) +* [IEEE 1547.1 working directory](https://github.com/jayatsandia/svp_1547.1) or another working directory +* Install [pysunspec2](https://github.com/sunspec/pysunspec2) from the latest release (it may also be pip-able now) + +Install the SVP dependencies using this [guide](https://github.com/jayatsandia/svp/blob/master/doc/INSTALL.md). It’s recommended to use Python 3.7.X because there were some issues reported for 3.9+. + +To run an SVP test, you will need to do the following: +1. Copy the `Lib` directory from the svp_energy_lab into the `Lib` folder in svp_1547.1 (we decided to keep a single, separate copy of the drivers in svp_energy_lab to simplify management). When you do this, leave the `p1547.py` file in the svpelab subdirectory that comes with svp_1547.1. You will need that. +2. Run the `ui.py` code in the opensvp to generate the GUI. +3. Navigate to the Interoperability Test in `tests` in the left pane of the GUI, right click -> edit. + * Set the DER1547 Parameters – Mode: SunSpec + * Set the Interface to “Mapped SunSpec Device” + * Set the map file to the path to the `Lib/svpelab/sunspec_device_1547.json` file. (This contains a file representing the PV inverter that is compliant the SunSpec Modbus protocol). +4. Run the test and verify it works. +5. Edit the InteroperatiblityTests.py file in the Scripts directory. +6. When you run the test the code in the `test_run()` function runs. If you would like something new printed to the screen you can write something like this `ts.log('Hello World')`. If you would like to see registers of the simulated DER device you can print those too. Change the code to this: +``` +# initialize DER configuration +eut = der1547.der1547_init(ts) +eut.config() +ts.log_debug('SunSpec info: %s' % eut.print_modbus_map(w_labels=True)) +``` +7. Note, this will call the `print_modbus_map()` method in the `Lib` folder in `der1547_modbus.py` that will print out all the registers in the simulated DER device. Play with changing parameters and rerunning the tests. + +Some noteworthy items: +* See how the test setup parameters using `info.param()`. The user can try to make some new parameters. When you restart the svp these will appear as new options in the test! +* Look at how there are different abstraction layers for the major components (`gridsim.py`, `hil.py`, etc.) The abstraction layers are in the `Lib` folder and will add options based on the files that have `gridsim_*.py` file names. +* You may want to create new drivers for the lab equipment that you have. For instance, if you have an Omicron DC Simulator, you would make a new `pvsim_omicron.py` file if it doesn’t exist already. You will need to make it uniquely named so the abstraction layer will find it, but then it will appear in the pull down menu in the GUI. + diff --git a/Scripts/battsim_test.py b/Scripts/battsim_test.py index a8d7887..f8081ad 100644 --- a/Scripts/battsim_test.py +++ b/Scripts/battsim_test.py @@ -38,7 +38,7 @@ lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') if lib_path not in sys.path: sys.path.append(lib_path) -print sys.path +print(sys.path) # place script library imports here from svpelab import battsim @@ -57,7 +57,7 @@ def test_run(): result = script.RESULT_COMPLETE - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -88,7 +88,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/das_make_data.py b/Scripts/das_make_data.py new file mode 100644 index 0000000..f1a36a0 --- /dev/null +++ b/Scripts/das_make_data.py @@ -0,0 +1,154 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import sys +import os +import traceback + +# needed if running script stand alone +lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') +if lib_path not in sys.path: + sys.path.append(lib_path) +print(sys.path) + +# place script library imports here +from svpelab import das +from svpelab import hil +from svpelab import result as rslt +import script +import openpyxl + +def test_run(): + + result = script.RESULT_FAIL + daq = None + + try: + # initialize data acquisition system + daq = das.das_init(ts) + ts.log('DAS device: %s' % daq.info()) + + # ts.log('Waiting 30 sec to start inverter') + # ts.sleep(30) + + # run data capture + ts.log('Running capture 1') + daq.data_capture(True) + ts.sleep(2) + ts.log('current data: %s' % daq.data_capture_read()) + ts.sleep(2) + ts.log('current data: %s' % daq.data_capture_read()) + ts.sleep(2) + ts.log('current data: %s' % daq.data_capture_read()) + ts.sleep(2) + daq.data_capture(False) + ds = daq.data_capture_dataset() + + # save captured data set to capture file in SVP result directory + for i in range(10): + filename = 'capture_%s.csv' % i + ds.to_csv(ts.result_file_path(filename)) + ts.result_file(filename) + + # create result workbook + excelfile = ts.config_name() + '.xlsx' + rslt.result_workbook(excelfile, ts.results_dir(), ts.result_dir()) + ts.result_file(excelfile) + + result = script.RESULT_COMPLETE + + except script.ScriptFail as e: + reason = str(e) + if reason: + ts.log_error(reason) + finally: + if daq is not None: + daq.close() + + return result + +def run(test_script): + + try: + global ts + ts = test_script + rc = 0 + result = script.RESULT_COMPLETE + + ts.log_debug('') + ts.log_debug('************** Starting %s **************' % (ts.config_name())) + ts.log_debug('Script: %s %s' % (ts.name, ts.info.version)) + ts.log_active_params() + + result = test_run() + + ts.result(result) + if result == script.RESULT_FAIL: + rc = 1 + + except Exception as e: + ts.log_error('Test script exception: %s' % traceback.format_exc()) + rc = 1 + + sys.exit(rc) + +info = script.ScriptInfo(name=os.path.basename(__file__), run=run, version='1.0.0') + + +# DAS +das.params(info) + +# HIL +hil.params(info) + +def script_info(): + + return info + + +if __name__ == "__main__": + + # stand alone invocation + config_file = None + if len(sys.argv) > 1: + config_file = sys.argv[1] + + # config_file = os.path.join(os.path.dirname(__file__), '..', 'Tests', 'das test.tst') + + params = None + + test_script = script.Script(info=script_info(), config_file=config_file, params=params) + test_script.log('log it') + + run(test_script) + + diff --git a/Scripts/das_test.py b/Scripts/das_test.py index 59527f0..2b972f3 100644 --- a/Scripts/das_test.py +++ b/Scripts/das_test.py @@ -38,7 +38,7 @@ lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') if lib_path not in sys.path: sys.path.append(lib_path) -print sys.path +print(sys.path) # place script library imports here from svpelab import das @@ -66,13 +66,13 @@ def test_run(): # run data capture ts.log('Running capture 1') daq.data_capture(True) - ts.sleep(5) + ts.sleep(2) ts.log('current data: %s' % daq.data_capture_read()) - ts.sleep(5) + ts.sleep(2) ts.log('current data: %s' % daq.data_capture_read()) - ts.sleep(5) + ts.sleep(2) ts.log('current data: %s' % daq.data_capture_read()) - ts.sleep(5) + ts.sleep(2) daq.data_capture(False) ds = daq.data_capture_dataset() @@ -81,11 +81,12 @@ def test_run(): ds.to_csv(ts.result_file_path(filename)) ts.result_file(filename) - chil.stop_simulation() + if chil is not None: + chil.stop_simulation() result = script.RESULT_COMPLETE - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -114,7 +115,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/der_comm_test.py b/Scripts/der_comm_test.py new file mode 100644 index 0000000..5322ce6 --- /dev/null +++ b/Scripts/der_comm_test.py @@ -0,0 +1,341 @@ +''' +Copyright (c) 2016, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Written by Sandia National Laboratories, Loggerware, and SunSpec Alliance +Questions can be directed to Jay Johnson (jjohns2@sandia.gov) +''' + +#!C:\Python27\python.exe + +import sys +import os +import traceback +# from svpelab import das +from svpelab import der +import script + +def test_run(): + + # initialize DER configuration + eut = der.der_init(ts) + eut.config() + + ts.log('---') + info = eut.info() + if info is not None: + ts.log('DER info:') + ts.log(' Manufacturer: %s' % (info.get('Manufacturer'))) + ts.log(' Model: %s' % (info.get('Model'))) + ts.log(' Options: %s' % (info.get('Options'))) + ts.log(' Version: %s' % (info.get('Version'))) + ts.log(' Serial Number: %s' % (info.get('SerialNumber'))) + else: + ts.log_warning('DER info not supported') + ts.log('---') + m = eut.measurements() + if m is not None: + ts.log('DER measurements:') + ts.log(' A: %s' % (m.get('A'))) + ts.log(' AphA: %s' % (m.get('AphA'))) + ts.log(' AphB: %s' % (m.get('AphB'))) + ts.log(' AphC: %s' % (m.get('AphC'))) + ts.log(' PPVphAB: %s' % (m.get('PPVphAB'))) + ts.log(' PPVphBC: %s' % (m.get('PPVphBC'))) + ts.log(' PPVphCA: %s' % (m.get('PPVphCA'))) + ts.log(' PhVphA: %s' % (m.get('PhVphA'))) + ts.log(' PhVphB: %s' % (m.get('PhVphB'))) + ts.log(' PhVphC: %s' % (m.get('PhVphC'))) + ts.log(' W: %s' % (m.get('W'))) + ts.log(' Hz: %s' % (m.get('Hz'))) + ts.log(' VA: %s' % (m.get('VA'))) + ts.log(' VAr: %s' % (m.get('VAr'))) + ts.log(' PF: %s' % (m.get('PF'))) + ts.log(' WH: %s' % (m.get('WH'))) + ts.log(' DCA: %s' % (m.get('DCA'))) + ts.log(' DCV: %s' % (m.get('DCV'))) + ts.log(' DCW: %s' % (m.get('DCW'))) + ts.log(' TmpCab: %s' % (m.get('TmpCab'))) + ts.log(' TmpSnk: %s' % (m.get('TmpSnk'))) + ts.log(' TmpTrns: %s' % (m.get('TmpTrns'))) + ts.log(' TmpOt: %s' % (m.get('TmpCt'))) + ts.log(' St: %s' % (m.get('St'))) + ts.log(' StVnd: %s' % (m.get('StVnd'))) + ts.log(' Evt1: %s' % (m.get('Evt1'))) + ts.log(' Evt2: %s' % (m.get('Evt2'))) + ts.log(' EvtVnd1: %s' % (m.get('EvtVnd1'))) + ts.log(' EvtVnd2: %s' % (m.get('EvtVnd2'))) + ts.log(' EvtVnd3: %s' % (m.get('EvtVnd3'))) + ts.log(' EvtVnd4: %s' % (m.get('EvtVnd4'))) + else: + ts.log_warning('DER measurements not supported') + ts.log('---') + nameplate = eut.nameplate() + if nameplate is not None: + ts.log('DER nameplate:') + ts.log(' WRtg: %s' % (nameplate.get('WRtg'))) + ts.log(' VARtg: %s' % (nameplate.get('VARtg'))) + ts.log(' VArRtgQ1: %s' % (nameplate.get('VArRtgQ1'))) + ts.log(' VArRtgQ2: %s' % (nameplate.get('VArRtgQ2'))) + ts.log(' VArRtgQ3: %s' % (nameplate.get('VArRtgQ3'))) + ts.log(' VArRtgQ4: %s' % (nameplate.get('VArRtgQ4'))) + ts.log(' ARtg: %s' % (nameplate.get('ARtg'))) + ts.log(' PFRtgQ1: %s' % (nameplate.get('PFRtgQ1'))) + ts.log(' PFRtgQ2: %s' % (nameplate.get('PFRtgQ2'))) + ts.log(' PFRtgQ3: %s' % (nameplate.get('PFRtgQ3'))) + ts.log(' PFRtgQ4: %s' % (nameplate.get('PFRtgQ4'))) + ts.log(' WHRtg: %s' % (nameplate.get('WHRtg'))) + ts.log(' AhrRtg: %s' % (nameplate.get('AhrRtg'))) + ts.log(' MaxChaRte: %s' % (nameplate.get('MaxChrRte'))) + ts.log(' MaxDisChaRte: %s' % (nameplate.get('MaxDisChaRte'))) + else: + ts.log_warning('DER nameplate not supported') + ts.log('---') + settings = eut.settings() + if settings is not None: + ts.log('DER settings:') + ts.log(' WMax: %s' % (settings.get('WMax'))) + ts.log(' VRef: %s' % (settings.get('VRef'))) + ts.log(' VRefOfs: %s' % (settings.get('VRefOfs'))) + ts.log(' VMax: %s' % (settings.get('VMax'))) + ts.log(' VMin: %s' % (settings.get('VMin'))) + ts.log(' VAMax: %s' % (settings.get('VAMax'))) + ts.log(' VArMaxQ1: %s' % (settings.get('VArMaxQ1'))) + ts.log(' VArMaxQ2: %s' % (settings.get('VArMaxQ2'))) + ts.log(' VArMaxQ3: %s' % (settings.get('VArMaxQ3'))) + ts.log(' VArMaxQ4: %s' % (settings.get('VArMaxQ4'))) + ts.log(' WGra: %s' % (settings.get('WGra'))) + ts.log(' PFMaxQ1: %s' % (settings.get('PFMaxQ1'))) + ts.log(' PFMaxQ2: %s' % (settings.get('PFMaxQ2'))) + ts.log(' PFMaxQ3: %s' % (settings.get('PFMaxQ3'))) + ts.log(' PFMaxQ4: %s' % (settings.get('PFMaxQ4'))) + ts.log(' VArAct: %s' % (settings.get('VArAct'))) + else: + ts.log_warning('DER settings not supported') + ts.log('---') + connect = eut.connect() + if connect is not None: + ts.log('DER connect:') + ts.log(' Conn: %s' % (connect.get('Conn'))) + ts.log(' WinTms: %s' % (connect.get('WinTms'))) + ts.log(' RvrtTms: %s' % (connect.get('RvrtTms'))) + else: + ts.log_warning('DER connect not supported') + ts.log('---') + fixed_pf = eut.fixed_pf() + if fixed_pf is not None: + ts.log('DER fixed_pf:') + ts.log(' Ena: %s' % (fixed_pf.get('Ena'))) + ts.log(' PF: %s' % (fixed_pf.get('PF'))) + ts.log(' WinTms: %s' % (fixed_pf.get('WinTms'))) + ts.log(' RmpTms: %s' % (fixed_pf.get('RmpTms'))) + ts.log(' RvrtTms: %s' % (fixed_pf.get('RvrtTms'))) + else: + ts.log_warning('DER fixed_pf not supported') + ts.log('---') + wmax = eut.limit_max_power() + if wmax is not None: + ts.log('DER limit_max_power:') + ts.log(' Ena: %s' % (wmax.get('Ena'))) + ts.log(' WMaxPct: %s' % (wmax.get('WMaxPct'))) + ts.log(' WinTms: %s' % (wmax.get('WinTms'))) + ts.log(' RmpTms: %s' % (wmax.get('RmpTms'))) + ts.log(' RvrtTms: %s' % (wmax.get('RvrtTms'))) + else: + ts.log_warning('DER limit_max_power not supported') + ts.log('---') + + volt_var = eut.volt_var() + if volt_var is not None: + ts.log('DER volt/var:') + ts.log(' Ena: %s' % (volt_var.get('Ena'))) + ts.log(' ActCrv: %s' % (volt_var.get('ActCrv'))) + ts.log(' NCrv: %s' % (volt_var.get('NCrv'))) + ts.log(' NPt: %s' % (volt_var.get('NPt'))) + ts.log(' WinTms: %s' % (volt_var.get('WinTms'))) + ts.log(' RmpTms: %s' % (volt_var.get('RmpTms'))) + ts.log(' RvrtTms: %s' % (volt_var.get('RvrtTms'))) + curve = volt_var.get('curve') + if curve is not None: + ts.log(' curve #%d:' % (curve.get('id'))) + ts.log(' v: %s' % (curve.get('v'))) + ts.log(' var: %s' % (curve.get('var'))) + ts.log(' DeptRef: %s' % (curve.get('DeptRef'))) + ts.log(' RmpTms: %s' % (curve.get('RmpTms'))) + ts.log(' RmpDecTmm: %s' % (curve.get('RmpDecTmm'))) + ts.log(' RmpIncTmm: %s' % (curve.get('RmpIncTmm'))) + else: + ts.log_warning('DER volt_var not supported') + + ''' + eut.volt_var(params={'curve': {'v': [89, 97, 103, 105], 'var': [100, 50, 50, 0]}}) + volt_var = eut.volt_var() + if volt_var is not None: + ts.log('DER volt/var:') + ts.log(' Ena: %s' % (volt_var.get('Ena'))) + ts.log(' ActCrv: %s' % (volt_var.get('ActCrv'))) + ts.log(' NCrv: %s' % (volt_var.get('NCrv'))) + ts.log(' NPt: %s' % (volt_var.get('NPt'))) + ts.log(' WinTms: %s' % (volt_var.get('WinTms'))) + ts.log(' RmpTms: %s' % (volt_var.get('RmpTms'))) + ts.log(' RvrtTms: %s' % (volt_var.get('RvrtTms'))) + curve = volt_var.get('curve') + if curve is not None: + ts.log(' curve #%d:' % (curve.get('id'))) + ts.log(' v: %s' % (curve.get('v'))) + ts.log(' var: %s' % (curve.get('var'))) + ts.log(' DeptRef: %s' % (curve.get('DeptRef'))) + ts.log(' RmpTms: %s' % (curve.get('RmpTms'))) + ts.log(' RmpDecTmm: %s' % (curve.get('RmpDecTmm'))) + ts.log(' RmpIncTmm: %s' % (curve.get('RmpIncTmm'))) + else: + ts.log_warning('DER volt_var not supported') + ts.log('---') + curve_num = 1 + eut.volt_var_curve(id=curve_num, params={'v': [80, 97, 103, 105], 'var': [100, 50, 50, 0]}) + curve = eut.volt_var_curve(id=curve_num) + if curve is not None: + ts.log(' curve #%d:' % (curve.get('id'))) + ts.log(' v: %s' % (curve.get('v'))) + ts.log(' var: %s' % (curve.get('var'))) + ts.log(' DeptRef: %s' % (curve.get('DeptRef'))) + ts.log(' RmpTms: %s' % (curve.get('RmpTms'))) + ts.log(' RmpDecTmm: %s' % (curve.get('RmpDecTmm'))) + ts.log(' RmpIncTmm: %s' % (curve.get('RmpIncTmm'))) + else: + ts.log_warning('DER volt_var not supported') + ts.log('---') + ''' + + volt_var = eut.volt_watt() + if volt_var is not None: + ts.log('DER volt/watt:') + ts.log(' Ena: %s' % (volt_var.get('Ena'))) + ts.log(' ActCrv: %s' % (volt_var.get('ActCrv'))) + ts.log(' NCrv: %s' % (volt_var.get('NCrv'))) + ts.log(' NPt: %s' % (volt_var.get('NPt'))) + ts.log(' WinTms: %s' % (volt_var.get('WinTms'))) + ts.log(' RmpTms: %s' % (volt_var.get('RmpTms'))) + ts.log(' RvrtTms: %s' % (volt_var.get('RvrtTms'))) + curve = volt_var.get('curve') + if curve is not None: + ts.log(' curve #%d:' % (curve.get('id'))) + ts.log(' v: %s' % (curve.get('v'))) + ts.log(' w: %s' % (curve.get('w'))) + ts.log(' DeptRef: %s' % (curve.get('DeptRef'))) + ts.log(' RmpTms: %s' % (curve.get('RmpTms'))) + ts.log(' RmpDecTmm: %s' % (curve.get('RmpDecTmm'))) + ts.log(' RmpIncTmm: %s' % (curve.get('RmpIncTmm'))) + else: + ts.log_warning('DER volt_var not supported') + + status = eut.controls_status() + if status is not None: + ts.log(' Is Fixed_W enabled?: %s' % (status.get('Fixed_W'))) + ts.log(' Is Fixed_Var enabled?: %s' % (status.get('Fixed_Var'))) + ts.log(' Is Fixed_PF enabled?: %s' % (status.get('Fixed_PF'))) + ts.log(' Is Volt_Var enabled?: %s' % (status.get('Volt_Var'))) + ts.log(' Is Freq_Watt_Param enabled?: %s' % (status.get('Freq_Watt_Param'))) + ts.log(' Is Freq_Watt_Curve enabled?: %s' % (status.get('Freq_Watt_Curve'))) + ts.log(' Is Dyn_Reactive_Power enabled?: %s' % (status.get('Dyn_Reactive_Power'))) + ts.log(' Is LVRT enabled?: %s' % (status.get('LVRT'))) + ts.log(' Is HVRT enabled?: %s' % (status.get('HVRT'))) + ts.log(' Is Watt_PF enabled?: %s' % (status.get('Watt_PF'))) + ts.log(' Is Volt_Watt enabled?: %s' % (status.get('Volt_Watt'))) + ts.log(' Is Scheduled enabled?: %s' % (status.get('Scheduled'))) + ts.log(' Is LFRT enabled?: %s' % (status.get('LFRT'))) + ts.log(' Is HFRT enabled?: %s' % (status.get('HFRT'))) + + ts.log('---') + status = eut.conn_status() + ts.log(' Is PV_Connected?: %s' % (status.get('PV_Connected'))) + ts.log(' Is PV_Available?: %s' % (status.get('PV_Available'))) + ts.log(' Is PV_Operating?: %s' % (status.get('PV_Operating'))) + ts.log(' Is PV_Test?: %s' % (status.get('PV_Test'))) + ts.log(' Is Storage_Connected?: %s' % (status.get('Storage_Connected'))) + ts.log(' Is Storage_Available?: %s' % (status.get('Storage_Available'))) + ts.log(' Is Storage_Operating?: %s' % (status.get('Storage_Operating'))) + ts.log(' Is Storage_Test?: %s' % (status.get('Storage_Test'))) + ts.log(' Is EPC_Connected?: %s' % (status.get('EPC_Connected'))) + ts.log('---') + + return script.RESULT_COMPLETE + +def run(test_script): + + try: + global ts + ts = test_script + rc = 0 + result = script.RESULT_COMPLETE + + ts.log_debug('') + ts.log_debug('************** Starting %s **************' % (ts.config_name())) + ts.log_debug('Script: %s %s' % (ts.name, ts.info.version)) + ts.log_active_params() + + result = test_run() + + ts.result(result) + if result == script.RESULT_FAIL: + rc = 1 + + except Exception as e: + ts.log_error('Test script exception: %s' % traceback.format_exc()) + rc = 1 + + sys.exit(rc) + +info = script.ScriptInfo(name=os.path.basename(__file__), run=run, version='1.0.0') + + +# DER +der.params(info) + + +info.logo('sunspec.gif') + +def script_info(): + + return info + + +if __name__ == "__main__": + + # stand alone invocation + config_file = None + if len(sys.argv) > 1: + config_file = sys.argv[1] + + params = None + + test_script = script.Script(info=script_info(), config_file=config_file, params=params) + + run(test_script) + + diff --git a/Scripts/gridsim_comm.py b/Scripts/gridsim_comm.py index a16f98f..e28978c 100644 --- a/Scripts/gridsim_comm.py +++ b/Scripts/gridsim_comm.py @@ -50,7 +50,7 @@ def test_run(): result = script.RESULT_PASS - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -79,7 +79,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/gridsim_multi.py b/Scripts/gridsim_multi.py index 6effe7f..2182d82 100644 --- a/Scripts/gridsim_multi.py +++ b/Scripts/gridsim_multi.py @@ -83,7 +83,7 @@ def test_run(): result = script.RESULT_PASS - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -114,7 +114,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/loadsim_test.py b/Scripts/loadsim_test.py index 8c0ec9b..2164fef 100644 --- a/Scripts/loadsim_test.py +++ b/Scripts/loadsim_test.py @@ -38,7 +38,7 @@ lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') if lib_path not in sys.path: sys.path.append(lib_path) -print sys.path +print(sys.path) # place script library imports here from svpelab import loadsim @@ -55,7 +55,7 @@ def test_run(): result = script.RESULT_COMPLETE - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -84,7 +84,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/net_test.py b/Scripts/net_test.py new file mode 100644 index 0000000..ddf9423 --- /dev/null +++ b/Scripts/net_test.py @@ -0,0 +1,111 @@ +""" +Copyright (c) 2017, Sandia National Labs and SunSpec Alliance +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the names of the Sandia National Labs and SunSpec Alliance nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Questions can be directed to support@sunspec.org +""" + +import sys +import os +import traceback +from svpelab import network +import script + +def test_run(): + + result = script.RESULT_FAIL + load = None + + try: + net = network.net_init(ts) + ts.log('Network capture device: %s' % net.info()) + + net.net_capture() + net.print_capture() + + result = script.RESULT_COMPLETE + + except script.ScriptFail as e: + reason = str(e) + if reason: + ts.log_error(reason) + + return result + + +def run(test_script): + + try: + global ts + ts = test_script + rc = 0 + result = script.RESULT_COMPLETE + + ts.log_debug('') + ts.log_debug('************** Starting %s **************' % (ts.config_name())) + ts.log_debug('Script: %s %s' % (ts.name, ts.info.version)) + ts.log_active_params() + + result = test_run() + + ts.result(result) + if result == script.RESULT_FAIL: + rc = 1 + + except Exception as e: + ts.log_error('Test script exception: %s' % traceback.format_exc()) + rc = 1 + + sys.exit(rc) + +info = script.ScriptInfo(name=os.path.basename(__file__), run=run, version='1.0.0') + +# network parameters +network.params(info) + + +def script_info(): + + return info + + +if __name__ == "__main__": + + # stand alone invocation + config_file = None + if len(sys.argv) > 1: + config_file = sys.argv[1] + + params = None + + test_script = script.Script(info=script_info(), config_file=config_file, params=params) + test_script.log('log it') + + run(test_script) + + diff --git a/Scripts/pvsim_comm.py b/Scripts/pvsim_comm.py index a72a114..eeb7bc2 100644 --- a/Scripts/pvsim_comm.py +++ b/Scripts/pvsim_comm.py @@ -51,7 +51,7 @@ def test_run(): result = script.RESULT_PASS - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -80,7 +80,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/pvsim_multi.py b/Scripts/pvsim_multi.py index 7148c7b..e73f643 100644 --- a/Scripts/pvsim_multi.py +++ b/Scripts/pvsim_multi.py @@ -54,7 +54,7 @@ def test_run(): result = script.RESULT_PASS - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -85,7 +85,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/switch_test.py b/Scripts/switch_test.py index ccfec11..ac34c99 100644 --- a/Scripts/switch_test.py +++ b/Scripts/switch_test.py @@ -38,7 +38,7 @@ lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') if lib_path not in sys.path: sys.path.append(lib_path) -print sys.path +print(sys.path) # place script library imports here from svpelab import switch @@ -58,7 +58,7 @@ def test_run(): result = script.RESULT_COMPLETE - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -87,7 +87,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Scripts/wavegen_test.py b/Scripts/wavegen_test.py index 6f0224e..26b06be 100644 --- a/Scripts/wavegen_test.py +++ b/Scripts/wavegen_test.py @@ -38,7 +38,7 @@ lib_path = os.path.join(os.path.dirname(__file__), '..', 'Lib') if lib_path not in sys.path: sys.path.append(lib_path) -print sys.path +print(sys.path) # place script library imports here from svpelab import wavegen @@ -58,7 +58,7 @@ def test_run(): result = script.RESULT_COMPLETE - except script.ScriptFail, e: + except script.ScriptFail as e: reason = str(e) if reason: ts.log_error(reason) @@ -87,7 +87,7 @@ def run(test_script): if result == script.RESULT_FAIL: rc = 1 - except Exception, e: + except Exception as e: ts.log_error('Test script exception: %s' % traceback.format_exc()) rc = 1 diff --git a/Tests/WT3000.tst b/Tests/WT3000.tst new file mode 100644 index 0000000..9482f7b --- /dev/null +++ b/Tests/WT3000.tst @@ -0,0 +1,17 @@ + + + 1000 + + 1 + 192.168.0.10 + 2 + 3 + AC + AC + AC + DC + Disabled + Network + Yokogawa WT3000 + + diff --git a/Tests/das_make_data.tst b/Tests/das_make_data.tst new file mode 100644 index 0000000..44a9834 --- /dev/null +++ b/Tests/das_make_data.tst @@ -0,0 +1,14 @@ + + + 1 + 2 + 3 + 1000 + AC + AC + DAS Simulation + DC + Disabled + Random + + diff --git a/Tests/net_test.tst b/Tests/net_test.tst new file mode 100644 index 0000000..4a941b5 --- /dev/null +++ b/Tests/net_test.tst @@ -0,0 +1,8 @@ + + + 10.0 + Local Area Connection + None + PyShark + + diff --git a/Tests/wt1600.tst b/Tests/wt1600.tst new file mode 100644 index 0000000..30e506e --- /dev/null +++ b/Tests/wt1600.tst @@ -0,0 +1,20 @@ + + + 1000 + 10001 + + + 1 + 192.168.0.10 + 2 + 3 + AC + AC + AC + DC + Disabled + Network + Yokogawa WT1600 + anonymous + +