From e3d87a1392059038b76575d91ebd7102ea47073b Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 15 Apr 2023 15:45:50 -0700 Subject: [PATCH 1/9] Worked on power supply stuff --- main.py | 10 ++++------ powerSupply.py | 15 +++++++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index cb91954..0eefc3a 100644 --- a/main.py +++ b/main.py @@ -48,12 +48,12 @@ except AttributeError as e: logger.fatal("Failed to connect to odrive controller: " + str(e)) logger.info("Shutting off the power supply.") - powerSupply.write(":OUTP CH1,OFF") # Turn off the power supply + powerSupply.disable() # Turn off the power supply sys_exit(-1) except NotImplementedError as e: logger.fatal("Error when connecting odrive controller du to incomplete script (Yes, you may blame programming this time): " + str(e)) logger.info("Shutting off the power supply.") - powerSupply.write(":OUTP CH1,OFF") # Turn off the power supply + powerSupply.disable() # Turn off the power supply sys_exit(-1) # Connect to & Configure Multimeter @@ -66,16 +66,14 @@ # To start: -#""" +"""s try: motor1.startSensorless() except SystemExit as e: logger.debug("Failed to start motor: " + str(e)) logger.info("Shutting off the power supply.") - powerSupply.write(":OUTP CH1,OFF") # Turn off the power supply + powerSupply.disable() # Turn off the power supply sys_exit(-1) - - #""" # TODO: finish diff --git a/powerSupply.py b/powerSupply.py index 468aeeb..6daea29 100644 --- a/powerSupply.py +++ b/powerSupply.py @@ -45,7 +45,7 @@ def __init__(self, port, voltage, current): #raise e # Get PS information logger.info("Connected to: " + self.powerSupply.query("*IDN?")) - logger.debug("Power supply last self test results: " + self.powerSupply.query("**TST?")) + logger.debug("Power supply last self test results: " + self.powerSupply.query("*TST?")) logger.debug("Power supply Ch. 1 output mode: " + self.powerSupply.query(":OUTPut:MODE?")) logger.debug("Power supply Ch. 1 OCP state: " + self.powerSupply.query(":OUTPut:OCP? CH1")) logger.debug("Power supply Ch. 1 OCP value: " + self.powerSupply.query(":OUTP:OCP:VAL? CH1")) @@ -56,14 +56,14 @@ def __init__(self, port, voltage, current): # Send a message to be displayed for fun and to make it obvious that it's being remote controlled self.powerSupply.write(':DISPlay:TEXT "CONNECTED TO DYNAMOTOR TEST APPLICATION",10,10') - time_sleep(2) + time_sleep(2.5) self.powerSupply.write(":DISPlay:TEXT:CLEar") # Set parameters self.PS_VOLTAGE = voltage # 12.0 self.PS_CURRENT = current # 3.0 - logger.info("Target power supply voltage: " + self.PS_VOLTAGES_VOLTAGE) - logger.info("Target power supply current: " + self.PS_CURRENT) + logger.info("Target power supply voltage: " + str(self.PS_VOLTAGE)) + logger.info("Target power supply current: " + str(self.PS_CURRENT)) self.powerSupply.write(":APPLy CH1,12.0,3.0") # Verify parameters @@ -96,6 +96,7 @@ def disable(self): """ Turns channel 1 off """ + logger.info("Turning ps channel 1 off.") self.powerSupply.write(":OUTP CH1,OFF") # Get data @@ -115,6 +116,8 @@ def getOutputVoltage(self): Fetches the last known output voltage of the power supply Run updateOutputStats() to update this value + + @returns The last known output voltage of the power supply """ return self.output_voltage @@ -123,6 +126,8 @@ def getOutputCurrent(self): Fetches the last known output current of the power supply Run updateOutputStats() to update this value + + @returns The last known output current of the power supply """ return self.output_current @@ -131,6 +136,8 @@ def getOutputPower(self): Fetches the last known output power of the power supply Run updateOutputStats() to update this value + + @returns The last known output power of the power supply """ return self.output_power From da4255ce2a7a4933f0fb0beaa9d1c7be19499a84 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 15 Apr 2023 15:46:31 -0700 Subject: [PATCH 2/9] Found electronic load file --- electronicLoad.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 electronicLoad.py diff --git a/electronicLoad.py b/electronicLoad.py new file mode 100644 index 0000000..360074d --- /dev/null +++ b/electronicLoad.py @@ -0,0 +1,165 @@ +# Imports - logger +import logging +import logger + +# Imports - exit handling +from sys import exit as sys_exit + +# Imports - delays +from time import sleep as time_sleep + +# Imports - control +import easy_scpi as scpi +import pyvisa + + +# Start Logger +logger = logging.getLogger("DynamometerControl") +logger.setLevel(logging.DEBUG) + +# easy_scpi Docs: https://pypi.org/project/easy-scpi/ +""" +# TODO: Figure out how to extend Pythong classes?s +class SDL1020X(scpi.Instrument): + def __init__(self): + pass +""" + +class SDL1020X: + def __init__(self, port): + """ + Wrapper object for easy_scpi.Instrument object to control a Siglent SDL1020X-E 200W electronic load over SCPI + Does not auto configure the instrument + + @param port (String) USB port to connect to + """ + # Connect to Electronic load + try: + self.instrument = scpi.Instrument(port=port) + #ps = KATVisaRM.open_resource("") + self.instrument.connect() + except pyvisa.errors.VisaIOError as e: + logger.fatal("Failed to connect to power supply: " + str(e)) + sys_exit(-1) + #raise e + # Get PS information + logger.info("Connected to: " + self.instrument.query("*IDN?")) + logger.debug("Power supply last self test results: " + self.instrument.query("**TST?")) # NOt used? + + # It doesn't support displaying messages :( + + raise NotImplementedError + + # Configure + def configureConstantResistance(self, resistance, currentRange, voltageRange, resistorRange): + """ + @param resistorRange {LOW | MIDDLE | HIGH | UPPER} + """ + self.EL_static_mode = "CR" + logger.info("Electronic static mode: " + str(self.EL_static_mode)) + self.EL_resistance = resistance + logger.info("Target electronic load resistance: " + str(self.EL_resistance)) + self.EL_currentRange = currentRange + logger.info("Target electronic load current range: " + str(self.EL_currentRange)) + self.EL_voltageRange = voltageRange + logger.info("Target electronic load voltage range: " + str(self.EL_voltageRange)) + self.EL_resistorRange = resistorRange + logger.info("Target electronic load resistor range: " + str(self.EL_resistorRange)) + + self.instrument.write(":SOURce:FUNCtion ") + str(self.EL_static_mode) + self.instrument.write(":SOURce: RESistance:LEVel:IMMediate ") + str(self.EL_resistance) + self.instrument.write(":SOURce:RESistance:IRANGe ") + str(self.EL_currentRange) + self.instrument.write(":SOURce:RESistance:VRANGe ") + str(self.EL_voltageRange) + self.instrument.write(":SOURce:RESistance:RRANGe ") + str(self.EL_resistorRange) + + self.EL_OCP = "ON" + logger.info("Target Electronic load OCP: " + str(self.EL_OCP)) + self.instrument.write(":SOURce:CURRent:PROTection:STATe ") + str(self.EL_OCP) + raise NotImplementedError + + # TODO: Left off on pg 52 + + # Verify configurations + def verifyConfig(self): + logger.debug("Electronic load CR resistor value: " + str(self.instrument.query(":SOURce:FUNCtion?"))) + logger.debug("Electronic load CR resistor value: " + str(self.instrument.query(":SOURce:RESistance:LEVel:IMMediate?"))) + logger.debug("Electronic load CR current range value: " + str(self.instrument.query(":SOURce:RESistance:IRANGe?"))) + logger.debug("Electronic load CR voltage range value: " + str(self.instrument.query(":SOURce:RESistance:VRANGe?"))) + logger.debug("Electronic load CR resistor range value: " + str(self.instrument.query(":SOURce:RESistance:RRANGe?"))) + + logger.debug("Electronic load OCP state: " + self.powerSupply.query(":SOURce:CURRent:PROTection:STATe?")) # Not sure, this wasn't how it was in the manuel but it makes more since + raise NotImplementedError + # Verify parameters + logger.debug("Power supply Ch. 1 OCP value: " + self.powerSupply.query(":OUTP:OCP:VAL? CH1")) + logger.debug("Power supply Ch. 1 OVP state: " + self.powerSupply.query(":OUTP:OVP? CH1")) + logger.debug("Power supply Ch. 1 OVP value: " + self.powerSupply.query(":OUTP:OVP:VAL? CH1")) + logger.debug("Power supply OTP state: " + self.powerSupply.query(":SYSTem:OTP?")) + logger.debug("Power supply system version: " + self.powerSupply.query(":SYSTem:VERSion?")) + + + # Control + def enable(self): + """ + Enable the input of the electronic load. Make sure it is configured first. + """ + logger.info("Turning the load input on.") + self.instrument.write(":SOURce:INPut:STATe ON") + + def disable(self): + """ + Disables the input of the electronic load + """ + logger.info("Turning the load input off.") + self.instrument.write(":SOURce:INPut:STATe OFF") + + # Get data + def getLoadVoltage(self): + """ + Fetches the input voltage from the electronic load + + @returns the input voltage from the electronic load + """ + self.load_voltage = self.instrument.query(":MEASure:VOLTage:DC?") + logger.debug("Electronic load voltage : " + str(self.load_voltage)) + return self.load_voltage + + def getLoadCurrent(self): + """ + Fetches the input current from the electronic load + + @returns the input current from the electronic load + """ + self.load_current = self.instrument.query(":MEASure:CURRent:DC?") + logger.debug("Electronic load current : " + str(self.load_current)) + return self.load_current + + def getLoadPower(self): + """ + Fetches the input power from the electronic load + + @returns the input power from the electronic load + """ + self.load_power = self.instrument.query(":MEASure:POWer:DC?") + logger.debug("Electronic load current : " + str(self.load_power)) + return self.load_power + + def getLoadResistance(self): + """ + Fetches the input power from the electronic load + + @returns the input power from the electronic load + """ + self.load_resistance = self.instrument.query(":MEASure:POWer:DC?") + logger.debug("Electronic load current : " + str(self.load_resistance)) + return self.load_resistance + + def testStepNumber(self): + """ + "Query the number of running step in the LIST/PROGRAM test sequence." + + @returns "the number of running step in the LIST/PROGRAM test sequence." + """ + self.load_step_number = self.instrument.query(":SOURce:TEST:STEP?") + logger.debug("Electronic step number: " + str(self.load_step_number)) + return self.load_step_number + From a08247560d9cfaf444044fbe59aebd86ffebfb2d Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 14:56:14 -0700 Subject: [PATCH 3/9] Worked on implementing wind tunnel testing --- DynamometerControl.code-workspace | 2 + main.py | 182 +++++++++++++++++++----------- odriveMotorController.py | 18 +++ 3 files changed, 138 insertions(+), 64 deletions(-) diff --git a/DynamometerControl.code-workspace b/DynamometerControl.code-workspace index 74c837f..fc62ddc 100644 --- a/DynamometerControl.code-workspace +++ b/DynamometerControl.code-workspace @@ -9,12 +9,14 @@ ], "settings": { "cSpell.words": [ + "DYNO", "highlevel", "odrive", "odrv", "pyvisa", "Rigol", "scpi", + "Sensorless", "Siglent", "visalib" ] diff --git a/main.py b/main.py index 0eefc3a..ed985ab 100644 --- a/main.py +++ b/main.py @@ -5,88 +5,142 @@ # Imports - Handle exiting from sys import exit as sys_exit -# Imports - Control -from odriveMotorController import odriveMotorController -import easy_scpi as scpi -import pyvisa -from powerSupply import DP832 - - # Start Logger logger = logging.getLogger("DynamometerControl") logger.setLevel(logging.DEBUG) -# VISA Configuration -# TODO: Unended if using easy_scpi -# https://pypi.org/project/easy-scpi/ -# https://pyvisa.readthedocs.io/en/latest/introduction/configuring.html - -# Keysight / Agilent Technologies VISA -#KATVisaRM = pyvisa.highlevel.ResourceManager("C:\\Program Files (x86)\\IVI Foundation\\VISA\\WinNT\\agvisa\\agbin\\visa32.dll") -KATVisaRM = pyvisa.highlevel.ResourceManager() -#a = pyvisa.ResourceManager() #? # Alt method? # TODO - -logger.info("Keysight / Agilent Technologies VISA: " + str(KATVisaRM.visalib)) -logger.info("Keysight / Agilent Technologies VISA Resources: " + str(KATVisaRM.list_resources())) +## CONFIG +CONTROLLER_ENABLE = True +PS_ENABLE = False +MULTIMETER_ENABLE = False +LOAD_ENABLE = False +#TEST_MODE = "DYNO" +TEST_MODE = "TUNNEL" -# National Instruments VISA -NI_VISA = 'C:\\Windows\\system32\\visa32.dll' -NIVisaRM = pyvisa.highlevel.ResourceManager(NI_VISA) -logger.info("National Instruments VISA: " + str(NIVisaRM.visalib)) -logger.info("National Instruments VISA Resources: " + str(NIVisaRM.list_resources())) +logger.info("Test Config: CONTROLLER_ENABLE=%s, PS_ENABLE=%s, MULTIMETER_ENABLE=%s, LOAD_ENABLE=%s", CONTROLLER_ENABLE, PS_ENABLE, MULTIMETER_ENABLE, LOAD_ENABLE) +# Imports - Control +if CONTROLLER_ENABLE: + logger.debug("Importing odriveMotorController") + from odriveMotorController import odriveMotorController +if PS_ENABLE or MULTIMETER_ENABLE or LOAD_ENABLE: + logger.debug("Importing easy_scpi and pyvisa") + import easy_scpi as scpi + import pyvisa + if PS_ENABLE: + logger.debug("Importing powerSupply") + from powerSupply import DP832 + +# Warn if PS Control unavailable +if (not PS_ENABLE) and CONTROLLER_ENABLE: + logger.warning("Controller enabled without power supply! Power shutoff on error unavailable.") -# Connect to & Configure Power supply -powerSupply = DP832("USB0", 12.0, 3.0) -powerSupply.enable() +# VISA Configuration +if PS_ENABLE or MULTIMETER_ENABLE or LOAD_ENABLE: + # VISA Configuration + # TODO: Unended if using easy_scpi + # https://pypi.org/project/easy-scpi/ + # https://pyvisa.readthedocs.io/en/latest/introduction/configuring.html + + # Keysight / Agilent Technologies VISA + if PS_ENABLE or MULTIMETER_ENABLE: + #KATVisaRM = pyvisa.highlevel.ResourceManager("C:\\Program Files (x86)\\IVI Foundation\\VISA\\WinNT\\agvisa\\agbin\\visa32.dll") + KATVisaRM = pyvisa.highlevel.ResourceManager() + #a = pyvisa.ResourceManager() #? # Alt method? # TODO + + logger.info("Keysight / Agilent Technologies VISA: " + str(KATVisaRM.visalib)) + logger.info("Keysight / Agilent Technologies VISA Resources: " + str(KATVisaRM.list_resources())) + + # National Instruments VISA + if LOAD_ENABLE: + NI_VISA = 'C:\\Windows\\system32\\visa32.dll' + NIVisaRM = pyvisa.highlevel.ResourceManager(NI_VISA) + logger.info("National Instruments VISA: " + str(NIVisaRM.visalib)) + logger.info("National Instruments VISA Resources: " + str(NIVisaRM.list_resources())) + + + # Connect to & Configure Power supply + if PS_ENABLE: + powerSupply = DP832("USB0", 12.0, 3.0) + powerSupply.enable() + +def PSSafeShutdown(): + if PS_ENABLE: + powerSupply.disable() + else: + logger.critical("Power Supply control not enabled. FAILED TO SHUTOFF POWER.") # Connect to & Configure Odrive motor controller -try: - motor1 = odriveMotorController() - motor1.configure() - motor1.verifyConfig() -except AttributeError as e: - logger.fatal("Failed to connect to odrive controller: " + str(e)) - logger.info("Shutting off the power supply.") - powerSupply.disable() # Turn off the power supply - sys_exit(-1) -except NotImplementedError as e: - logger.fatal("Error when connecting odrive controller du to incomplete script (Yes, you may blame programming this time): " + str(e)) - logger.info("Shutting off the power supply.") - powerSupply.disable() # Turn off the power supply - sys_exit(-1) +if CONTROLLER_ENABLE: + try: + motor1 = odriveMotorController() + #motor1.configure() #TODO + motor1.verifyConfig() + except AttributeError as e: + logger.fatal("Failed to connect to odrive controller: " + str(e)) + logger.info("Shutting off the power supply.") + PSSafeShutdown() + sys_exit(-1) + except NotImplementedError as e: + logger.fatal("Error when connecting odrive controller du to incomplete script (Yes, you may blame programming this time): " + str(e)) + logger.info("Shutting off the power supply.") + PSSafeShutdown() + sys_exit(-1) + +def ControllerSafeShutdown(): + ## TODO: Verify that this is correct + if CONTROLLER_ENABLE: + motor1.stop() # Connect to & Configure Multimeter -multimeter = scpi.Instrument(port=None) -# TODO: finish +if MULTIMETER_ENABLE: + multimeter = scpi.Instrument(port=None) + # TODO: finish # Connect to & Configure Digital Load -load = scpi.Instrument(port=None, backend=NI_VISA) -# TODO: finish +if LOAD_ENABLE: + load = scpi.Instrument(port=None, backend=NI_VISA) + # TODO: finish # To start: - -"""s -try: - motor1.startSensorless() -except SystemExit as e: - logger.debug("Failed to start motor: " + str(e)) - logger.info("Shutting off the power supply.") - powerSupply.disable() # Turn off the power supply - sys_exit(-1) -#""" -# TODO: finish +if CONTROLLER_ENABLE: + if TEST_MODE == "DYNO": + raise NotImplementedError + try: + motor1.startSensorless() + except SystemExit as e: + logger.debug("Failed to start motor: " + str(e)) + logger.info("Shutting off the power supply.") + PSSafeShutdown() + sys_exit(-1) + # TODO: finish + elif TEST_MODE == "TUNNEL": + try: + motor1.startSensorless() + except SystemExit as e: + logger.debug("Failed to start motor: " + str(e)) + logger.info("Shutting off the power supply.") + PSSafeShutdown() + sys_exit(-1) # Main loop #""" -powerSupply.updateOutputStats() -powerSupply.getOutputVoltage() -powerSupply.getOutputCurrent() -powerSupply.getOutputPower() + #""" -# Stop -## TODO: Verify that this is correct -motor1.stop() -powerSupply.disable() + +try: + while True: + if CONTROLLER_ENABLE: + motor1.getDCBusVoltage() + motor1.getDCBusCurrent() + if PS_ENABLE: + powerSupply.updateOutputStats() + powerSupply.getOutputVoltage() + powerSupply.getOutputCurrent() + powerSupply.getOutputPower() +except KeyboardInterrupt as e : + logger.fatal("Shutting down due to keyboard Interrupt: ", e) + ControllerSafeShutdown() + PSSafeShutdown() diff --git a/odriveMotorController.py b/odriveMotorController.py index 27ddf47..05b6ebe 100644 --- a/odriveMotorController.py +++ b/odriveMotorController.py @@ -189,3 +189,21 @@ def stop(self): """ self.setVelocity(0) + def getDCBusVoltage(self): + """ + @return Voltage on the DC bus as measured by the ODrive. + """ + temp = self.odrv0.vbus_voltage + logger.debug("DCBusVoltage: " + temp) + return + + def getDCBusCurrent(self): + """ + @return Current on the DC bus as calculated by the ODrive. + A positive value means that the ODrive is consuming power from the power supply, a negative value means that the ODrive is sourcing power to the power supply. + This value is equal to the sum of the motor currents and the brake resistor currents. The motor currents are measured, the brake resistor current is calculated based on config.brake_resistance. + + """ + temp = self.odrv0.ibus + return logger.debug("DCBusCurrent: " + temp) + From 381e7bed37598062f028782bbc512a23e7ee2796 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 15:59:16 -0700 Subject: [PATCH 4/9] Worked on data recording --- DataRecorder.py | 35 ++++++++++++++++++++++++++++ main.py | 11 +++++++-- output/data-2023-04-22__15-58-35.csv | 2 ++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 DataRecorder.py create mode 100644 output/data-2023-04-22__15-58-35.csv diff --git a/DataRecorder.py b/DataRecorder.py new file mode 100644 index 0000000..ad05d11 --- /dev/null +++ b/DataRecorder.py @@ -0,0 +1,35 @@ +# Imports - Logging +import logger +import logging + +# Start Logger +logger = logging.getLogger("DynamometerControl") +logger.setLevel(logging.DEBUG) + +# Other imports +import csv +from datetime import datetime as datetime + +class DataRecorder: + def __init__(self): + current_time = datetime.now().strftime('%Y-%m-%d__%H-%M-%S') + self.filePath = "output/data-" + current_time + ".csv" + logger.info("Saving data to :" + self.filePath) + self.data_file = open(self.filePath, mode='w') + self.data_writer = csv.writer(self.data_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) + self.data_writer.writerow(['Timestamp (ISO8601 - YYYY:MM:DDThh:mm:ss+/-TimeZoneOffset)', 'Timestamp (POSIX in s)', 'Voltage (Volts)', 'Current (Amps)']) + + def write_data(self, voltage, current): + """ + @param voltage + @param current + """ + current_time = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f%z') + current_timestamp = datetime.utcnow().timestamp() + logger.debug("Saved data: " + current_time + ", " + str(current_timestamp) + ", "+ str(voltage) + ", " + str(current)) + self.data_writer.writerow([current_time, current_timestamp, voltage, current]) + + def close_file(self): + logger.debug("Closed data file") + self.data_file.close() + diff --git a/main.py b/main.py index ed985ab..0e38a67 100644 --- a/main.py +++ b/main.py @@ -31,6 +31,11 @@ logger.debug("Importing powerSupply") from powerSupply import DP832 +# Imports - data handling +from DataRecorder import DataRecorder +recorder = DataRecorder() +recorder.write_data(-1.9, -1.3) + # Warn if PS Control unavailable if (not PS_ENABLE) and CONTROLLER_ENABLE: logger.warning("Controller enabled without power supply! Power shutoff on error unavailable.") @@ -102,6 +107,9 @@ def ControllerSafeShutdown(): load = scpi.Instrument(port=None, backend=NI_VISA) # TODO: finish +# Prepare data collection +recorder = DataRecorder() + # To start: if CONTROLLER_ENABLE: if TEST_MODE == "DYNO": @@ -132,8 +140,7 @@ def ControllerSafeShutdown(): try: while True: if CONTROLLER_ENABLE: - motor1.getDCBusVoltage() - motor1.getDCBusCurrent() + recorder.write_data(motor1.getDCBusVoltage(), motor1.getDCBusCurrent()) if PS_ENABLE: powerSupply.updateOutputStats() powerSupply.getOutputVoltage() diff --git a/output/data-2023-04-22__15-58-35.csv b/output/data-2023-04-22__15-58-35.csv new file mode 100644 index 0000000..ffe60dc --- /dev/null +++ b/output/data-2023-04-22__15-58-35.csv @@ -0,0 +1,2 @@ +Timestamp (ISO8601 - YYYY:MM:DDThh:mm:ss+/-TimeZoneOffset),Timestamp (POSIX in s),Voltage (Volts),Current (Amps) +2023-04-22T15:58:35.760379,1682229515.760379,-1.9,-1.3 From 9a797f56b81daf2e767481d0c8e30f4217cf7fba Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 16:00:26 -0700 Subject: [PATCH 5/9] Ignore output files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d9005f2..168d441 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Output +output From 7ab9bd32eb8d911339551b325f0f7823cd7fc830 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 19:05:53 -0700 Subject: [PATCH 6/9] Improved data recorded compatibility with recent changes and load testing --- DataRecorder.py | 39 ++++++++++++++++++++++++++++++++++----- main.py | 2 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/DataRecorder.py b/DataRecorder.py index ad05d11..bd6c384 100644 --- a/DataRecorder.py +++ b/DataRecorder.py @@ -11,23 +11,52 @@ from datetime import datetime as datetime class DataRecorder: - def __init__(self): + def __init__(self, PS_ENABLE= False, MULTIMETER_ENABLE = False, LOAD_ENABLE = False, CONTROLLER_ENABLE = True): + self.PS_ENABLE = PS_ENABLE + self.MULTIMETER_ENABLE = MULTIMETER_ENABLE + self.LOAD_ENABLE = LOAD_ENABLE + self.CONTROLLER_ENABLE = CONTROLLER_ENABLE + current_time = datetime.now().strftime('%Y-%m-%d__%H-%M-%S') self.filePath = "output/data-" + current_time + ".csv" logger.info("Saving data to :" + self.filePath) self.data_file = open(self.filePath, mode='w') self.data_writer = csv.writer(self.data_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) - self.data_writer.writerow(['Timestamp (ISO8601 - YYYY:MM:DDThh:mm:ss+/-TimeZoneOffset)', 'Timestamp (POSIX in s)', 'Voltage (Volts)', 'Current (Amps)']) + header = ['Timestamp (ISO8601 - YYYY:MM:DDThh:mm:ss+/-TimeZoneOffset)', 'Timestamp (POSIX in s)'] + if PS_ENABLE: + header.append('Power Supply Voltage (Volts)') + header.append('Power Supply Current (Amps)') + if MULTIMETER_ENABLE: + raise NotImplementedError + if LOAD_ENABLE: + raise NotImplementedError + if CONTROLLER_ENABLE: + header.append('Odrive Dc Bus Voltage (Volts)') + header.append('Odrive Dc Bus Current (Amps)') + logger.debug("Data header as list: " + str(header)) + self.data_writer.writerow(header) - def write_data(self, voltage, current): + def write_data(self, ps_voltage=-1, ps_current=-1, odrive_bus_voltage=-1, odrive_bus_current = -1): """ @param voltage @param current """ current_time = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f%z') current_timestamp = datetime.utcnow().timestamp() - logger.debug("Saved data: " + current_time + ", " + str(current_timestamp) + ", "+ str(voltage) + ", " + str(current)) - self.data_writer.writerow([current_time, current_timestamp, voltage, current]) + logger.debug("Saved data: " + current_time + ", " + str(current_timestamp) + ", " + str(ps_voltage) + ", " + str(ps_current) + ", " + str(odrive_bus_voltage) + ", " + str(odrive_bus_current)) + + output = [current_time, current_timestamp] + if self.PS_ENABLE: + output.append(ps_voltage) + output.append(ps_current) + if self.MULTIMETER_ENABLE: + raise NotImplementedError + if self.LOAD_ENABLE: + raise NotImplementedError + if self.CONTROLLER_ENABLE: + output.append(odrive_bus_voltage) + output.append(odrive_bus_current) + self.data_writer.writerow(output) def close_file(self): logger.debug("Closed data file") diff --git a/main.py b/main.py index 0e38a67..1998b3d 100644 --- a/main.py +++ b/main.py @@ -108,7 +108,7 @@ def ControllerSafeShutdown(): # TODO: finish # Prepare data collection -recorder = DataRecorder() +recorder = DataRecorder(PS_ENABLE=False, MULTIMETER_ENABLE=False, LOAD_ENABLE=False) # To start: if CONTROLLER_ENABLE: From b51ac3ced0f4bf7588b93c5ba77f0f1aa2616f18 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 19:56:44 -0700 Subject: [PATCH 7/9] Improved documentation --- DataRecorder.py | 39 +++++++++++++++++++++++++++++++++++++-- README.md | 36 +++++++++++++++++++++++++++++++----- electronicLoad.py | 20 ++++++++++++++++++-- main.py | 40 ++++++++++++++++++++++++++-------------- odriveMotorController.py | 24 ++++++++++++++++++++---- powerSupply.py | 10 ++++++++++ 6 files changed, 142 insertions(+), 27 deletions(-) diff --git a/DataRecorder.py b/DataRecorder.py index bd6c384..35665be 100644 --- a/DataRecorder.py +++ b/DataRecorder.py @@ -1,3 +1,14 @@ +""" +DataRecorder.py +Saves test data to a file. + +@author BSI + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" + # Imports - Logging import logger import logging @@ -11,17 +22,33 @@ from datetime import datetime as datetime class DataRecorder: + """ + Saves test data to a file. + + @author BSI + """ def __init__(self, PS_ENABLE= False, MULTIMETER_ENABLE = False, LOAD_ENABLE = False, CONTROLLER_ENABLE = True): + """ + Create an object to record data to a file. File will be created in /output and will have a time-based filename + @param PS_ENABLE: True to accept and log data from a power supply, False to not + @param MULTIMETER_ENABLE: True to accept and log data from a multimeter, False to not + @param LOAD_ENABLE: True to accept and log data from an electronic load, False to not + @param CONTROLLER_ENABLE: True to accept and log data from a motor controller, False to not + """ + # Update instance variables self.PS_ENABLE = PS_ENABLE self.MULTIMETER_ENABLE = MULTIMETER_ENABLE self.LOAD_ENABLE = LOAD_ENABLE self.CONTROLLER_ENABLE = CONTROLLER_ENABLE + # Create file to save data to current_time = datetime.now().strftime('%Y-%m-%d__%H-%M-%S') self.filePath = "output/data-" + current_time + ".csv" logger.info("Saving data to :" + self.filePath) self.data_file = open(self.filePath, mode='w') self.data_writer = csv.writer(self.data_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) + + # Add column headers to file header = ['Timestamp (ISO8601 - YYYY:MM:DDThh:mm:ss+/-TimeZoneOffset)', 'Timestamp (POSIX in s)'] if PS_ENABLE: header.append('Power Supply Voltage (Volts)') @@ -38,8 +65,13 @@ def __init__(self, PS_ENABLE= False, MULTIMETER_ENABLE = False, LOAD_ENABLE = Fa def write_data(self, ps_voltage=-1, ps_current=-1, odrive_bus_voltage=-1, odrive_bus_current = -1): """ - @param voltage - @param current + Records data to the file opened in __init__ + Ensure that the parameters being passed match what is enabled in __init__ + + @param ps_voltage + @param ps_current + @param odrive_bus_voltage + @param odrive_bus_current """ current_time = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f%z') current_timestamp = datetime.utcnow().timestamp() @@ -59,6 +91,9 @@ def write_data(self, ps_voltage=-1, ps_current=-1, odrive_bus_voltage=-1, odrive self.data_writer.writerow(output) def close_file(self): + """ + Closes the file opened in __init__ + """ logger.debug("Closed data file") self.data_file.close() diff --git a/README.md b/README.md index 74e5c9b..3449e6b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,38 @@ -# DynomometerControl +# Cal Poly Wind Power Club - Power Team - Automated Test Control Software + +This script is intended to perform semi-automated testing on a generator as well as wind-tunnel testing for a wind turbine. Data is recorded to a `.csv` file in the `/output/` directory and logs are stored in `/logs/`. Different components can be enabled or disabled by changing the variables marked by `# CONFIG`. + +## Dynamometer Control Performs automated dynamometer testing on a generator by controlling & monitoring the following components over USB: - A VISA compatible power supply (Rigol DP832) using SCPI -- An odrive motor connected to a Odrive Robotics v3.6 controller using the odrive library +- An Odrive motor connected to a Odrive Robotics v3.6 controller using the odrive library +- (Optional) A VISA compatible multimeter (Agilent U3606A) using SCPI +- (Recommended) A VISA compatible programable electronic road (Siglent SDL1020X-E) using SCPI + +Note that this program is specific to the use case and ignores things a more generic library might include, such as multiple power supply channels. + +## Wind Tunnel Testing + +Records data and manages wind tunnel testing on a hobbyist wind turbine by controlling & monitoring the following components over USB: + +- An Odrive motor connected to a Odrive Robotics v3.6 controller using the odrive library - (Optional) A VISA compatible multimeter (Agilent U3606A) using SCPI - (Recommended) A VISA compatible programable electronic road (Siglent SDL1020X-E) using SCPI -Note that this program is specified to the use case and ignores things a more generic library might include, such as multiple power supply channels +Note that this program is specific to the use case and ignores things a more generic library might include, such as multiple power supply channels. ## Key terms -- Virtual Instrument Software architecture (VISA library) -- Standard Commands for Programmable Instruments (SCPI) +- Virtual Instrument Software architecture (VISA library) [More info - Wikipedia](https://en.wikipedia.org/wiki/Virtual_instrument_software_architecture) +- Standard Commands for Programmable Instruments (SCPI) [More information - Wikipedia](https://en.wikipedia.org/wiki/Standard_Commands_for_Programmable_Instruments) +- `.csv` - Comma separated values file extension - basically a spreadsheet represented in plain text + +## SCPI + +The SCPI syntax is hierarchial - A root command might be `:MEASure` with a subcommand `:VOLTage` and then `:DC?` under that. Note that `:` is used a separator and `?` indicate query while setter commands don't have a question mark afterwords. For example, to putting the above together would result in `MEASure:VOLTage:DC?`, which would measure the DC voltage. Not that only the capital letters are required. +Additionally, commands can be concatenated with a semicolon in between and arguments can be provided after a space. ## Questions, Comments, and work put off for the future @@ -26,3 +46,9 @@ Note that this program is specified to the use case and ignores things a more ge - The Keysight VISA has some extra libraries that cause it to need extra configuration for pyvisa - [See Wikipedia - VISA on compatibility](https://en.wikipedia.org/wiki/Virtual_instrument_software_architecture_ - 32-bit vs. 64-bit: I didn't realize that both NI and Keysight provided 64-bit VISAs so I installed python 3.11 32-bit and now I can't be bothered to switch back to whatever 64-bit version of Python I had before + +## License + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/electronicLoad.py b/electronicLoad.py index 360074d..5b11cde 100644 --- a/electronicLoad.py +++ b/electronicLoad.py @@ -1,3 +1,14 @@ +""" +ElectronicLoad.py +Control a VISA compatible electronic load with SCPI over USB + +@author BSI + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" + # Imports - logger import logging import logger @@ -19,13 +30,18 @@ # easy_scpi Docs: https://pypi.org/project/easy-scpi/ """ -# TODO: Figure out how to extend Pythong classes?s +# TODO: Figure out how to extend Python classes?s class SDL1020X(scpi.Instrument): def __init__(self): pass """ class SDL1020X: + """ + Control a Siglent SDL1020X-E 200W electronic load via SCPI over USB + + @author BSI + """ def __init__(self, port): """ Wrapper object for easy_scpi.Instrument object to control a Siglent SDL1020X-E 200W electronic load over SCPI @@ -88,7 +104,7 @@ def verifyConfig(self): logger.debug("Electronic load CR resistor range value: " + str(self.instrument.query(":SOURce:RESistance:RRANGe?"))) logger.debug("Electronic load OCP state: " + self.powerSupply.query(":SOURce:CURRent:PROTection:STATe?")) # Not sure, this wasn't how it was in the manuel but it makes more since - raise NotImplementedError + raise NotImplementedError # TODO # Verify parameters logger.debug("Power supply Ch. 1 OCP value: " + self.powerSupply.query(":OUTP:OCP:VAL? CH1")) logger.debug("Power supply Ch. 1 OVP state: " + self.powerSupply.query(":OUTP:OVP? CH1")) diff --git a/main.py b/main.py index 1998b3d..e3b3020 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,14 @@ +""" +main.py +This script is intended to perform semi-automated testing on a generator as well as wind-tunnel testing for a wind turbine. Data is recorded to a `.csv` file in the `/output/` directory and logs are stored in `/logs/`. Different components can be enabled or disabled by changing the variables marked by `# CONFIG`. + +@author BSI + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" + # Imports - Logging import logger import logging @@ -9,27 +20,27 @@ logger = logging.getLogger("DynamometerControl") logger.setLevel(logging.DEBUG) -## CONFIG -CONTROLLER_ENABLE = True -PS_ENABLE = False -MULTIMETER_ENABLE = False -LOAD_ENABLE = False -#TEST_MODE = "DYNO" -TEST_MODE = "TUNNEL" +# CONFIG +CONTROLLER_ENABLE = True # CONFIG: Boolean +PS_ENABLE = False # CONFIG: Boolean +MULTIMETER_ENABLE = False # CONFIG: Boolean +LOAD_ENABLE = False # CONFIG: Boolean +#TEST_MODE = "DYNO" # CONFIG: "DYNO" or "TUNNEL" +TEST_MODE = "TUNNEL" # CONFIG: "DYNO" or "TUNNEL" logger.info("Test Config: CONTROLLER_ENABLE=%s, PS_ENABLE=%s, MULTIMETER_ENABLE=%s, LOAD_ENABLE=%s", CONTROLLER_ENABLE, PS_ENABLE, MULTIMETER_ENABLE, LOAD_ENABLE) # Imports - Control if CONTROLLER_ENABLE: logger.debug("Importing odriveMotorController") - from odriveMotorController import odriveMotorController + from OdriveMotorController import odriveMotorController if PS_ENABLE or MULTIMETER_ENABLE or LOAD_ENABLE: logger.debug("Importing easy_scpi and pyvisa") import easy_scpi as scpi import pyvisa if PS_ENABLE: logger.debug("Importing powerSupply") - from powerSupply import DP832 + from PowerSupply import DP832 # Imports - data handling from DataRecorder import DataRecorder @@ -70,6 +81,9 @@ powerSupply.enable() def PSSafeShutdown(): + """ + Checks to see if the power supply has been configured and shuts it off if it has. + """ if PS_ENABLE: powerSupply.disable() else: @@ -93,6 +107,9 @@ def PSSafeShutdown(): sys_exit(-1) def ControllerSafeShutdown(): + """ + Checks to see if the motor controller has been configured and shuts it off if it has. + """ ## TODO: Verify that this is correct if CONTROLLER_ENABLE: motor1.stop() @@ -132,11 +149,6 @@ def ControllerSafeShutdown(): sys_exit(-1) # Main loop -#""" - -#""" - - try: while True: if CONTROLLER_ENABLE: diff --git a/odriveMotorController.py b/odriveMotorController.py index 05b6ebe..6e3ee93 100644 --- a/odriveMotorController.py +++ b/odriveMotorController.py @@ -1,3 +1,14 @@ +""" +OdriveMotorController.py +Control an Odrive motor controller over USB + +@author BSI + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" + # Imports - logger import logging import logger @@ -15,10 +26,15 @@ class odriveMotorController: - # Our controller is a bit old, this version is more relevant: # https://docs.odriverobotics.com/v/0.5.4/getting-started.html - # Latest: https://docs.odriverobotics.com/v/0.5.5/specifications.html - # https://docs.odriverobotics.com/v/0.5.5/getting-started.html - # Troubleshooting: https://docs.odriverobotics.com/v/latest/troubleshooting.html + """ + Control an Odrive motor controller over USB + + Useful links: + Our controller is a bit old, this version is more relevant: # https://docs.odriverobotics.com/v/0.5.4/getting-started.html + Latest: https://docs.odriverobotics.com/v/0.5.5/specifications.html + https://docs.odriverobotics.com/v/0.5.5/getting-started.html + Troubleshooting: https://docs.odriverobotics.com/v/latest/troubleshooting.html + """ def __init__(self): """ diff --git a/powerSupply.py b/powerSupply.py index 6daea29..9b17272 100644 --- a/powerSupply.py +++ b/powerSupply.py @@ -1,3 +1,13 @@ +""" +PowerSupply.py +Control a VISA compatible power supply with SCPI over USB + +@author BSI + +This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" # Imports - logger import logging import logger From 1e3b8c2f47e64aeac239f9d8a9d847d9e4dcbe17 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 22 Apr 2023 20:01:13 -0700 Subject: [PATCH 8/9] Improved formatting a tiny bit --- main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index e3b3020..faa5c45 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,10 @@ """ main.py -This script is intended to perform semi-automated testing on a generator as well as wind-tunnel testing for a wind turbine. Data is recorded to a `.csv` file in the `/output/` directory and logs are stored in `/logs/`. Different components can be enabled or disabled by changing the variables marked by `# CONFIG`. +This script is intended to perform semi-automated testing on a generator + as well as wind-tunnel testing for a wind turbine. Data is recorded + to a `.csv` file in the `/output/` directory and logs are stored in + `/logs/`. Different components can be enabled or disabled by + changing the variables marked by `# CONFIG`. @author BSI From 519bd153be92cca6d7b9cdd165fc837ce22e71c4 Mon Sep 17 00:00:00 2001 From: BobSaidHi <47067448+BobSaidHi@users.noreply.github.com> Date: Sat, 30 Sep 2023 13:48:48 -0700 Subject: [PATCH 9/9] Some updates --- DynamometerControl.code-workspace | 3 ++- electronicLoad.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/DynamometerControl.code-workspace b/DynamometerControl.code-workspace index fc62ddc..324340b 100644 --- a/DynamometerControl.code-workspace +++ b/DynamometerControl.code-workspace @@ -19,6 +19,7 @@ "Sensorless", "Siglent", "visalib" - ] + ], + "workbench.colorTheme": "Default Dark+" } } \ No newline at end of file diff --git a/electronicLoad.py b/electronicLoad.py index 5b11cde..67678ff 100644 --- a/electronicLoad.py +++ b/electronicLoad.py @@ -104,7 +104,7 @@ def verifyConfig(self): logger.debug("Electronic load CR resistor range value: " + str(self.instrument.query(":SOURce:RESistance:RRANGe?"))) logger.debug("Electronic load OCP state: " + self.powerSupply.query(":SOURce:CURRent:PROTection:STATe?")) # Not sure, this wasn't how it was in the manuel but it makes more since - raise NotImplementedError # TODO + # Verify parameters logger.debug("Power supply Ch. 1 OCP value: " + self.powerSupply.query(":OUTP:OCP:VAL? CH1")) logger.debug("Power supply Ch. 1 OVP state: " + self.powerSupply.query(":OUTP:OVP? CH1")) @@ -112,6 +112,7 @@ def verifyConfig(self): logger.debug("Power supply OTP state: " + self.powerSupply.query(":SYSTem:OTP?")) logger.debug("Power supply system version: " + self.powerSupply.query(":SYSTem:VERSion?")) + raise NotImplementedError # TODO # Control def enable(self):