Witam!
Od niedawna jestem posiadaczem programowalnego zasilacza laboratoryjnego AX-3003P.
Jedną z funkcji jest możliwość kontrolowania zasilacza za pomocą komend wysyłanych po USB. Axio met stworzyło nawet oprogramowanie do sterowanie zasilaczem a także kilka przykładów w Lab View.
Niestety jest z tym kilka problemów:
- Oprogramowanie do sterowania zasilaczem to w zasadzie tylko wygodniejsza wersja panelu kontrolnego.
2. Lab view kosztuje
3. Ani Lab view ani ta aplikacja nie działa na linuxie.
Aby rozwiązać te problemy postanowiłem napisać pakiet w Pythonie3 który umożliwi mi pisanie własnych programów do kontroli zasilacza.
Standard Commands for Programmable Instruments (SCPI)
Mój zasilacz jak większość programowalnego sprzętu laboratoryjnego jest kontrolowany za pomocą komend SCPI.
SCPI to standard syntaxów komend używanych do kontroli sprzętu pomiarowe i testowego. Według dokumentacji, zasilacz obsługuje 59 różnych komend SCPI. Poniżej zamieszczam tabelę najczęściej używanych komend. W załącznikach znajdziecie pełną dokumentację zasilacza.
Komenda | Przykład | OPis |
CURR [current] | CURR 0.1 | Ustaw natężenie prądu. |
VOLT [voltage] | VOLT 5 | Ustaw napięcie. |
MEAS:CURR? | MEAS:CURR? | Zmierz natężenie. |
MEAS:VOLT? | MEAS:VOLT? | Zmierz napięcie . |
OUTP [state] | OUTP ON | Wyłącz/Włącz wyjście. |
CURR:PROT [current] | CURR:PROT 1 | Ustaw natężenie granicznego OCP. |
CURR:PROT? | CURR:PROT? | Sprawdź natężenie graniczne OCP. |
CURR:PROT:CLE | CURR:PROT:CLE | Zresetuj OCP. |
CURR:PROT:TRIP? | CURR:PROT:TRIP? | Sprawdź czy OCP zostało aktywowane. |
CURR:PROT:STAT [state] | CURR:PROT:STAT ON | Wyłącz/Włącz OCP. |
VOLT:PROT [voltage] | VOLT:PROT 10 | Ustaw napięcie graniczne OVP. |
VOLT:PROT? | VOLT:PROT? | Sprawdź napięcie graniczne OVP. |
VOLT:PROT:CLE | VOLT:PROT:CLE | Zresetuj OVP. |
VOLT:PROT:TRIP? | VOLT:PROT:TRIP? | Sprawdź czy OVP zostało aktywowane. |
VOLT:PROT:STAT [state] | VOLT:PROT:STAT OFF | Wyłącz/Włącz OVP. |
Notatka:
OVP – OverVoltage Protection
OCP – OverCurrent Protection
Po podłączeniu zasilacza do komputera uruchomiłem komendę lsusb aby wyświetlić listę wszystkich urządzeń podłączonych do portów USB.
Zasilacz pokazał się jako “Prolific Technology, Inc. PL22303 Serial Port”.
Następnie uruchomiłem komendę l na ścieżce /dev/setial/by-id aby wyświetlić wszystkie porty szeregowe obecne w systemie.
Na moim systemie l to alias do komendy ls -l.
Jak widać zasilacz jest dostępny pod plikiem /dev/ttyUSB0.
Jednak przed otworzeniem portu szeregowego, pozostał do zrobienia jeszcze jeden krok.
Ustawienia dostępu pliku /dev/ttyUSB0 nie pozwalają na otworzenie pliku chyba że użytkownik jest rootem albo należy do grupy dialout.
Aby rozwiązać ten problem po prostu dodałem siebie do grupy dialout wykonując komendę usermod -A -G dialout [użytkownik] jako root.
Wiem że nie jest to eleganckie rozwiązanie i pewnie parę osób będzie narzekało w komentarzach ale trudno. (:
Teraz kiedy mam już dostęp do zasilacza, pora na otworzenie portu szeregowego i wykonanie kilku komend.
Ustawienia dla portu szeregowego (domyślne ustawienia zasilacza) to:
- Baud rate: 9600
- 1 stop bit
- Brak bitów parzystości
- 8 Bitów danych
- Brak kontroli przepływu.
Jako terminala użyłem programu o nazwie cutecom: https://gitlab.com/cuteco/cutecom.
Program jest dostępny jako binarka na większości dystrybucji linuxa, a także na Maca i Windowsa.
Jak widać wszystko działa. Na wysłanie komendy MEAS:CURR? zasilacz odpowiedział aktualnym natężeniem prądku na wyjściu zasilacza wysłanym jako tekst zakończony nową linią (znak \n). Każda komenda wysłana do zasilacza również jest zakończone znakiem nowej linii.
Proof of concept
Teraz pora na stworzenie prostego skryptu który udowodni że da się steroważ zasilaczem laboratoryjny za pomocą pythona.
Skrypt wykorzystuje pakiet pySerial który dostarcza wygodną w użyciu klasę do obsługi portu szeregowego. Pakiet działa na Windowsie, Linuxie, systemach MacOS a także na BSD.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import serial import sys from time import sleep #check if we have necessary arguments if(len(sys.argv) < 2): print("usage: python3 test.py [device]") print("Example: python3 test.py /dev/ttyUSB0") sys.exit(1) device = sys.argv[1] #open connection to the serial port port = serial.Serial(port=device, baudrate=9600, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=None, xonxoff=False, rtscts=False, write_timeout=None, dsrdtr=False, inter_byte_timeout=None, exclusive=None) #set power supply to 0.1A 1.12V and enable the outpt port.write(b'CURR 0.1\n') sleep(0.1) port.write(b'VOLT 1.12\n') sleep(0.1) port.write(b'OUTP ON\n') sleep(0.1) #keep printing the voltage readout while 1: port.write(b'MEAS:VOLT?\n') sleep(0.1) if port.in_waiting > 0: print(port.readline()) else: print("No response") |
Skrypt przyjmuje ścieżkę do portu szeregowego jako argument z konsoli, otwiera połączenie do zasilacza, ustawia natężenie prądu na 0.1A i napięcie na 1.12V a następnie uruchamia wyjście i wyświetla napięcie na wyjściu w pętli.
Oto przykładowy efekt.
Opóźnienia po wysłaniu komend zostały dodany ponieważ zauważyłem że jeśli komendy są wysyłane w krótkich odstępach czasu, zasilacz czasem nie wykonuje cześci z nich mimo że zostały one wysłane poprawnie.
Struktura pakietu Pythona
Podstawy pakowania projektów napisanych w pythonie zostały bardzo dobrze opisane w oficjalnym tutorialu https://packaging.python.org/tutorials/packaging-projects/
W moim przypadku struktura pakietu wygląda tak:
Folder AX3003P zawieta właściwy kod pakietu. W pliku AX3003P.py znajduje się klasa która będzie zawierała cały kod niezbędny do obsługi zasilacza. (Nasz pakiet nie będzie zawierał dużych ilości kodu więc umieszczenie wszystkiego w jednym pliku ma w tym wypadku sens).
Plik init.py jest niezbędny aby folder AX3003P został rozpoznany jako moduł.
Folder examples zawiera przykłady użycia biblioteki.
Plik LICENSE zawiera tekst licencji pod którą pakiet jest udostępniony (wybrałem MIT).
Plik README.md zawiera opis pakietu.
Plik requirements.txt zawiera listę zależności pakietu.
Plik setup.py zawiera skrypt używany do budowania pakietu. W tym pliku zdefiniowane są także metadane pakietu.
Łączenie się z zasilaczem
Pierwszy kod który musiałem napisać to kod do łączenia się z zasilaczem:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
from time import sleep, time_ns import serial WRITE_COMMAND_DELAY = 0.1 # 100ms - Delay after the write command. READ_RETRIES_DELAY = 0.03 # 30ms - Delay between read command and waiting for response def connect(device): return AX3003P(device) class AX3003P(): def __init__(self, device): self.device = device self.connect() def connect(self): self.connection = serial.Serial(port=self.device, baudrate=9600, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=None, xonxoff=False, rtscts=False, write_timeout=100, dsrdtr=False, inter_byte_timeout=None, exclusive=None) def disconnect(self): self.connection.close() |
Wewnątrz klasy AX3003P umieściłem dwie metody. Metoda connect() łączy się z zasilaczem, metoda disconnect() rozłącza połączenie.
Ścieżka do portu szeregowego jest przechowywana w self.device. Konstruktor klasy przyjmuje jako jedyny parametr ścieżkę do portu szeregowego zasilacza.
Funkcja connect() poza klasą jest po to aby po dodaniu:
1 |
from .AX3003P import connect |
Do pliku init.py, było możliwe takie użycie biblioteki w taki sposób:
1 2 3 4 5 6 |
import AX3003P psu = AX3003P.connect("/dev/ttyUSB0") #czytelnie zaznaczamy że łączymy się z zasilaczem w tej linijce psu.someMethod() psu.disconnect() |
Który w mojej opinii jest dużo bardziej czytelny niż takie użycie:
1 2 3 4 5 6 |
import AX3003P psu = AX3003P("/dev/ttyUSB0") #bez dokumentacji nie wiadomo czy się połączyliśmy czy nie psu.someMethod() psu.disconnect() |
Które trzeba byłoby stosować gdyby tej funkcji tam nie było.
Metody do wysyłania komend
Teraz pora na dodanie metod do wysyłania komend do zasilacza. Zasilacz przyjmuje dwa główne typy komend.
Pierwszy typ komend nic nie zwraca. Drugi typ komend zwraca jakieś dane.
Pierwszy typ jest zaimplementowany tutaj:
1 2 3 4 5 6 7 |
def sendWriteCommand(self, command): # message must be in a form of a byte array message = command + "\n" message = str.encode(message) self.connection.write(message) sleep(WRITE_COMMAND_DELAY) |
Metoda najpierw dodaje do komendy znak nowej linii a następnie enkoduje ją do typu byte array (metoda encode()), wysyła ją do zasilacza i czeka WRITE_COMMAND_DELAY sekund.
Implementacja drugiego typu komend jest nieco bardziej skomplikowana:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def sendReadCommand(self, command, timeout=1000): # message must be in a form of a byte array message = command + "\n" message = str.encode(message) stopTime = time_ns() + timeout*1000000 while time_ns() < stopTime: self.connection.write(message) sleep(READ_RETRIES_DELAY) if self.connection.in_waiting > 0: received = self.connection.readline() received = received.decode('unicode_escape') return received[:-1] return None |
Początek jest ten sam: dodajemy znak nowej linii i enkodujemy komendę.
Później wysyłamy wiadomość do zasilacza i czekamy READ_RETRIES_DELAY sekund. Następnie sprawdzamy czy w buforze odebranych danych są jakieś bajty. Jeśli tak odczytujemy jedną linię z bufora, dekodujemy wiadomość do stringa (format unicode_escape dekoduje wiadomośc jako ASCI), usuwamy znak nowej linii z końca stringa i zwracamy wynik. Jeśli w buforze nie było żadnych danych to wtedy wysyłamy komendę jeszcze raz i cały proces zaczyna się od nowa. Jeśli po timeout milisekund nadal nie otrzymamy odpowiedzi to wtedy metoda zwraca None co sygnalizuje problem z połączeniem.
WRITE_COMMAND_DELAY jest ustawione na 100 milisekund (wartość ustalona eksperymentalnie).
Wartość READ_RETRIES_DELAY została ustalona na podstawie eksperymentu w którym mierzyłem ilość komend po których nie otrzymałem odpowiedzi w funkcji READ_RETRIES_DELAY.
Wyniki z eksperymentu:
Na podstawie wyników eksperymentu ustaliłem opóźnienie READ_RETRIES_DELAY na 30 milisekund.
Metody do kontrolowania zasilacza
Teraz pora na napisanie kodu który będzie odpowiadał za kontrolowanie zasilacza.
Dzięki metodom które napisałem wcześniej większość implementacji sprowadzała się do jednej linijki kodu.
Oto kilka przykładów:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def setVoltage(self, voltage): self.sendWriteCommand("VOLT "+str(voltage)) def setCurrent(self, current): self.sendWriteCommand("CURR "+str(current)) def measureVoltage(self): return float(self.sendReadCommand("MEAS:VOLT?")) def measureCurrent(self): return float(self.sendReadCommand("MEAS:CURR?")) def measurePower(self): return float(self.sendReadCommand("MEAS:POW?")) |
float() odpowiada za konwersję stringa zwracanego przez sendReadCommand do zmiennej typu float.
Dokumentacja pakietu za pomocą Sphinxa
Kolejnym etapem projektu było napisanie dokumentacji.
Do tego celu wykorzystałem narzędzie do generowania dokumentacji o nazwie Sphinx.
Narzędzie przyjmuje strony dokumentacji napisane jako tekst reST (reStructuredText) i generuje dokumentację w postacji strony internetowej.
Dobry tutorial na temat instalacji narzędzia znajduje się tutaj: http://www.sphinx-doc.org/en/1.5/tutorial.html
Polecam również ten tutorial: http://www.sphinx-doc.org/en/master/usage/quickstart.html
W moim przypadku użyłem również rozszerzenia o nazwie autodoc. Umożliwia ono pisanie dokumentacji bezpośrednio w kodzie źródłowym i automatyczne generowanie odpowiedniej strony html. Narzędzie autodoc obsługuje kilka stylów dokumentacji. Ja użyłem stylu reST.
Oto przykład udokumentowanych funkcji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
def connect(self): """ Connect to the power supply over the serial interface. Connects to the power supply over the serial interface. :raises SerialException: If there is a problem with the serial port. """ self.connection = serial.Serial(port=self.device, baudrate=9600, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=None, xonxoff=False, rtscts=False, write_timeout=100, dsrdtr=False, inter_byte_timeout=None, exclusive=None) def disconnect(self): """ Disconnect from the power supply. Closes serial connection to the power supply. """ self.connection.close() def sendWriteCommand(self, command): """ Send command to the power supply. Sends command to the power supply. :param command: Command :type command: Str """ # message must be in a form of a byte array message = command + "\n" message = str.encode(message) self.connection.write(message) |
Więcej przykładów można znaleźć tutaj: https://thomas-cokelaer.info/tutorials/sphinx/docstring_python.html
Po dodaniu strony z takim oto kodem do dokumentacji:
1 2 3 4 5 6 7 8 |
================================= AX3003P package API documentation ================================= .. automodule:: AX3003P.AX3003P :members: :undoc-members: :show-inheritance: |
Dodania rozszerzenia autodoc do listy rozszerzeń w pliku konfiguracyjnym (conf.py)
1 |
extensions = ['sphinx.ext.autodoc'] |
I zbudowaniu dokumentacji, moim oczom ukazała się gotowa dokumentacja:
Następnie zdecydowałem się opublikować dokumentacji w serwisie readthedocs.com. Jest to platforma która umożliwia automatyczne budowanie i publikację dokumentacji projektu. Dokładne instrukcje o tym jak to zrobić są przedstawione w oficjalnym tutorialu: https://docs.readthedocs.io/en/stable/intro/getting-started-with-sphinx.html
A oto efekt:
Publikowanie pakietu na PyPI
Teraz pora na zbudowanie naszego pakietu i publikację na PyPI. PyPI (Python Package Index). Jest to oficjalne repozytorium zawierające. Pakiety znajdujące się w tym repozytorium mogą następnie zostać zainstalowane za pomocą menagera pakietów pip. Publikowanie projektów a PyPI jest bardzo proste.
Najpierw założyłem konto na PyPI https://pypi.org/
Następnie dodałem taki oto kod do pliku setup.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
from setuptools import setup def readme(): with open('README.md') as f: return f.read() setup(name='AX3003P', version='0.1', author='Krzysztof Adamkiewicz', author_email='kadamkiewicz835@gmail.com', url='https://github.com/Bill2462/AX3003P', description='Package for controlling AX-3003P Programmable DC Power Supply', long_description=readme(), packages=['AX3003P'], classifiers=['Development Status :: 3 - Alpha', 'Intended Audience :: Science/Research', 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', ], install_requires=[ 'pyserial', ], ) |
Pakiet setuptools zawiera zestaw narzędzi umożliwiających budowanie własnych pakietów.
Funkcja setup jako argumenty przyjmuje zestaw parametrów opisujących pakiet.
Następnym krokiem było zbudowanie pakietu. Aby to zrobić należy najpierw zainstalować 2 dodakowe pakiety: wheel i setuptools.
Można to zrobić za pomocą komendy: pip install wheel setuptools.
Następnie zbudowałem pakiet uruchamiając skrypt setup.py z argumentami sdist i bdist_wheel.
Jeśli wszystko poszło dobrze, w folderze projektu powinien pojawić się folder dist zawierający dwa pliki. Jeden z rozszerzeniem .whl a drugi z rozszerzniem .tar.gz.
Ostatnim krokiem jest wysłanie zbudowanego pakietu do PyPI.
W tym celu zainstalowałem program o nazwie twine. Twine to narzędzie służące do interakcji z PyPI. Można je zainstalować za pomocą menagera pip.
Wysłanie pakietu do PyPI było potem już kwestią jednej komendy:
Po mniej więcej minucie od wrzucenia pakietu na PyPI byłem w stane pobrać go za pomocą menagera pip.
Linki
To by było na tyle jeśli chodzi o ten niewielki projekt.
Kod źródłowy całej biblioteki jest dostępny na githubie: https://github.com/Bill2462/AX3003P
W załączniku znajdziecie dokumentację pozostałych komend obsługiwanych przez zasilacz.
Pliki załączone do artykułu:
- single-output-programmable-dc-power-supply-programming-manual-rev4.pdf
- single-output-programmable-dc-power-supply_v02_20140814.pdf
profeska!