Sunday, October 27, 2019

Unit testing of circuit schematic operations - Part I

After setting up the test environments for the command line and the web app in the previous post, I now get started with some serious testing. To begin with, the code for this can be found in the testing branch in either of these two repositories:
https://sourceforge.net/p/pythonpowerelec/code/ci/testing/tree/
https://bitbucket.org/shivkiyer/ppe_simulator/branch/testing

I expanded the testing of csv_element_2D method to the testing of csv_element_2D and csv_tuple_2D as these two methods quite often go hand in hand since one converts a tuple to a string cell position and the vice versa. Here is the code:

def test_network_cell_conversions():
"""
Testing csv_element_2D and csv_tuple_2D
csv_element_2D - converts a numeric tuple to a string cell cell_position
csv_tuple_2D - converts a string cell position to a numeric tuple
"""
from network_reader import csv_element_2D, csv_tuple_2D
import random
print()
# Testing what can be humanly verified
print("Manual tests")
assert csv_element_2D([0, 26]) == "1AA"
assert csv_tuple_2D("1AA") == [0, 26]
print("Testing for co-ordinate {}, {} that comes to {}".format(0, 26, "1AA"))
assert csv_element_2D([11, 26]) == "12AA"
assert csv_tuple_2D("12AA") == [11, 26]
print("Testing for co-ordinate {}, {} that comes to {}".format(11, 26, "12AA"))
assert csv_element_2D([33, 52]) == "34BA"
assert csv_tuple_2D("34BA") == [33, 52]
print("Testing for co-ordinate {}, {} that comes to {}".format(33, 52, "34BA"))
print()
# Testing random entries and checking if reverse conversion works.
print("Random automatic tests")
for test_count in range(20):
x = random.randint(0, 200)
y = random.randint(0, 200)
cell_position = csv_element_2D([x, y])
assert csv_tuple_2D(cell_position) == [x, y]
print("Testing for co-ordinate {}, {} that comes to {}".format(x, y, cell_position))
print()
return
There are two types of tests. First, the manual tests are tests where I know there could be a problem and these are the borderline cases - when for example 1Z becomes 1AA or 9A becomes 10A etc. Manual tests can be limited unless you have a lot of time or the function is very complicated and only manual tests are possible. In this case, automatic tests are also possible. For automatic tests, I randomly generate tuples of x (row) and y (column) positions and convert them into string cell positions using csv_element_2D and then convert the resultant cell positions back into tuples using csv_tuple_2D. I chose 20 such iterations and made asserts for each case.

The method can be extended to the Django web app with minor modifications - only the assert statement of pytest will be replaced by assertEqual of TestCase.

Next comes the csv_reader method that takes in a file object which is the .csv file and returns a 2D matrix (lists embedded within a list) representation of the circuit. This is the code:

def test_csv_reader():
"""
csv_reader - converts string eq of a .csv file to a matrix.
"""
import os
from network_reader import csv_reader
# Create a dummy test file as .csv file
f = open("test_reader.csv", "w")
# Write contents that could be a circuit
f.write("a,, b,,")
f.write("\n")
f.write(",c,, e ,")
f.close()
# Infer the matrix 2D representation of the circuit
req_result = [['a','','b','',''], ['','c','','e','']]
f = open("test_reader.csv")
# Pass the dummy test file to csv_reader
ckt_matrix = csv_reader(f)
print()
print("Test for {}".format(str(req_result)))
f.close()
assert ckt_matrix == [['a','','b','',''], ['','c','','e','']]
# Cleanup - remove the test .csv file
os.remove("test_reader.csv")
print()
return
It is important to write the desired circuit contents to a file. Simply passing the string contents to csv_reader as the argument will not work. This is because when Python opens a file, it creates an iterable object and so you can iterate line by line. However, if I pass a string, the iteration will happen character by character which is not what we want.

I didn't bother with circuit components or circuit topology because what matters here is the reading of the .csv file. A few errors are expected:
  1. If the separator is anything other than a comma, the reading fails.
  2. If one line has an extra character, the reading fails. This is because the expected result is a 2D matrix. This might be ok in most cases, but it might be a good idea to put a catch block in case, some weird spreadsheet software passes through a schematic with unequal line lengths. This should result in a user readable error rather than a syntax error.
  3. The elements are scrubbed internally which means leading and trailing spaces are removed from the elements. This is ok.

Will need to spend some thought on this as spreadsheet software in general are a bit messy and the need to generate .csv files in a particular form may not be obvious to the normal user.

Sunday, October 20, 2019

Starting unit testing in Python Power Electronics

My work as a web developer has introduced me to the wonders of unit testing. No one likes writing tests and neither did I for a very long time. I relied on manual testing like many others. Until I started writing unit tests in Jasmine for my web apps. The number of scenarios that you can test makes your code way more reliable.

Another advantage of testing is that quite often there are changes you would like to make but don't want to make them right now as you might break the code that is working. And in the end these changes never happen as you get too busy with other stuff. Here, test driven development can solve the problem of this inertia. Even if you do not want to change the code itself, test suites can help to totally determine every potential lapse in a block of code that will take away the fear bit by bit of finally getting around to fixing them.

