Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
99 changes: 99 additions & 0 deletions DataRecorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
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

# Start Logger
logger = logging.getLogger("DynamometerControl")
logger.setLevel(logging.DEBUG)

# Other imports
import csv
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)')
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, ps_voltage=-1, ps_current=-1, odrive_bus_voltage=-1, odrive_bus_current = -1):
"""
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()
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):
"""
Closes the file opened in __init__
"""
logger.debug("Closed data file")
self.data_file.close()

5 changes: 4 additions & 1 deletion DynamometerControl.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
],
"settings": {
"cSpell.words": [
"DYNO",
"highlevel",
"odrive",
"odrv",
"pyvisa",
"Rigol",
"scpi",
"Sensorless",
"Siglent",
"visalib"
]
],
"workbench.colorTheme": "Default Dark+"
}
}
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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/.
182 changes: 182 additions & 0 deletions electronicLoad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
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

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

# 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?"))

raise NotImplementedError # TODO

# 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

Loading