With these noble thoughts, I have finally got over my laziness and decided to write tests for the circuit simulator. As a start, Python comes with the in-built unittest module for writing tests. Django uses this and creates a class TestCase to test API end points. Besides this, Python also has the pytest module that needs to be separately installed.

So, I decided to write unit tests for the command line interface with pytest and for the web app using Django's TestCase class based on unittest. In the beginning, the code for these will be separate. However, with time, the plan will be have one set of tests and also at the same time have one set of code for both web apps.

So, the directory structure for the simulator is:

README
command_line
            --  circuit_solver.py
            --  circuit_elements.py
            --  circuit_exceptions.py
            --  LICENSE.txt
            --  matrix.py
            --  network_reader.py
            --  solver.py

web_app
            -- requirements.txt
            -- simulator_interface
                     -- LICENSE.txt
                     -- manage.py
                     -- simulation_collection
                     -- media_files
                     -- simulator_interface
                                 -- settings.py
                                 -- urls.py
                                 -- wsgi.py
                     -- simulations
                                 -- circuit_solver.py
                                 -- circuit_elements.py
                                 -- circuit_exceptions.py
                                 -- solver.py
                                 -- network_reader.py
                                 -- matrix.py
                                 -- admin.py
                                 -- views.py
                                 -- models.py
                                 -- tests.py


There may be a few more directories and files, but these are the major ones at least for testing. We will create a tests directory inside command_line directory and house the command line app tests there. We will remove the tests.py file inside the simulations directory  inside web_app/simulator_interface and create a tests directory for the web app tests.

To install pytest, just do:
pip install pytest

For the command line app, I decided to get started with testing by writing a very simple test for the function csv_element_2D:

def csv_element_2D(elem):
"""
Takes the [row, column] input for a csv file
and given a human readable spreadsheet position.
"""
# Convert column numbers to alphabets
csv_col = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"
csv_dict = {}
csv_col_list = csv_col.split(" ")
for c1 in range(26):
csv_dict[c1] = csv_col_list[c1]
# Because row 0 doesn't exist on a
# spreadsheet
row = elem[0]+1
col = elem[1]
# Create a list of all the alphabets
# that a column will have
col_nos = [-1]
# On the run, an alphabet will
# have a remainder and a prefix
# This is essentially the first and
# second alphabet
prefix = 0
remdr = col
# The alphabet that is to be found
col_count = 0
while remdr-25>0:
# If the column>26, the first
# alphabet increments by 1
prefix += 1
remdr = remdr-26
if remdr<26:
if prefix>25:
# More than 2 alphabets
col_nos[col_count] = remdr
# The remainder takes the prefix
remdr = prefix-1
# The prefix is the third/next alphabet
prefix = 0
# Add another element to the list
col_nos.append(-1)
col_count += 1
else:
# 2 alphabets only
col_nos.append(-1)
col_nos[-1] = prefix-1
col_nos[col_count] = remdr
col_letters = ""
# The alphabets are backwards
for c1 in range(len(col_nos)-1, -1, -1):
col_letters = col_letters + csv_dict[col_nos[c1]]
csv_format = str(row) + col_letters
return csv_format


This function takes a tuple/list that represents a row, column position and converts it to a string that represents the cell position. So, [0,0] will be "1A" on a spreadsheet. So, a test for this function in the the command_line app would be:

import os,sys,inspect
# Inserting the parent directory command_line
# into the python path. Without that imports
# in the tests do not work.
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
software_dir = os.path.dirname(currentdir)
sys.path.insert(0,software_dir)
def test_csv_element_2D():
"""
Testing csv_element_2D - takes a tuple and returns the string cell position.
"""
from network_reader import csv_element_2D
assert csv_element_2D([0, 0]) == "1A"


The test itself if very simple. We import the method from the file network_reader.py that has the method. The test function has to start with test_ for pytest to execute it. The asset method in pytest merely asserts that for the test to pass, the value returned by the function for a particular argument has to be equal to a certain value.

The challenge in getting this test to run was in pytest being able to find the file/module network_reader.py. For this the first block of code above the function had to be written. I had to extract the parent method and insert it into the python path so that Python will look in that directory for the module and import it.

The test for the web app was a bit easier as I didn't have to struggle with path as much. The only challenge was I was trying to use pytest and it would not work as pytest for some reason could not find django module that is imported in the web app modules. The reason I can think of is in Django 2 onwards, the app needs to be loaded and therefore, django is not available until the app is working. For this, I had to go with the default Django TestCase class.

from django.test import TestCase
class CSVElement2D(TestCase):
"""
Testing csv_element_2D - takes a tuple and returns the string cell position.
"""
def test_csv_element_2D(self):
from simulations.network_reader import csv_element_2D
self.assertEqual(csv_element_2D([0, 0]), "1A")


To run these tests. Inside command_line directory, just run the command:
pytest

There are several arguments that can be passed and I will investigate as time goes on. For the web app, inside the directory that contains manage.py, run:
python manage.py test simulations

The app name simulations is important because the tests directory with the test is inside the simulations app directory.