Modified og_gui_manager.py to make it accessible on devices not on the efabless platform. Changed the create project script to make the proper config directories so that the editors can be used. Modified profile.py to make the settings properly reflect the user preferences.
diff --git a/common/cace_datasheet_upload.py b/common/cace_datasheet_upload.py
new file mode 100755
index 0000000..95469b1
--- /dev/null
+++ b/common/cace_datasheet_upload.py
@@ -0,0 +1,193 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3 -B
+#
+# cace_datasheet_upload.py
+#
+# Send newly-created challenge datasheet and associated
+# design files (testbench schematics and netlists) to
+# the  marketplace server for storage.
+#
+
+import os
+import json
+import re
+import sys
+import requests
+import subprocess
+
+import file_compressor
+import file_request_hash
+
+import og_config
+
+"""
+ Open Galaxy standalone script.
+ Makes rest calls to marketplace REST server to save datasheet
+ and associated file(s).  Request hash is generated so the two
+ requests can be associated on the server side.  This action
+ has no other side effects.
+"""
+
+mktp_server_url = og_config.mktp_server_url
+
+# Make request to server sending json passed in.
+def send_doc(doc):
+    result = requests.post(mktp_server_url + '/cace/save_datasheet', json=doc)
+    print('send_doc', result.status_code)
+
+# Pure HTTP post here.  Add the file to files object and the hash/filename
+# to the data params.
+def send_file(hash, file, file_name):
+    files = {'file': file.getvalue()}
+    data = {'request-hash': hash, 'file-name': file_name}
+    result = requests.post(mktp_server_url + '/cace/save_files', files=files, data=data)
+    print('send_file', result.status_code)
+
+
+if __name__ == '__main__':
+
+    # Divide up command line into options and arguments
+    options = []
+    arguments = []
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    # There should be two arguments passed to the script.  One is
+    # the path and filename of the datasheet JSON file, and the
+    # other a path to the location of testbenches (netlists and/or
+    # schematics).  If there is only one argument, then datasheet_filepath
+    # is assumed to be in the same path as netlist_filepath.
+
+    datasheet_filepath = []
+    netlist_filepath = []
+
+    for argval in arguments:
+        if os.path.isfile(argval):
+            datasheet_filepath = argval
+        elif os.path.isdir(argval):
+            netlist_filepath = argval
+        elif os.path.splitext(argval)[1] == '':
+            argname = argval + '.json'
+            if os.path.isfile(argval):
+                datasheet_filepath = argname
+
+    if not datasheet_filepath:
+        # Check for JSON file 'project.json' in the netlist filepath directory
+        # or the directory above it.
+        if netlist_filepath:
+            argtry = netlist_filepath + '/project.json'
+            if os.path.isfile(argtry):
+                datasheet_filepath = argtry
+            else:
+                argtry = os.path.split(netlist_filepath)[0] + '/project.json'
+                if os.path.isfile(argtry):
+                    datasheet_filepath = argtry
+
+        # Legacy behavior support
+        if not os.path.isfile(datasheet_filepath):
+            # Check for JSON file with same name as netlist filepath,
+            # but with a .json extension, in the netlist filepath directory
+            # or the directory above it.
+            if netlist_filepath:
+                argtry = netlist_filepath + '/' + os.path.basename(netlist_filepath) + '.json'
+                if os.path.isfile(argtry):
+                    datasheet_filepath = argtry
+                else:
+                    argtry = os.path.split(netlist_filepath)[0] + '/' + os.path.basename(netlist_filepath) + '.json'
+                    if os.path.isfile(argtry):
+                        datasheet_filepath = argtry
+
+    if not datasheet_filepath:
+        print('Error:  No datasheet JSON file specified.\n')
+        sys.exit(1)
+
+    if not os.path.isfile(datasheet_filepath):
+        print('Error:  No datasheet JSON file ' + datasheet_filepath + ' found.\n')
+        sys.exit(1)
+
+    # Technically okay to have null netlist_filepath, but unlikely,
+    # so flag a warning.
+
+    if not netlist_filepath:
+        print('Warning: No netlist filepath given.  No files will be '
+		+ 'transmitted with the datasheet.\n')
+
+    dsheet = {}
+    with open(datasheet_filepath, 'r') as user_doc_file:
+        docinfo = json.load(user_doc_file)
+        dsheet = docinfo['data-sheet']
+        try:
+            name = dsheet['ip-name']
+        except KeyError:
+            datasheet_file = os.path.split(datasheet_filepath)[1]
+            name = os.path.splitext(datasheet_file)[0]
+            dsheet['ip-name'] = name
+
+    # Behavior starting 4/27/2017:  the UID is required to be a
+    # numeric value, but the datasheet upload is generally being
+    # done as user admin from the remote CACE host, and admin has
+    # no official UID number.  So always attach ID 9999 to datasheet
+    # uploads.
+
+    if 'UID' in docinfo:
+        uid = docinfo['UID']
+    else:
+        uid = 9999
+        docinfo['UID'] = uid
+
+    # Get a request hash and add it to the JSON document
+    rhash, timestamp = file_request_hash.get_hash(name)
+    docinfo['request-hash'] = rhash
+
+    # Put the current git system state into the target directory
+    # prior to tarballing
+    if os.path.isfile('/ef/.ef-version'):
+        with open('/ef/.ef-version', 'r') as f:
+            ef_version = f.read().rstrip()
+        docinfo['ef-version'] = ef_version
+
+    # Now send the datasheet
+    if '-test' in options:
+        print("Test:  running send_doc( <docinfo> )\n")
+        print("       with docinfo['UID'] = " + uid + "\n")
+    else:
+        send_doc(docinfo)
+
+    # Send the merged JSON file and the tarballed design file directory
+    # to the marketplace server for storage.
+
+    if netlist_filepath:
+        # Use the version below to ignore the 'spi' directory.  However, it may
+        # be the intention of the challenge creator to seed the challenge with
+        # an example.  This is normally not the case.  So use '-include' option
+        # to avoid excluding the 'spi' folder contents.
+        if '-includeall' in options:
+            print('Including netlist/schematic and simulation files in project folder.')
+            tar = file_compressor.tar_directory_contents(netlist_filepath,
+			exclude=['elec/\.java', 'elec/electric\.log', name + '\.log',
+			'.*\.raw', 'ngspice/run/\.allwaves'])
+        elif '-include' in options:
+            print('Including netlist/schematic files in project folder.')
+            tar = file_compressor.tar_directory_contents(netlist_filepath,
+			exclude=['ngspice', 'elec/\.java', 'elec/electric\.log',
+			name + '\.log'])
+        else:
+            print('Excluding netlist/schematic and simulation files in project folder.')
+            tar = file_compressor.tar_directory_contents(netlist_filepath,
+			exclude=['spi', 'ngspice', 'elec/\.java', 'mag', 'elec/electric\.log',
+			'elec/' + name + '\.delib/' + name + '\.sch', name + '\.log'])
+        tarballname = name + '.tar.gz'
+
+        # Now send the netlist file tarball
+        if '-test' in options:
+            print('Test:  running send_file(' + rhash + ' <tarball> ' + tarballname + ')')
+            print('Saving tarball locally as ' + tarballname)
+            file_compressor.tar_directory_contents_to_file(netlist_filepath,
+			tarballname, exclude=['spi', 'ngspice', 'elec/\.java', 'mag',
+			'elec/electric\.log', 'elec/' + name + '\.delib/' + name + '\.sch',
+			name + '\.log'])
+        else:
+            send_file(rhash, tar, tarballname)
+
diff --git a/common/cace_design_upload.py b/common/cace_design_upload.py
new file mode 100755
index 0000000..08bdfa2
--- /dev/null
+++ b/common/cace_design_upload.py
@@ -0,0 +1,258 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+# cace_design_upload.py
+#
+# The purpose of this script is to package up the user
+# design schematic and associated files and send them to
+# the remote marketplace server, precipitating a launch
+# of CACE to officially characterize the design.
+#
+
+import os
+import json
+import re
+import sys
+import requests
+import subprocess
+
+import file_compressor
+import file_request_hash
+import local_uid_services
+
+import og_config
+
+"""
+ Open Galaxy standalone script.
+ Makes rest calls to marketplace REST server to save datasheet
+ and associated file(s).  Request hash is generated so the two
+ requests can be associated on the server side.  This action
+ causes the marketplace to run the official characterization
+ on the CACE server.
+"""
+
+mktp_server_url = og_config.mktp_server_url
+cace_server_url = og_config.cace_server_url
+
+# Make request to server sending json passed in.
+def send_doc(doc):
+    result = requests.post(mktp_server_url + '/cace/simulate_request', json=doc)
+    print('send_doc', result.status_code)
+
+# Cancel simulation (sent directly to CACE)
+def send_cancel_doc(doc):
+    result = requests.post(cace_server_url + '/cace/cancel_sims', json=doc)
+    print('send_cancel_doc', result.status_code)
+
+# Pure HTTP post here.  Add the file to files object and the hash/filename
+# to the data params.
+def send_file(hash, file, file_name):
+    files = {'file': file.getvalue()}
+    data = {'request-hash': hash, 'file-name': file_name}
+    result = requests.post(mktp_server_url + '/cace/simulate_request_files', files=files, data=data)
+    print('send_file', result.status_code)
+
+if __name__ == '__main__':
+
+   # Divide up command line into options and arguments
+    options = []
+    arguments = []
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    # There should be two arguments passed to the script.  One is
+    # the path and filename of the datasheet JSON file, and the
+    # other a path to the location of the design (netlist and/or
+    # schematic).
+
+    datasheet_filepath = []
+    design_filepath = []
+
+    for argval in arguments:
+        if os.path.isfile(argval):
+            datasheet_filepath = argval
+        elif os.path.isdir(argval):
+            design_filepath = argval
+        elif os.path.splitext(argval)[1] == '':
+            argname = argval + '.json'
+            if os.path.isfile(argval):
+                datasheet_filepath = argname
+
+    if not datasheet_filepath:
+        # Check for JSON file 'project.json' in the current or parent directory
+        if design_filepath:
+            argtry = design_filepath + '/project.json'
+            if os.path.isfile(argtry):
+                datasheet_filepath = argtry
+            else:
+                argtry = os.path.split(design_filepath)[0] + '/project.json'
+                if os.path.isfile(argtry):
+                    datasheet_filepath = argtry
+
+        if not os.path.isfile(datasheet_filepath):
+            # Legacy behavior support:
+            # Check for JSON file with same name as netlist filepath,
+            # but with a .json extension, in the netlist filepath directory
+            # or the directory above it.
+            if design_filepath:
+                argtry = design_filepath + '/' + os.path.basename(design_filepath) + '.json'
+                if os.path.isfile(argtry):
+                    datasheet_filepath = argtry
+                else:
+                    argtry = os.path.split(design_filepath)[0] + '/' + os.path.basename(design_filepath) + '.json'
+                    if os.path.isfile(argtry):
+                        datasheet_filepath = argtry
+
+    if not datasheet_filepath:
+        print('Error:  No datasheet JSON file specified\n')
+        sys.exit(1)
+
+    if not os.path.isfile(datasheet_filepath):
+        print('Error:  No datasheet JSON file ' + datasheet_filepath + ' found\n')
+        sys.exit(1)
+
+    # Read the datasheet now.  Get the expected design name
+
+    dsheet = {}
+    print('Reading JSON datasheet ' + datasheet_filepath)
+    with open(datasheet_filepath, 'r') as user_doc_file:
+        docinfo = json.load(user_doc_file)
+        dsheet = docinfo['data-sheet']
+        name = dsheet['ip-name']
+
+    # Get JSON file of settings if it exists.  It should be in the same
+    # location as the JSON datasheet file (generated by og_gui_characterize.py)
+    testmode = False
+    force = False
+    settings_filepath = os.path.split(datasheet_filepath)[0] + '/settings.json'
+    if os.path.exists(settings_filepath):
+        with open(settings_filepath, 'r') as user_settings_file:
+            settings = json.load(user_settings_file)
+            docinfo['settings'] = settings
+            if 'submit-as-schematic' in settings:
+                if settings['submit-as-schematic'] == True:
+                    force = True
+            if 'submit-test-mode' in settings:
+                if settings['submit-test-mode'] == True:
+                    testmode = True
+
+    # Use of "-force" in the options overrides any settings from the JSON file.
+    if '-force' in options:
+        force = True
+
+    if '-test' in options:
+        testmode = True
+
+    # Diagnostic
+    if 'identifiers' in docinfo:
+        print('Identifiers: ' + str(docinfo['identifiers']))
+
+    if not design_filepath:
+        print('Error:  No schematic or netlist directory given\n')
+        sys.exit(1)
+    else:
+        # If design_filepath has a subdirectory "design", add that to
+        # the path name.
+        if os.path.isdir(design_filepath + '/spi'):
+            spice_filepath = design_filepath + '/spi'
+            filelist = os.listdir(spice_filepath)
+        if os.path.isdir(design_filepath + '/elec'):
+            if os.path.isdir(design_filepath + '/elec/' + name + '.delib'):
+                schem_filepath = design_filepath + '/elec/' + name + '.delib'
+                filelist.extend(os.listdir(schem_filepath))
+
+        # To be valid, the filepath must contain either a .spi file with
+        # the name of ip-name, or a .sch file with the name of ip-name.
+        netlistname = name + '.spi'
+        schemname = name + '.sch'
+        if netlistname not in filelist and schemname not in filelist:
+            print('Error:  Path ' + design_filepath + ' has no schematic '
+			+ 'or netlist for design ' + name + '\n')
+            sys.exit(1)
+
+    # Add key 'project-folder' to the document, containing the path to the
+    # JSON file.  This is used by the CACE to ensure that progress information
+    # is passed to the correct folder, not relying on hard-coded home paths,
+    # and allowing for copies of projects in different paths.
+
+    foldername = os.path.split(datasheet_filepath)[0]
+    docinfo['project-folder'] = foldername
+
+    # Current expectation is to use UID (username).  If it is not in the
+    # document, then add it here.
+
+    if testmode:
+        uid = {}
+    else:
+        if 'UID' not in docinfo:
+            uid = local_uid_services.get_uid(os.environ['USER'])
+        else:
+            uid = docinfo['UID']
+
+    if not uid or uid == 'null':
+        uid = os.environ['USER']
+    docinfo['UID'] = uid
+
+    # Handle cancel requests
+    if '-cancel' in options:
+        # Read last message . . .
+        if os.path.exists(design_filepath + '/ngspice/char/remote_status.json'):
+            with open(design_filepath + '/ngspice/char/remote_status.json', 'r') as f:
+                status = json.load(f)
+            if 'hash' in status:
+                docinfo['request-hash'] = status['hash']
+                send_cancel_doc(docinfo)
+            else:
+                print('No hash value in status file, cannot cancel.')
+        else:
+            print('No status file found, cannot cancel.')
+        sys.exit(0)
+
+    # If settings specify that the submission should be forced to be schematic-only,
+    # pass the setting to CACE as 'netlist-source' in the data-sheet record.
+    if force:
+        dsheet['netlist-source'] = 'schematic'
+
+    # Put the current git system state into the target directory
+    # prior to tarballing
+    if os.path.isfile('/ef/.ef-version'):
+        with open('/ef/.ef-version', 'r') as f:
+            ef_version = f.read().rstrip()
+        docinfo['ef-version'] = ef_version
+
+    rhash, timestamp = file_request_hash.get_hash(name)
+    docinfo['request-hash'] = rhash
+    print('request hash = ' + rhash + '\n')
+
+    # Now send the document
+    if testmode:
+        print('Test:  running send_doc(docinfo)\n')
+    else:
+        send_doc(docinfo)
+
+    # Send the tarballed design file directory to the marketplace server for storage.
+    # Ignore the log file, which is meant for in-system diagnostics, not for storage.
+    exclusions = [name + '\.log', '.*\.raw',
+		'elec/\.java',
+		'ngspice/run/\.allwaves']
+
+    # If settings specify that the submission should be forced to be schematic-only,
+    # then don't tarball the layout database files.
+    if force:
+        exclusions.append('mag/.*\.mag')
+        exclusions.append('mag/.*\.ext')
+
+    # Now send the netlist file tarball
+    tarballname = name + '.tar.gz'
+    if testmode:
+        file_compressor.tar_directory_contents_to_file(design_filepath,
+		tarballname, exclude=exclusions)
+        os.rename(design_filepath + '/' + tarballname, tarballname)
+        print('Test:  running send_file(' + rhash + ', <tarball>, ' + tarballname + ')\n')
+    else:
+        tar = file_compressor.tar_directory_contents(design_filepath,
+		exclude=exclusions)
+        send_file(rhash, tar, tarballname)
+
diff --git a/common/cace_gensim.py b/common/cace_gensim.py
new file mode 100755
index 0000000..76afb4b
--- /dev/null
+++ b/common/cace_gensim.py
@@ -0,0 +1,2196 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+"""
+cace_gensim.py
+This is the main part of the automatic characterization engine.  It takes
+a JSON simulation template file as input and parses it for information on
+how to construct files for the characterization simulations.  Output is
+a number of simulation files (for now, at least, in ng-spice format).
+
+Usage:
+
+cace_gensim.py [<root_path>] [<option> ...]
+
+    <root_path> is the root of all the other path names, if the other
+    path names are not full paths.  If the other pathnames are all
+    full paths, then <root_path> may be omitted.
+
+options:
+
+   -simdir <path>
+        is the location where simulation files and data should be placed.
+   -datasheetdir <path>
+        is the location of the JSON file describing the characterization
+   -testbenchdir <path>
+        is the location of the netlists for the characterization methods
+   -designdir <path>
+        is the location of the netlist for the device-under-test
+   -layoutdir <path>
+        is the location of the layout netlist for the device-under-test
+   -datasheet <name>
+        is the name of the datasheet JSON file
+   -method <name>, ...
+        is a list of one or more names of methods to simulate.  If omitted,
+        all methods are run for a complete characterization.
+   -local
+        indicates that cace_gensim is being run locally, not on the CACE
+        server, simulation conditions should be output along with results;
+        'local' mode implies that results are not posted to the marketplace
+        after simulation, and result files are kept.
+   -bypass
+        acts like remote CACE by running all simulations in one batch and
+        posting to the marketplace.  Does not generate status reports.
+   -keep
+        test mode:  keep all files after simulation
+   -plot
+        test mode:  generate plot (.png) files locally
+   -nopost
+        test mode:  do not post results to the marketplace
+   -nosim
+        test mode:  set up all files for simulation but do not simulate
+
+Quick local run---Use:
+
+    cace_gensim.py <root_dir> -local -method=<method_name>
+
+e.g.,
+
+    cace_gensim.py ~/design/XBG_1V23LC_V01 -local -method=DCVOLTAGE_VBG.1
+"""
+
+import os
+import sys
+import json
+import re
+import time
+import shutil
+import signal
+import datetime
+import subprocess
+import faulthandler
+from functools import reduce
+from spiceunits import spice_unit_convert
+from fix_libdirs import fix_libdirs
+
+import og_config
+
+# Values obtained from og_config:
+#
+apps_path = og_config.apps_path
+launchproc = []
+
+def construct_dut_from_path(pname, pathname, pinlist, foundry, node):
+    # Read the indicated file, find the .subckt line, and copy out the
+    # pin names and DUT name.  Complain if pin names don't match pin names
+    # in the datasheet.
+    # NOTE:  There may be more than one subcircuit in the netlist, so
+    # insist upon the actual DUT (pname)
+
+    subrex = re.compile('^[^\*]*[ \t]*.subckt[ \t]+(.*)$', re.IGNORECASE)
+    noderex = re.compile('\*\*\* Layout tech:[ \t]+([^ \t,]+),[ \t]+foundry[ \t]+([^ \t]+)', re.IGNORECASE)
+    outline = ""
+    dutname = ""
+    if not os.path.isfile(pathname):
+        print('Error:  No design netlist file ' + pathname + ' found.')
+        return outline
+
+    # First pull in all lines of the file and concatenate all continuation
+    # lines.
+    with open(pathname, 'r') as ifile:
+        duttext = ifile.read()
+
+    dutlines = duttext.replace('\n+', ' ').splitlines()
+    found = 0
+    for line in dutlines:
+        lmatch = noderex.match(line)
+        if lmatch:
+            nlnode = lmatch.group(1)
+            nlfoundry = lmatch.group(2)
+            if nlfoundry != foundry:
+                print('Error:  Foundry is ' + foundry + ' in spec sheet, ' + nlfoundry + ' in netlist.')
+                # Not yet fixed in Electric
+                ## return ""
+            if nlnode != node:
+                # Hack for legacy node name
+                if nlnode == 'XH035A' and node == 'XH035':
+                    pass
+                else:
+                    print('Error:  Node is ' + node + ' in spec sheet, ' + nlnode + ' in netlist.')
+                    # Not yet fixed in Electric
+                    ## return ""
+        lmatch = subrex.match(line)
+        if lmatch:
+            rest = lmatch.group(1) 
+            tokens = rest.split()
+            dutname = tokens[0]
+            if dutname == pname:
+                outline = outline + 'X' + dutname + ' '
+                for pin in tokens[1:]:
+                    upin = pin.upper()
+                    try:
+                        pinmatch = next(item for item in pinlist if item['name'].upper() == upin)
+                    except StopIteration:
+                        # Maybe this is not the DUT?
+                        found = 0
+                        # Try the next line
+                        break
+                    else:
+                        outline = outline + pin + ' '
+                        found += 1
+
+    if found == 0 and dutname == "":
+        print('File ' + pathname + ' does not contain any subcircuits!')
+        raise SyntaxError('File ' + pathname + ' does not contain any subcircuits!')
+    elif found == 0:
+        if dutname != pname: 
+            print('File ' + pathname + ' does not have a subcircuit named ' + pname + '!')
+            raise SyntaxError('File ' + pathname + ' does not have a subcircuit named ' + pname + '!')
+        else:
+            print('Pins in schematic: ' + str(tokens[1:]))
+            print('Pins in datasheet: ', end='')
+            for pin in pinlist:
+                print(pin['name'] + ' ', end='')
+            print('')
+            print('File ' + pathname + ' subcircuit ' + pname + ' does not have expected pins!')
+            raise SyntaxError('File ' + pathname + ' subcircuit ' + pname + ' does not have expected pins!')
+    elif found != len(pinlist):
+        print('File ' + pathname + ' does not contain the project DUT ' + pname)
+        print('or not all pins of the DUT were found.')
+        print('Pinlist is : ', end='')
+        for pinrec in pinlist:
+            print(pinrec['name'] + ' ', end='')
+        print('')
+         
+        print('Length of pinlist is ' + str(len(pinlist)))
+        print('Number of pins found in subcircuit call is ' + str(found))
+        raise SyntaxError('File ' + pathname + ' does not contain the project DUT!')
+    else:
+        outline = outline + dutname + '\n'
+    return outline
+
+conditiontypes = {
+	"VOLTAGE":     1,
+	"DIGITAL":     2,
+	"CURRENT":     3,
+	"RISETIME":    4,
+	"FALLTIME":    5,
+	"RESISTANCE":  6,
+	"CAPACITANCE": 7,
+	"TEMPERATURE": 8,
+	"FREQUENCY":   9,
+	"CORNER":      10,
+	"SIGMA":       11,
+	"ITERATIONS":  12,
+	"TIME":	       13
+}
+
+# floating-point numeric sequence generators, to be used with condition generator
+
+def linseq(condition, unit, start, stop, step):
+    a = float(start)
+    e = float(stop)
+    s = float(step)
+    while (a < e + s):
+        if (a > e):
+            yield (condition, unit, stop)
+        else:
+            yield (condition, unit, str(a))
+        a = a + s
+
+def logseq(condition, unit, start, stop, step):
+    a = float(start)
+    e = float(stop)
+    s = float(step)
+    while (a < e * s):
+        if (a > e):
+            yield (condition, unit, stop)
+        else:
+            yield (condition, unit, str(a))
+        a = a * s
+    
+# binary (integer) numeric sequence generators, to be used with condition generator
+
+def bindigits(n, bits):
+    s = bin(n & int("1" * bits, 2))[2:]
+    return ("{0:0>%s}" % (bits)).format(s)
+
+def twos_comp(val, bits):
+    """compute the 2's compliment of int value val"""
+    if (val & (1 << (bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255
+        val = val - (1 << bits)        # compute negative value
+    return val                         # return positive value as is
+
+def bcount(condition, unit, start, stop, step):
+    blen = len(start)
+    a = eval('0b' + start)
+    e = eval('0b' + stop)
+    if a > e:
+        a = twos_comp(a, blen)
+        e = twos_comp(e, blen)
+    s = int(step)
+    while (a < e + s):
+        if (a > e):
+            bstr = bindigits(e, blen)
+        else:
+            bstr = bindigits(a, blen)
+        yield (condition, unit, bstr)
+        a = a + s
+
+def bshift(condition, unit, start, stop, step):
+    a = eval('0b' + start)
+    e = eval('0b' + stop)
+    if a > e:
+        a = twos_comp(a, blen)
+        e = twos_comp(e, blen)
+    s = int(step)
+    while (a < e * s):
+        if (a > e):
+            bstr = bindigits(e, blen)
+        else:
+            bstr = bindigits(a, blen)
+        yield (condition, unit, bstr)
+        a = a * s
+    
+# define a generator for conditions.  Given a condition (dictionary),
+# return (as a yield) each specified condition as a
+# 3-tuple (condition_type, value, unit)
+
+def condition_gen(cond):
+    lcond = cond['condition']
+    if "unit" in cond:
+        unit = cond['unit']
+    else:
+        unit = ''
+
+    if "enum" in cond:
+        for i in cond["enum"]:
+            yield(lcond, unit, i)
+    elif "min" in cond and "max" in cond and "linstep" in cond:
+        if unit == "'b" or lcond.split(':', 1)[0] == 'DIGITAL':
+            yield from bcount(lcond, unit, cond["min"], cond["max"], cond["linstep"])
+        else:
+            yield from linseq(lcond, unit, cond["min"], cond["max"], cond["linstep"])
+    elif "min" in cond and "max" in cond and "logstep" in cond:
+        if unit == "'b" or lcond.split(':', 1)[0] == 'DIGITAL':
+            yield from bshift(lcond, unit, cond["min"], cond["max"], cond["logstep"])
+        else:
+            yield from logseq(lcond, unit, cond["min"], cond["max"], cond["logstep"])
+    elif "min" in cond and "max" in cond and "typ" in cond:
+        yield(lcond, unit, cond["min"])
+        yield(lcond, unit, cond["typ"])
+        yield(lcond, unit, cond["max"])
+    elif "min" in cond and "max" in cond:
+        yield(lcond, unit, cond["min"])
+        yield(lcond, unit, cond["max"])
+    elif "min" in cond and "typ" in cond:
+        yield(lcond, unit, cond["min"])
+        yield(lcond, unit, cond["typ"])
+    elif "max" in cond and "typ" in cond:
+        yield(lcond, unit, cond["typ"])
+        yield(lcond, unit, cond["max"])
+    elif "min" in cond:
+        yield(lcond, unit, cond["min"])
+    elif "max" in cond:
+        yield(lcond, unit, cond["max"])
+    elif "typ" in cond:
+        yield(lcond, unit, cond["typ"])
+
+# Find the maximum time to run a simulation.  This is the maximum of:
+# (1) maximum value, if method is RISETIME or FALLTIME, and (2) maximum
+# RISETIME or FALLTIME of any condition.
+#
+# "lcondlist" is the list of local conditions extended by the list of
+# all global conditions that are not overridden by local values.
+#
+# NOTE:  This list is limited to rise and fall time values, as they are
+# the only time constraints known to cace_gensim at this time.  This list
+# will be extended as more simulation methods are added.
+
+def findmaxtime(param, lcondlist):
+    maxtime = 0.0
+    try:
+       simunit = param['unit']
+    except KeyError:
+       # Plots has no min/max/typ so doesn't require units.
+       if 'plot' in param:
+           return maxtime
+
+    maxval = 0.0
+    found = False
+    if 'max' in param:
+        prec = param['max']
+        if 'target' in prec:
+            pmax = prec['target']
+            try:
+                maxval = float(spice_unit_convert([simunit, pmax], 'time'))
+                found = True
+            except:
+                pass
+    if not found and 'typ' in param:
+        prec = param['typ']
+        if 'target' in prec:
+            ptyp = prec['target']
+            try:
+                maxval = float(spice_unit_convert([simunit, ptyp], 'time'))
+                found = True
+            except:
+                pass
+    if not found and 'min' in param:
+        prec = param['min']
+        if 'target' in prec:
+            pmin = prec['target']
+            try:
+                maxval = float(spice_unit_convert([simunit, pmin], 'time'))
+                found = True
+            except:
+                pass
+    if maxval > maxtime:
+        maxtime = maxval
+    for cond in lcondlist:
+        condtype = cond['condition'].split(':', 1)[0]
+        # print ('condtype ' + condtype)
+        if condtype == 'RISETIME' or condtype == 'FALLTIME':
+            condunit = cond['unit']
+            maxval = 0.0
+            if 'max' in cond:
+                maxval = float(spice_unit_convert([condunit, cond['max']], 'time'))
+            elif 'enum' in cond:
+                maxval = float(spice_unit_convert([condunit, cond['enum'][-1]], 'time'))
+            elif 'typ' in cond:
+                maxval = float(spice_unit_convert([condunit, cond['typ']], 'time'))
+            elif 'min' in cond:
+                maxval = float(spice_unit_convert([condunit, cond['min']], 'time'))
+            if maxval > maxtime:
+                maxtime = maxval
+
+    return maxtime
+
+# Picked up from StackOverflow:  Procedure to remove non-unique entries
+# in a list of lists (as always, thanks StackOverflow!).
+
+def uniquify(seq):
+    seen = set()
+    return [x for x in seq if str(x) not in seen and not seen.add(str(x))]
+
+# Insert hints that have been selected in the characterization tool for
+# aid in getting stubborn simulations to converge, or to avoid failures
+# due to floating nodes, etc.  The hints are somewhat open-ended and can
+# be extended as needed.  NOTE:  Hint "method" selects the parameter
+# method and is handled outside this routine, which only adds lines to
+# the simulation netlist.
+
+def insert_hints(param, ofile):
+    if 'hints' in param:
+        phints = param['hints']
+        if 'reltol' in phints:
+            value = phints['reltol']
+            ofile.write('.options reltol = ' + value + '\n')
+        if 'rshunt' in phints:
+            value = phints['rshunt']
+            ofile.write('.options rshunt = ' + value + '\n')
+        if 'itl1' in phints:
+            value = phints['itl1']
+            ofile.write('.options itl1 = ' + value + '\n')
+        if 'nodeset' in phints:
+            value = phints['nodeset']
+            # replace '/' in nodeset with '|' to match character replacement done
+            # on the output of magic.
+            ofile.write('.nodeset ' + value.replace('/', '|') + '\n')
+        if 'include' in phints:
+            value = phints['include']
+            ofile.write('.include ' + value + '\n')
+
+# Replace the substitution token ${INCLUDE_DUT} with the contents of the DUT subcircuit
+# netlist file.  "functional" is a list of IP block names that are to be searched for in
+# .include lines in the netlist and replaced with functional view equivalents (if such
+# exist).
+
+def inline_dut(filename, functional, rootpath, ofile):
+    comtrex = re.compile(r'^\*') # SPICE comment
+    inclrex = re.compile(r'[ \t]*\.include[ \t]+["\']?([^"\' \t]+)["\']?', re.IGNORECASE) # SPICE include statement
+    braktrex = re.compile(r'([^ \t]+)\[([^ \t])\]', re.IGNORECASE)  # Node name with brackets
+    subcrex = re.compile(r'[ \t]*x([^ \t]+)[ \t]+(.*)$', re.IGNORECASE) # SPICE subcircuit line
+    librex = re.compile(r'(.*)__(.*)', re.IGNORECASE)
+    endrex = re.compile(r'[ \t]*\.end[ \t]*', re.IGNORECASE)
+    endsrex = re.compile(r'[ \t]*\.ends[ \t]*', re.IGNORECASE)
+    # IP names in the ridiculously complicated form
+    # <user_path>/design/ip/<proj_name>/<version>/<spi-type>/<proj_name>/<proj_netlist>
+    ippathrex = re.compile(r'(.+)/design/ip/([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/ \t]+)')
+    locpathrex = re.compile(r'(.+)/design/([^/]+)/spi/([^/]+)/([^/ \t]+)')
+    # This form does not appear on servers but is used if an IP block is being prepared locally.
+    altpathrex = re.compile(r'(.+)/design/([^/]+)/([^/]+)/([^/]+)/([^/ \t]+)')
+    # Local IP names in the form
+    # <user_path>/design/<project>/spi/<spi-type>/<proj_netlist>
+
+    # To be completed
+    with open(filename, 'r') as ifile:
+        nettext = ifile.read()
+
+    netlines = nettext.replace('\n+', ' ').splitlines()
+    for line in netlines:
+        subsline = line
+        cmatch = comtrex.match(line)
+        if cmatch:
+            print(line, file=ofile)
+            continue
+        # Check for ".end" which should be removed (but not ".ends", which must remain)
+        ematch = endrex.match(line)
+        if ematch:
+            smatch = endsrex.match(line)
+            if not smatch:
+                continue
+        imatch = inclrex.match(line)
+        if imatch:
+            incpath = imatch.group(1)
+            # Substitution behavior is complicated due to the difference between netlist
+            # files from schematic capture vs. layout and read-only vs. read-write IP.
+            incroot = os.path.split(incpath)[1]
+            incname = os.path.splitext(incroot)[0]
+            lmatch = librex.match(incname)
+            if lmatch:
+                ipname = lmatch.group(2)
+            else:
+                ipname = incname
+            if ipname.upper() in functional:
+                # Search for functional view (depends on if this is a read-only IP or 
+                # read-write local subcircuit)
+                funcpath = None
+                ippath = ippathrex.match(incpath)
+                if ippath:
+                    userpath = ippath.group(1)
+                    ipname2 = ippath.group(2)
+                    ipversion = ippath.group(3)
+                    spitype = ippath.group(4)
+                    ipname3 = ippath.group(5)
+                    ipnetlist = ippath.group(6)
+                    funcpath = userpath + '/design/ip/' + ipname2 + '/' + ipversion + '/spi-func/' + ipname + '.spi' 
+                else:
+                    locpath = locpathrex.match(incpath)
+                    if locpath:
+                        userpath = locpath.group(1)
+                        ipname2 = locpath.group(2)
+                        spitype = locpath.group(3)
+                        ipnetlist = locpath.group(4)
+                        funcpath = userpath + '/design/' + ipname2 + '/spi/func/' + ipname + '.spi' 
+                    else:
+                        altpath = altpathrex.match(incpath)
+                        if altpath:
+                            userpath = altpath.group(1)
+                            ipname2 = altpath.group(2)
+                            spitype = altpath.group(3)
+                            ipname3 = altpath.group(4)
+                            ipnetlist = altpath.group(5)
+                            funcpath = userpath + '/design/' + ipname2 + '/spi/func/' + ipname + '.spi' 
+                        
+                funcpath = os.path.expanduser(funcpath)
+                if funcpath and os.path.exists(funcpath):
+                    print('Subsituting functional view for IP block ' + ipname)
+                    print('Original netlist is ' + incpath)
+                    print('Functional netlist is ' + funcpath)
+                    subsline = '.include ' + funcpath
+                elif funcpath:
+                    print('Original netlist is ' + incpath)
+                    print('Functional view specified but no functional view found.')
+                    print('Tried looking for ' + funcpath)
+                    print('Retaining original view.')
+                else:
+                    print('Original netlist is ' + incpath)
+                    print('Cannot make sense of netlist path to find functional view.')
+
+        # If include file name is in <lib>__<cell> format (from electric) and the
+        # functional view is not, then find the subcircuit call and replace the
+        # subcircuit name.  At least at the moment, the vice versa case does not
+        # happen.
+        smatch = subcrex.match(line)
+        if smatch:
+            subinst = smatch.group(1)
+            tokens = smatch.group(2).split()
+            # Need to test for parameters passed to subcircuit.  The actual subcircuit
+            # name occurs before any parameters.
+            params = []
+            pins = []
+            for token in tokens:
+                if '=' in token:
+                    params.append(token)
+                else:
+                    pins.append(token)
+
+            subname = pins[-1]
+            pins = pins[0:-1]
+            lmatch = librex.match(subname)
+            if lmatch:
+                testname = lmatch.group(1)
+                if testname.upper() in functional:
+                    subsline = 'X' + subinst + ' ' + ' '.join(pins) + ' ' + testname + ' ' + ' '.join(params)
+
+        # Remove any array brackets from node names in the top-level subcircuit, because they
+        # interfere with the array notation used by XSPICE which may be present in functional
+        # views (replace bracket characters with underscores).
+        # subsline = subsline.replace('[', '_').replace(']', '_')
+        #
+        # Do this *only* when there are no spaces inside the brackets, or else any XSPICE
+        # primitives in the netlist containing arrays will get messed up.
+        subsline = braktrex.sub(r'\1_\2_', subsline)
+
+        ofile.write(subsline + '\n')
+
+    ofile.write('\n')
+
+# Define how to write a simulation file by making substitutions into a
+# template schematic.
+
+def substitute(filename, fileinfo, template, simvals, maxtime, schemline,
+		localmode, param):
+    """Simulation derived by substitution into template schematic"""
+
+    # Regular expressions
+    varex = re.compile(r'(\$\{[^ \}\t]+\})')		# variable name ${name}
+    defaultex = re.compile(r'\$\{([^=]+)=([^=\}]+)\}')	# name in ${name=default} format
+    condpinex = re.compile(r'\$\{([^:]+):([^:\}]+)\}')	# name in ${cond:pin} format
+    condex = re.compile(r'\$\{([^\}]+)\}')		# name in ${cond} format
+    sweepex = re.compile(r'\$\{([^\}]+):SWEEP([^\}]+)\}') # name in ${cond:[pin:]sweep} format
+    pinex = re.compile(r'PIN:([^:]+):([^:]+)')		# name in ${PIN:pin_name:net_name} format
+    funcrex = re.compile(r'FUNCTIONAL:([^:]+)')		# name in ${FUNCTIONAL:ip_name} format
+    colonsepex = re.compile(r'^([^:]+):([^:]+)$')	# a:b (colon-separated values)
+    vectrex = re.compile(r'([^\[]+)\[([0-9]+)\]')       # pin name is a vector signal
+    vect2rex = re.compile(r'([^<]+)<([0-9]+)>')         # pin name is a vector signal (alternate style)
+    libdirrex = re.compile(r'.lib[ \t]+(.*)[ \t]+')     # pick up library name from .lib
+    vinclrex = re.compile(r'[ \t]*`include[ \t]+"([^"]+)"')	# verilog include statement
+
+    # Information about the DUT
+    simfilepath = fileinfo['simulation-path']
+    schempath = fileinfo['design-netlist-path']
+    schemname = fileinfo['design-netlist-name']
+    testbenchpath = fileinfo['testbench-netlist-path']
+    rootpath = fileinfo['root-path']
+    schempins = schemline.upper().split()[1:-1]
+    simpins = [None] * len(schempins)
+
+    suffix = os.path.splitext(template)[1]
+    functional = []
+
+    # Read ifile into a list
+    # Concatenate any continuation lines
+    with open(template, 'r') as ifile:
+        simtext = ifile.read()
+
+    simlines = simtext.replace('\n+', ' ').splitlines()
+
+    # Make initial pass over contents of template file, looking for SWEEP
+    # entries, and collapse simvals accordingly.
+
+    sweeps = []
+    for line in simlines:
+        sublist = sweepex.findall(line)
+        for pattern in sublist:
+            condition = pattern[0]
+            try:
+                entry = next(item for item in sweeps if item['condition'] == condition)
+            except (StopIteration, KeyError):
+                print("Did not find condition " + condition + " in sweeps.")
+                print("Pattern = " + str(pattern))
+                print("Sublist = " + str(sublist))
+                print("Sweeps = " + str(sweeps))
+                entry = {'condition':condition}
+                sweeps.append(entry)
+
+                # Find each entry in simvals with the same condition.
+                # Record the minimum, maximum, and step for substitution, at the same
+                # time removing that item from the entry.
+                lvals = []
+                units = ''
+                for simval in simvals:
+                    try:
+                        simrec = next(item for item in simval if item[0] == condition)
+                    except StopIteration:
+                        print('No condition = ' + condition + ' in record:\n')
+                        ptext = str(simval) + '\n'
+                        sys.stdout.buffer.write(ptext.encode('utf-8'))
+                    else:
+                        units = simrec[1]
+                        lvals.append(float(simrec[2]))
+                        simval.remove(simrec)
+
+                # Remove non-unique entries from lvals
+                lvals = list(set(lvals))
+
+                # Now parse lvals for minimum/maximum
+                entry['unit'] = units
+                minval = min(lvals)
+                maxval = max(lvals)
+                entry['START'] = str(minval)
+                entry['END'] = str(maxval)
+                numvals = len(lvals)
+                if numvals > 1:
+                    entry['STEPS'] = str(numvals)
+                    entry['STEP'] = str((maxval - minval) / (numvals - 1))
+                else:
+                    entry['STEPS'] = "1"
+                    entry['STEP'] = str(minval)
+
+    # Remove non-unique entries from simvals
+    simvals = uniquify(simvals)
+
+    simnum = 0
+    testbenches = []
+    for simval in simvals:
+        # Create the file
+        simnum += 1
+        simfilename = simfilepath + '/' + filename + '_' + str(simnum) + suffix
+        controlblock = False
+        with open(simfilename, 'w') as ofile:
+            for line in simlines:
+
+                # Check if the parser is in the ngspice control block section
+                if '.control' in line:
+                    controlblock = True
+                elif '.endc' in line:
+                    controlblock = False
+                elif controlblock == True:
+                    ofile.write('set sqrnoise\n')
+                    # This will need to be more nuanced if controlblock is used
+                    # to do more than just insert the noise sim hack.
+                    controlblock = False
+
+                # This will be replaced
+                subsline = line
+
+                # Find all variables to substitute
+                for patmatch in varex.finditer(line):
+                    pattern = patmatch.group(1)
+                    # If variable is in ${x=y} format, it declares a default value
+                    # Remove the =y default part and keep it for later if needed.
+                    defmatch = defaultex.match(pattern)
+                    if defmatch:
+                        default = defmatch.group(2)
+                        vpattern = '${' + defmatch.group(1) + '}'
+                    else:
+                        default = []
+                        vpattern = pattern
+
+                    repl = []
+                    no_repl_ok = False
+                    sweeprec = sweepex.match(vpattern)
+                    if sweeprec:
+                        sweeptype = sweeprec.group(2)
+                        condition = sweeprec.group(1)
+
+                        entry = next(item for item in sweeps if item['condition'] == condition)
+                        uval = spice_unit_convert((entry['unit'], entry[sweeptype]))
+                        repl = str(uval)
+                    else:
+                        cond = condex.match(vpattern)
+                        if cond:
+                            condition = cond.group(1)
+
+                            # Check if the condition contains a pin vector
+                            lmatch = vectrex.match(condition)
+                            if lmatch:
+                                pinidx = int(lmatch.group(2))
+                                vcondition = lmatch.group(1)
+                            else:
+                                lmatch = vect2rex.match(condition)
+                                if lmatch:
+                                    pinidx = int(lmatch.group(2))
+                                    vcondition = lmatch.group(1)
+                                
+                            try:
+                                 entry = next((item for item in simval if item[0] == condition))
+                            except (StopIteration, KeyError):
+                                # check against known names (to-do: change if block to array of procs)
+                                if condition == 'N':
+                                    repl = str(simnum)
+                                elif condition == 'MAXTIME':
+                                    repl = str(maxtime)
+                                elif condition == 'STEPTIME':
+                                    repl = str(maxtime / 100)
+                                elif condition == 'DUT_PATH':
+                                    repl = schempath + '/' + schemname + '\n'
+                                    # DUT_PATH is required and is a good spot to
+                                    # insert hints (but deprecated in fafor of INCLUDE_DUT)
+                                    insert_hints(param, ofile)
+                                elif condition == 'INCLUDE_DUT':
+                                    if len(functional) == 0:
+                                        repl = '.include ' + schempath + '/' + schemname + '\n'
+                                    else:
+                                        inline_dut(schempath + '/' + schemname, functional, rootpath, ofile)
+                                        repl = '** End of in-line DUT subcircuit'
+                                    insert_hints(param, ofile)
+                                elif condition == 'DUT_CALL':
+                                    repl = schemline
+                                elif condition == 'DUT_NAME':
+                                    # This verifies pin list of schematic vs. the netlist.
+                                    repl = schemline.split()[-1]
+                                elif condition == 'FILENAME':
+                                    repl = filename
+                                elif condition == 'RANDOM':
+                                    repl = str(int(time.time() * 1000) & 0x7fffffff)
+                                # Stack math operators.  Perform specified math
+                                # operation on the last two values and replace.
+                                #
+                                # Note that ngspice is finicky about space around "=" so
+                                # handle this in a way that keeps ngspice happy.
+                                elif condition == '+':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(float(ltok[-2]) + float(ltok[-1])))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition == '-':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(float(ltok[-2]) - float(ltok[-1])))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition == '*':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(float(ltok[-2]) * float(ltok[-1])))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition == '/':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(float(ltok[-2]) / float(ltok[-1])))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition == 'MAX':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(max(float(ltok[-2]), float(ltok[-1]))))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition == 'MIN':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-2]
+                                    ntok.append(str(min(float(ltok[-2]), float(ltok[-1]))))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                # 'NEG' acts on only the previous value in the string.
+                                elif condition == 'NEG':
+                                    smatch = varex.search(subsline)
+                                    watchend = smatch.start()
+                                    ltok = subsline[0:watchend].replace('=', ' = ').split()
+                                    ntok = ltok[:-1]
+                                    ntok.append(str(-float(ltok[-1])))
+                                    subsline = ' '.join(ntok).replace(' = ', '=') + line[patmatch.end():]
+                                    repl = ''
+                                    no_repl_ok = True
+                                elif condition.find('PIN:') == 0:
+                                    # Parse for ${PIN:<pin_name>:<net_name>}
+                                    # Replace <pin_name> with index of pin from DUT subcircuit
+                                    pinrec = pinex.match(condition)
+                                    pinname = pinrec.group(1).upper()
+                                    netname = pinrec.group(2).upper()
+                                    try:
+                                       idx = schempins.index(pinname)
+                                    except ValueError:
+                                       repl = netname
+                                    else:
+                                       repl = '${PIN}'
+                                       simpins[idx] = netname
+                                elif condition.find('FUNCTIONAL:') == 0:
+                                    # Parse for ${FUNCTIONAL:<ip_name>}
+                                    # Add <ip_name> to "functional" array.
+                                    # 'FUNCTIONAL' declarations must come before 'INCLUDE_DUT' or else
+                                    # substitution will not be made.  'INCLUDE_DUT' must be used in place
+                                    # of 'DUT_PATH' to get the correct behavior.
+                                    funcrec = funcrex.match(condition)
+                                    ipname = funcrec.group(1)
+                                    functional.append(ipname.upper())
+                                    repl = '** Using functional view for ' + ipname
+                                else:
+                                    if lmatch:
+                                        try:
+                                            entry = next((item for item in simval if item[0].split('[')[0].split('<')[0] == vcondition))
+                                        except:
+                                            # if no match, subsline remains as-is.
+                                            pass
+                                        else:
+                                            # Handle as vector bit slice (see below)
+                                            vlen = len(entry[2])
+                                            uval = entry[2][(vlen - 1) - pinidx]
+                                            repl = str(uval)
+                                    # else if no match, subsline remains as-is.
+
+                            else:
+                                if lmatch:
+                                    # pull signal at pinidx out of the vector.
+                                    # Note: DIGITAL assumes binary value.  May want to
+                                    # allow general case of real-valued vectors, which would
+                                    # require a spice unit conversion routine without indexing.
+                                    vlen = len(entry[2])
+                                    uval = entry[2][(vlen - 1) - pinidx]
+                                else:
+                                    uval = spice_unit_convert(entry[1:])
+                                repl = str(uval)
+
+                    if not repl and default:
+                        # Use default if no match was found and default was specified
+                        repl = default
+
+                    if repl:
+                        # Make the variable substitution
+                        subsline = subsline.replace(pattern, repl)
+                    elif not no_repl_ok:
+                        print('Warning: Variable ' + pattern + ' had no substitution')
+
+                # Check if ${PIN} are in line.  If so, order by index and
+                # rewrite pins in order
+                for i in range(len(simpins)):
+                    if '${PIN}' in subsline:
+                        if simpins[i]:
+                            subsline = subsline.replace('${PIN}', simpins[i], 1)
+                        else:
+                            print("Error:  simpins is " + str(simpins) + '\n')
+                            print("        subsline is " + subsline + '\n')
+                            print("        i is " + str(i) + '\n')
+
+                # Check for a verilog include file, and if any is found, copy it
+                # to the target simulation directory.  Replace any leading path
+                # with the local current working directory '.'.
+                vmatch = vinclrex.match(subsline)
+                if vmatch:
+                    incfile = vmatch.group(1)
+                    incroot = os.path.split(incfile)[1]
+                    curpath = os.path.split(template)[0]
+                    incpath = os.path.abspath(os.path.join(curpath, incfile))
+                    shutil.copy(incpath, simfilepath + '/' + incroot)
+                    subsline = '   `include "./' + incroot + '"'
+
+                # Write the modified output line (with variable substitutions)
+                ofile.write(subsline + '\n')
+
+        # Add information about testbench file and conditions to datasheet JSON,
+        # which can be parsed by cace_launch.py.
+        testbench = {}
+        testbench['filename'] = simfilename
+        testbench['prefix'] = filename
+        testbench['conditions'] = simval
+        testbenches.append(testbench)
+
+    return testbenches
+
+# Define how to write simulation devices
+
+def generate_simfiles(datatop, fileinfo, arguments, methods, localmode):
+
+    # pull out the relevant part, which is "data-sheet"
+    dsheet = datatop['data-sheet']
+
+    # grab values held in 'fileinfo'
+    testbenchpath = fileinfo['testbench-netlist-path']
+
+    # electrical parameter list comes from "methods" if non-NULL.
+    # Otherwise, each entry in 'methods' is checked against the
+    # electrical parameters.
+
+    if 'electrical-params' in dsheet:
+        eparamlist = dsheet['electrical-params']
+    else:
+        eparamlist = []
+    if 'physical-params' in dsheet:
+        pparamlist = dsheet['physical-params']
+    else:
+        pparamlist = []
+
+    # If specific methods are called out for simulation using option "-method=", then
+    # generate the list of electrical parameters for those methods only.
+
+    if methods:
+        neweparamlist = []
+        newpparamlist = []
+        for method in methods:
+            # If method is <methodname>.<index>, simulate only the <index>th instance of
+            # the method.
+            if '.' in method:
+                (method, index) = method.split('.')
+            else:
+                index = []
+
+            if method == 'physical':
+                usedmethods = list(item for item in pparamlist if item['condition'] == index)
+                if not usedmethods:
+                    print('Unknown parameter ' + index + ' requested in options.  Ignoring.\n')
+                for used in usedmethods:
+                    newpparamlist.append(used)
+
+            else:
+                usedmethods = list(item for item in eparamlist if item['method'] == method)
+                if not usedmethods:
+                    print('Unknown method ' + method + ' requested in options.  Ignoring.\n')
+                if index:
+                    neweparamlist.append(usedmethods[int(index)])
+                else:
+                    for used in usedmethods:
+                        neweparamlist.append(used)
+
+        if not neweparamlist and not newpparamlist:
+            print('Warning:  No valid methods given as options, so no simulations will be done.\n')
+        if neweparamlist:
+            for param in neweparamlist:
+                if 'display' in param:
+                    ptext = 'Simulating parameter: ' + param['display'] + ' (' + param['method'] + ')\n'
+                else:
+                    ptext = 'Simulating method: ' + param['method'] + '\n'
+                sys.stdout.buffer.write(ptext.encode('utf-8'))
+        eparamlist = neweparamlist
+        if newpparamlist:
+            for param in newpparamlist:
+                if 'display' in param:
+                    ptext = 'Checking parameter: ' + param['display'] + ' (' + param['condition'] + ')\n'
+                else:
+                    ptext = 'Checking parameter: ' + param['condition'] + '\n'
+                sys.stdout.buffer.write(ptext.encode('utf-8'))
+        pparamlist = newpparamlist
+
+    # Diagnostic
+    # print('pparamlist:')
+    # for param in pparamlist:
+    #     ptext = param['condition'] + '\n'
+    #     sys.stdout.buffer.write(ptext.encode('utf-8'))
+    # print('eparamlist:')
+    # for param in eparamlist:
+    #     ptext = param['method'] + '\n'
+    #     sys.stdout.buffer.write(ptext.encode('utf-8'))
+
+    # major subcategories of "data-sheet"
+    gcondlist = dsheet['global-conditions']
+
+    # Make a copy of the pin list in the datasheet, and expand any vectors.
+    pinlist = []
+    vectrex = re.compile(r"([^\[]+)\[([0-9]+):([0-9]+)\]")
+    vect2rex = re.compile(r"([^<]+)\<([0-9]+):([0-9]+)\>")
+    for pinrec in dsheet['pins']:
+        vmatch = vectrex.match(pinrec['name'])
+        if vmatch:
+            pinname = vmatch.group(1)
+            pinmin = vmatch.group(2)
+            pinmax = vmatch.group(3)
+            if int(pinmin) > int(pinmax):
+                pinmin = vmatch.group(3)
+                pinmax = vmatch.group(2)
+            for i in range(int(pinmin), int(pinmax) + 1):
+                newpinrec = pinrec.copy()
+                pinlist.append(newpinrec)
+                newpinrec['name'] = pinname + '[' + str(i) + ']'
+        else:
+            vmatch = vect2rex.match(pinrec['name'])
+            if vmatch:
+                pinname = vmatch.group(1)
+                pinmin = vmatch.group(2)
+                pinmax = vmatch.group(3)
+                if int(pinmin) > int(pinmax):
+                    pinmin = vmatch.group(3)
+                    pinmax = vmatch.group(2)
+                for i in range(int(pinmin), int(pinmax) + 1):
+                    newpinrec = pinrec.copy()
+                    pinlist.append(newpinrec)
+                    newpinrec['name'] = pinname + '<' + str(i) + '>'
+            else:
+                pinlist.append(pinrec)
+
+    # Make sure all local conditions define a pin.  Those that are not
+    # associated with a pin will have a null string for the pin name.
+
+    for cond in gcondlist:
+        # Convert old style (separate condition, pin) to new style
+        if 'pin' in cond and cond['pin'] != '':
+            if ':' not in cond['condition']:
+                cond['condition'] += ':' + cond['pin']
+            cond.pop('pin', 0)
+        if 'order' not in cond:
+            try:
+                cond['order'] = conditiontypes[cond['condition']]
+            except:
+                cond['order'] = 0
+
+    # Find DUT netlist file and capture the subcircuit call line
+    schempath = fileinfo['design-netlist-path']
+    schemname = fileinfo['design-netlist-name']
+    pname = fileinfo['project-name']
+    dutpath = schempath + '/' + schemname
+    foundry = dsheet['foundry']
+    node = dsheet['node']
+    try:
+        schemline = construct_dut_from_path(pname, dutpath, pinlist, foundry, node)
+    except SyntaxError:
+        print("Failure to construct a DUT subcircuit.  Does the design have ports?")
+        schemline = ''
+
+    if schemline == '':
+        # Error finding DUT file.  If only physical parameters are requested, this may
+        # not be a failure (e.g., chip top level)
+        if len(eparamlist) == 0:
+            prescore = 'unknown'
+        else:
+            prescore = 'fail'
+    else:
+        prescore = 'pass'
+
+    methodsfound = {}
+
+    # electrical parameter types determine the simulation type.  Simulation
+    # types will be broken into individual routines (to be done)
+
+    for param in eparamlist:
+
+        # Break out name, method, and conditions as variables
+        simtype = param['method']
+
+        # For methods with ":", the filename is the part before the colon.
+        testbench = simtype.split(":")[0]
+
+        # If hint 'method' is applied, append the value to the method name.
+        # If no such method exists, flag a warning and revert to the original.
+
+        testbench_orig = None
+        if 'hints' in param:
+            phints = param['hints']
+            if 'method' in phints:
+                testbench_orig = testbench
+                testbench += phints['method']            
+
+        if testbench == simtype:
+            if arguments:
+                if simtype not in arguments:
+                    continue
+
+            if simtype in methodsfound:
+                fnum = methodsfound[simtype]
+                fsuffix = '_' + str(fnum)
+                methodsfound[simtype] = fnum + 1
+            else:
+                fsuffix = '_0'
+                methodsfound[simtype] = 1
+        else:
+            if arguments:
+                if testbench not in arguments:
+                    continue
+
+            if testbench in methodsfound:
+                fnum = methodsfound[testbench]
+                fsuffix = '_' + str(fnum)
+                methodsfound[testbench] = fnum + 1
+            else:
+                fsuffix = '_0'
+                methodsfound[testbench] = 1
+
+        lcondlist = param['conditions']
+
+        # Make sure all local conditions which define a pin are in condition:pin form
+
+        for cond in lcondlist:
+            if 'pin' in cond and cond['pin'] != '':
+                if not ':' in cond['condition']:
+                    cond['condition'] += ':' + cond['pin']
+                cond.pop('pin', 0)
+            if "order" not in cond:
+                if cond["condition"].split(':', 1)[0] in conditiontypes:
+                    cond["order"] = conditiontypes[cond["condition"].split(':', 1)[0]]
+                else:
+                    cond["order"] = 14
+
+        # Append to lcondlist any global conditions that aren't overridden by
+        # local values for the electrical parameter's set of conditions.
+
+        grec = []
+        for cond in gcondlist:
+            try:
+                test = next((item for item in lcondlist if item["condition"] == cond["condition"]))
+            except StopIteration:
+                grec.append(cond)
+
+        lcondlist.extend(grec)	# Note this will permanently alter lcondlist
+
+        # Find the maximum simulation time required by this method
+        # Simulations are ordered so that "risetime" and "falltime" simulations
+        # on a pin will set the simulation time of any simulation of any other
+        # electrical parameter on that same pin.
+
+        maxtime = findmaxtime(param, lcondlist)
+        print("maxtime is " + str(maxtime))
+
+        # Sort the list for output conditions, ordering according to 'conditiontypes'.
+
+        list.sort(lcondlist, key=lambda k: k['order'])
+
+        # Find the length of each generator
+        cgenlen = []
+        for cond in lcondlist:
+            cgenlen.append(len(list(condition_gen(cond))))
+
+        # The lengths of all generators multiplied together is the number of
+        # simulations to be run
+        numsims = reduce(lambda x, y: x * y, cgenlen)
+        rlen = [x for x in cgenlen]	# note floor division operator
+
+        # This code repeats each condition as necessary such that the final list
+        # (transposed) is a complete set of unique condition combinations.
+        cgensim = []
+        for i in range(len(rlen)):
+            mpre = reduce(lambda x, y: x * y, rlen[0:i], 1)
+            mpost = reduce(lambda x, y: x * y, rlen[i + 1:], 1)
+            clist = list(condition_gen(lcondlist[i]))
+            duplist = [item for item in list(condition_gen(lcondlist[i])) for j in range(mpre)]
+            cgensim.append(duplist * mpost)
+
+        # Transpose this list
+        simvals = list(map(list, zip(*cgensim)))
+
+        # Generate filename prefix for this electrical parameter
+        filename = testbench + fsuffix
+
+        # If methodtype is the name of a schematic netlist, then use
+        # it and make substitutions
+        # NOTE:  Schematic methods are bundled with the DUT schematic
+
+        template = testbenchpath + '/' + testbench.lower() + '.spi'
+
+        if testbench_orig and not os.path.isfile(template):
+            print('Warning:  Alternate testbench ' + testbench + ' cannot be found.')
+            print('Reverting to original testbench ' + testbench_orig)
+            testbench = testbench_orig
+            filename = testbench + fsuffix
+            template = testbenchpath + '/' + testbench.lower() + '.spi'
+
+        if os.path.isfile(template):
+            param['testbenches'] = substitute(filename, fileinfo, template,
+			simvals, maxtime, schemline, localmode, param)
+
+            # For cosimulations, if there is a '.tv' file corresponding to the '.spi' file,
+            # then make substitutions as for the .spi file, and place in characterization
+            # directory.
+
+            vtemplate = testbenchpath + '/' + testbench.lower() + '.tv'
+            if os.path.isfile(vtemplate):
+                substitute(filename, fileinfo, vtemplate,
+			simvals, maxtime, schemline, localmode, param)
+
+        else:
+            print('Error:  No testbench file ' + template + '.')
+
+    for param in pparamlist:
+        # Break out name, method, and conditions as variables
+        cond = param['condition']
+        simtype = 'physical.' + cond
+
+        if arguments:
+            if simtype not in arguments:
+                continue
+
+        if simtype in methodsfound:
+            fnum = methodsfound[simtype]
+            fsuffix = '_' + str(fnum)
+            methodsfound[simtype] = fnum + 1
+        else:
+            fsuffix = '_0'
+            methodsfound[simtype] = 1
+
+        # Mark parameter as needing checking by cace_launch.
+        param['check'] = 'true'
+
+    # Remove "order" keys
+    for param in eparamlist:
+        lcondlist = param['conditions']
+        for cond in lcondlist:
+            cond.pop('order', 0)
+    gconds = dsheet['global-conditions']
+    for cond in gconds:
+        cond.pop('order', 0)
+
+    return prescore
+
+def check_layout_out_of_date(spipath, layoutpath):
+    # Check if a netlist (spipath) is out-of-date relative to the layouts
+    # (layoutpath).  Need to read the netlist and check all of the subcells.
+    need_capture = False
+    if not os.path.isfile(spipath):
+        need_capture = True
+    elif not os.path.isfile(layoutpath):
+        need_capture = True
+    else:
+        spi_statbuf = os.stat(spipath)
+        lay_statbuf = os.stat(layoutpath)
+        if spi_statbuf.st_mtime < lay_statbuf.st_mtime:
+            # netlist exists but is out-of-date
+            need_capture = True
+        else:
+            # only found that the top-level-layout is older than the
+            # netlist.  Now need to read the netlist, find all subcircuits,
+            # and check those dates, too.
+            layoutdir = os.path.split(layoutpath)[0]
+            subrex = re.compile('^[^\*]*[ \t]*.subckt[ \t]+([^ \t]+).*$', re.IGNORECASE)
+            with open(spipath, 'r') as ifile:
+                duttext = ifile.read()
+            dutlines = duttext.replace('\n+', ' ').splitlines()
+            for line in dutlines:
+                lmatch = subrex.match(line)
+                if lmatch:
+                    subname = lmatch.group(1)
+                    sublayout = layoutdir + '/' + subname + '.mag'
+                    # subcircuits that cannot be found in the current directory are
+                    # assumed to be library components and therefore never out-of-date.
+                    if os.path.exists(sublayout):
+                        sub_statbuf = os.stat(sublayout)
+                        if spi_statbuf.st_mtime < lay_statbuf.st_mtime:
+                            # netlist exists but is out-of-date
+                            need_capture = True
+                            break
+    return need_capture
+
+def check_schematic_out_of_date(spipath, schempath):
+    # Check if a netlist (spipath) is out-of-date relative to the schematics
+    # (schempath).  Need to read the netlist and check all of the subcells.
+    need_capture = False
+    if not os.path.isfile(spipath):
+        print('Schematic-captured netlist does not exist.  Need to regenerate.')
+        need_capture = True
+    elif not os.path.isfile(schempath):
+        need_capture = True
+    else:
+        spi_statbuf = os.stat(spipath)
+        sch_statbuf = os.stat(schempath)
+        print('DIAGNOSTIC:  Comparing ' + spipath + ' to ' + schempath)
+        if spi_statbuf.st_mtime < sch_statbuf.st_mtime:
+            # netlist exists but is out-of-date
+            print('Netlist is older than top-level schematic')
+            need_capture = True
+        else:
+            print('Netlist is newer than top-level schematic, but must check subcircuits')
+            # only found that the top-level-schematic is older than the
+            # netlist.  Now need to read the netlist, find all subcircuits,
+            # and check those dates, too.
+            schemdir = os.path.split(schempath)[0]
+            subrex = re.compile('^[^\*]*[ \t]*.subckt[ \t]+([^ \t]+).*$', re.IGNORECASE)
+            with open(spipath, 'r') as ifile:
+                duttext = ifile.read()
+
+            dutlines = duttext.replace('\n+', ' ').splitlines()
+            for line in dutlines:
+                lmatch = subrex.match(line)
+                if lmatch:
+                    subname = lmatch.group(1)
+                    # NOTE: Electric uses library:cell internally to track libraries,
+                    # and maps the ":" to "__" in the netlist.  Not entirely certain that
+                    # the double-underscore uniquely identifies the library:cell. . .
+                    librex = re.compile('(.*)__(.*)', re.IGNORECASE)
+                    lmatch = librex.match(subname)
+                    if lmatch:
+                        elecpath = os.path.split(os.path.split(schempath)[0])[0]
+                        libname = lmatch.group(1)
+                        subschem = elecpath + '/' + libname + '.delib/' + lmatch.group(2) + '.sch'
+                    else:
+                        libname = {}
+                        subschem = schemdir + '/' + subname + '.sch'
+                    # subcircuits that cannot be found in the current directory are
+                    # assumed to be library components or read-only IP components and
+                    # therefore never out-of-date.
+                    if os.path.exists(subschem):
+                        sub_statbuf = os.stat(subschem)
+                        if spi_statbuf.st_mtime < sch_statbuf.st_mtime:
+                            # netlist exists but is out-of-date
+                            print('Netlist is older than subcircuit schematic ' + subname)
+                            need_capture = True
+                            break
+                    # mapping of characters to what's allowed in SPICE makes finding
+                    # the associated schematic file a bit difficult.  Requires wild-card
+                    # searching.
+                    elif libname:
+                        restr = lmatch.group(2) + '.sch'
+                        restr = restr.replace('.', '\.')
+                        restr = restr.replace('_', '.')
+                        schrex = re.compile(restr, re.IGNORECASE)
+                        try:
+                            liblist = os.listdir(elecpath + '/' + libname + '.delib')
+                        except FileNotFoundError:
+                            # Potentially could look through the paths in LIBDIR. . .
+                            pass
+                        else:
+                            for file in liblist:
+                                lmatch = schrex.match(file)
+                                if lmatch:
+                                    subschem = elecpath + '/' + libname + '.delib/' + file
+                                    sub_statbuf = os.stat(subschem)
+                                    if spi_statbuf.st_mtime < sch_statbuf.st_mtime:
+                                        # netlist exists but is out-of-date
+                                        need_capture = True
+                                        print('Netlist is older than subcircuit schematic ' + file)
+                                        print('In library ' + libname)
+                                    break
+    return need_capture
+
+def printwarn(output):
+    # Check output for warning or error
+    if not output:
+        return 0
+
+    warnrex = re.compile('.*warning', re.IGNORECASE)
+    errrex = re.compile('.*error', re.IGNORECASE)
+
+    errors = 0
+    outlines = output.splitlines()
+    for line in outlines:
+        try:
+            wmatch = warnrex.match(line)
+        except TypeError:
+            line = line.decode('utf-8')
+            wmatch = warnrex.match(line)
+        ematch = errrex.match(line)
+        if ematch:
+            errors += 1
+        if ematch or wmatch:
+            print(line)
+    return errors
+
+def layout_netlist_includes(pexnetlist, dspath):
+    # Magic does not generate netlist output for LEF-like views unless
+    # the option "blackbox on" is passed to ext2spice, in which case it
+    # generates stub entries.  When generating a PEX view for simulation,
+    # these entries need to be generated then replaced with the correct
+    # include statement to the ip/ directory.
+
+    comtrex = re.compile(r'^\*') # SPICE comment
+    subcrex = re.compile(r'^[ \t]*x([^ \t]+)[ \t]+(.*)$', re.IGNORECASE) # SPICE subcircuit line
+    subrex  = re.compile(r'^[ \t]*.subckt[ \t]+([^ \t]+)[ \t]*([^ \t]+.*)', re.IGNORECASE)
+    endsrex = re.compile(r'^[ \t]*\.ends[ \t]*', re.IGNORECASE)
+
+    # Also convert commas from [X,Y] arrays to vertical bars as something
+    # that can be converted back as necessary.  ngspice treats commas as
+    # special characters for some reason.  ngspice also does not correctly
+    # handle slash characters in node names (okay as part of the netlist but
+    # fails if used in, say, ".nodeset").  Should be okay to replace all '/'
+    # because the layout extracted netlist won't have .include or other
+    # entries with filename paths.
+
+    # Find project tech path
+    if os.path.exists(dspath + '/.ef-config/techdir'):
+        techdir = os.path.realpath(dspath + '/.ef-config/techdir')
+        maglefdir = techdir + '/libs.ref/maglef'
+    else:
+        print('Warning:  Project ' + dspath + ' does not define a target process!')
+        techdir = None
+        maglefdir = None
+
+    with open(pexnetlist, 'r') as ifile:
+        spitext = ifile.read()
+
+    # Find project info file (introduced with FFS, 2/2019.  Does not exist in earlier
+    # projects)
+
+    depends = {}
+    ipname = ''
+    if os.path.exists(dspath + '/.ef-config/info'):
+       with open(dspath + '/.ef-config/info', 'r') as ifile:
+           infolines = ifile.read().splitlines
+           deprec = False
+           for line in infolines:
+               if 'dependencies:' in line:
+                   deprec = True
+               if deprec:
+                   if 'version' in line:
+                       version = line.split()[1].strip("'")
+                       if ipname != '':
+                           depends[ipname] = version
+                           ipname = ''
+                       else:
+                           print('Error:  Badly formed info file in .ef-config', file=sys.stderr)
+                   else:
+                       ipname = line.strip(':')
+
+    spilines = spitext.replace('\n+', ' ').replace(',', '|').replace('/','|').splitlines()
+
+    newspilines = []
+    extended_names = []
+    pinsorts = {}
+    inbox = False
+    for line in spilines:
+        cmatch = comtrex.match(line)
+        smatch = subrex.match(line)
+        xmatch = subcrex.match(line)
+        if 'Black-box' in line:
+            inbox = True
+        elif not inbox:
+            if xmatch:
+                # Pull subcircuit name from an 'X' component and see if it matches any of the
+                # names that were rewritten in Electric <library>__<cell> style.  If so, replace
+                # the subcircuit name with the modified name while preserving the rest of the
+                # component line.
+                rest = xmatch.group(2).split()
+                r1 = list(i for i in rest if '=' not in i)
+                r2 = list(i for i in rest if '=' in i)
+                subname = r1[-1]
+                r1 = r1[0:-1]
+
+                # Re-sort the pins if needed
+                if subname in pinsorts:
+                    r1 = [r1[i] for i in pinsorts[subname]]
+
+                if subname.upper() in extended_names:
+                    newsubname = subname + '__' + subname
+                    newspilines.append('X' + xmatch.group(1) + ' ' + ' '.join(r1) + ' ' + newsubname + ' ' + ' '.join(r2))
+                else:
+                    newspilines.append(line)
+            else:
+                newspilines.append(line)
+        elif cmatch:
+            newspilines.append(line)
+        elif smatch:
+            subname = smatch.group(1)
+            pinlist = smatch.group(2).split()
+            print("Parsing black-box subcircuit " + subname)
+            ippath = '~/design/ip/' + subname
+            ipfullpath = os.path.expanduser(ippath)
+            if os.path.exists(ipfullpath):
+                # Version control:  Use the versions specified in the .ef-config/info
+                # version list.  If it does not exist (legacy behavior), then use the
+                # method outlined below (finds highest version number available).
+                if subname in depends:
+                    useversion = str(depends[subname])
+                else:
+                    versions = os.listdir(ipfullpath)
+                    vf = list(float(i) for i in versions)
+                    vi = vf.index(max(vf))
+                    useversion = versions[vi]
+
+                versionpath = ipfullpath + '/' + useversion
+
+                # First to do:  Check for /spi-stub entry (which is readable), and
+                # check if pin order is correct.  Flag a warning if it is not, and
+                # save the pin order in a record so that all X records can be pin
+                # sorted correctly.
+
+                if os.path.exists(versionpath + '/spi-stub'):
+                    stubpath = versionpath + '/spi-stub/' + subname + '/' + subname + '__' + subname + '.spi'
+                    # More spice file reading!  This should be quick, as these files have
+                    # only a single empty subcircuit in them.
+                    found = False
+                    with open(stubpath, 'r') as sfile:
+                        stubtext = sfile.read()
+                        stublines = stubtext.replace('\n+', ' ').replace(',', '|').splitlines()
+                        for line in stublines:
+                            smatch = subrex.match(line)
+                            if smatch:
+                                found = True
+                                stubname = smatch.group(1) 
+                                stublist = smatch.group(2).split()
+                                if stubname != subname + '__' + subname:
+                                    print('Error:  Looking for subcircuit ' + subname + '__' + subname + ' in file ' + stubpath + ' but found subcircuit ' + stubname + ' instead!')
+                                    print("This simulation probably isn't going to go well.")
+                                else:
+                                    needsort = False
+                                    for stubpin, subpin in zip(stublist, pinlist):
+                                        if stubpin.upper() != subpin.upper():
+                                            print('Warning: pin mismatch between layout and schematic stub header on subcircuit ' + subname)
+                                            print('Will sort layout netlist to match.')
+                                            print('Correct pin order is: ' + smatch.group(2))
+                                            needsort = True
+                                            break
+                                    if needsort:
+                                        pinorder = [i[0] for i in sorted(enumerate(pinlist), key = lambda x:stublist.index(x[1]))]
+                                        pinsorts[subname] = pinorder
+                                break
+                    if not found:
+                        print('Error:  Cannot find subcircuit in IP spi-stub entry.') 
+                else:
+                    print('Warning: IP has no spi-stub entry, cannot verify pin order.')
+
+                if os.path.exists(versionpath + '/spi-rcx'):
+                    # This path is restricted and can only be seen by ngspice, which is privileged
+                    # to read it.  So we can only assume that it matches the spi-stub entry.
+                    # NOTE (10/16/2018): Use unexpanded tilde expression in file.
+                    # rcxpath = versionpath + '/spi-rcx/' + subname + '/' + subname + '__' + subname + '.spi'
+                    rcxpath = ippath + '/' + useversion + '/spi-rcx/' + subname + '/' + subname + '__' + subname + '.spi'
+                    newspilines.append('* Black-box entry replaced by path to RCX netlist')
+                    newspilines.append('.include ' + rcxpath)
+                    extended_names.append(subname.upper())
+                elif os.path.exists(ipfullpath + '/' + useversion + '/spi'):
+                    # In a pinch, if there is no spi-rcx, try plain spi
+                    # NOTE (10/16/2018): Use unexpanded tilde expression in file.
+                    # spipath = versionpath + '/spi/' + subname + '.spi'
+                    spipath = ippath + '/' + useversion + '/spi/' + subname + '.spi'
+                    newspilines.append('* Black-box entry replaced by path to schematic netlist')
+                    newspilines.append('.include ' + spipath)
+                else:
+                    # Leave as is, and let it force an error
+                    newspilines.append(line)
+                    inbox = False
+            elif maglefdir:
+                # Check tech file paths
+                found = False
+                maglefsubdirs = os.listdir(maglefdir)
+                for techsubdir in maglefsubdirs:
+                    if not os.path.isdir(maglefdir + '/' + techsubdir):
+                        continue
+                    # print('Diagnostic:  looking in ' + str(maglefdir) + ' ' + str(techsubdir))
+                    maglefcells = os.listdir(maglefdir + '/' + techsubdir)
+                    if subname + '.mag' in maglefcells:
+                        # print("Diagnostic: Parsing black-box subcircuit " + subname)
+                        # print('from tech path ' + maglefdir + '/' + techsubdir)
+
+                        # Like the IP directory, can't read spi/ so have to assume it's there.
+                        # Problem---there is no consistency across PDKs for the naming of
+                        # files in spi/!
+
+                        newspilines.append('* Need include to schematic netlist for ' + subname)
+                        # However, the CDL stub file can be used to check pin order
+                        stubpath = techdir + '/libs.ref/cdlStub/' + techsubdir + '/stub.cdl'
+                        if os.path.exists(stubpath):
+                            # More spice file reading!  This should be quick, as these files have
+                            # only a empty subcircuits in them.
+                            with open(stubpath, 'r') as sfile:
+                                stubtext = sfile.read()
+                                stublines = stubtext.replace('\n+', ' ').replace(',', '|').splitlines()
+                                for line in spilines:
+                                    smatch = subrex.match(line)
+                                    if smatch:
+                                        stubname = smatch.group(1) 
+                                        stublist = smatch.group(2).split()
+                                        if stubname == subname:
+                                            found = True
+                                            needsort = False
+                                            for stubpin, subpin in zip(stublist, pinlist):
+                                                if stubpin.upper() != subpin.upper():
+                                                    print('Warning: pin mismatch between layout and schematic stub header on subcircuit ' + subname)
+                                                    print('Will sort layout netlist to match.')
+                                                    print('Correct pin order is: ' + smatch.group(2))
+                                                    needsort = True
+                                                    break
+                                            if needsort:
+                                                pinorder = [i[0] for i in sorted(enumerate(pinlist), key = lambda x:stublist.index(x[1]))]
+                                                pinsorts[subname] = pinorder
+                                    if found:
+                                        break
+
+                        else:
+                            print('No file ' + stubpath + ' found.')
+                            print('Failure to find stub netlist for checking pin order.  Good luck.')
+                        break
+
+                if not found:
+                    print('Error: Subcell ' + subname + ' not found in IP or tech paths.')
+                    print('This netlist is not going to simulate correctly.')
+                    newspilines.append('* Unknown black-box entry ' + subname)
+                    newspilines.append(line)
+        elif endsrex.match(line):
+            inbox = False
+
+    with open(pexnetlist, 'w') as ofile:
+        for line in newspilines:
+            print(line, file=ofile)
+
+def regenerate_netlists(localmode, dspath, dsheet):
+    # When running locally, 'netlist-source' determines whether to use the
+    # layout extracted netlist or the schematic captured netlist.  Also for
+    # local running only, regenerate the netlist only if it is out of date,
+    # or if the user has selected forced regeneration in the settings.
+
+    dname = dsheet['ip-name']
+    magpath = dspath + '/mag/'
+
+    spipath = dspath + '/spi/'		# Schematic netlist for sim
+    stubpath = dspath + '/spi/stub/'	# Schematic netlist for LVS
+    pexpath = dspath + '/spi/pex/'	# Layout netlist for sim
+    lvspath = dspath + '/spi/lvs/'	# Layout netlist for LVS
+    vlogpath = dspath + '/verilog/'	# Verilog netlist for sim and LVS
+
+    netlistname = dname + '.spi'
+    schnetlist = spipath + netlistname
+    stubnetlist = stubpath + netlistname
+    pexnetlist = pexpath + netlistname
+    laynetlist = lvspath + netlistname
+
+    layoutpath = magpath + dname + '.mag'
+    elecpath = dspath + '/elec/' + dname + '.delib'
+    schempath = elecpath + '/' + dname + '.sch'
+    verilogpath = vlogpath + dname + '.v'
+    pathlast = os.path.split(dspath)[1]
+    verilogaltpath = vlogpath + pathlast + '/' + dname + '.vgl'
+    need_sch_capture = True
+    need_stub_capture = True
+    need_lay_capture = True
+    need_pex_capture = True
+    force_regenerate = False
+
+    # Check if datasheet has been marked for forced netlist regeneration
+    if 'regenerate' in dsheet:
+        if dsheet['regenerate'] == 'force':
+            force_regenerate = True
+
+    # If schempath does not exist, check if the .sch file is in a different
+    # library.
+    if not os.path.exists(schempath):
+        print('No schematic in path ' + schempath)
+        print('Checking for other library paths.')
+        for libname in os.listdir(dspath + '/elec/'):
+            if os.path.splitext(libname)[1] == '.delib':
+                elecpath = dspath + '/elec/' + libname
+                if os.path.exists(elecpath):
+                    for schfile in os.listdir(elecpath):
+                        if schfile == dname + '.sch':
+                            schempath = elecpath + '/' + schfile
+                            print('Schematic found in ' + schempath)
+                            break
+
+    # Guess the source based on the file or files in the design directory,
+    # with preference given to layout.  This may be overridden in local mode.
+
+    if localmode and ('netlist-source' in dsheet) and (not force_regenerate):
+        print("Checking for out-of-date netlists.\n")
+        netlist_source = dsheet['netlist-source']
+        need_sch_capture = check_schematic_out_of_date(schnetlist, schempath)
+        need_stub_capture = check_schematic_out_of_date(stubnetlist, schempath)
+        if netlist_source == 'layout':
+            netlist_path = pexnetlist
+            need_pex_capture = check_layout_out_of_date(pexnetlist, layoutpath)
+            need_lay_capture = check_layout_out_of_date(laynetlist, layoutpath)
+        else:
+            netlist_path = schnetlist
+            need_lay_capture = False
+            need_pex_capture = False
+    else:
+        if not localmode:
+            print("Remote use, ", end='');
+        print("forcing regeneration of all netlists.\n")
+        if 'netlist-source' in dsheet:
+            netlist_source = dsheet['netlist-source']
+            if netlist_source == 'layout':
+                netlist_path = pexnetlist
+            else:
+                netlist_path = schnetlist
+                need_lay_capture = False
+                need_pex_capture = False
+        else:
+            if os.path.exists(layoutpath):
+                netlist_path = pexnetlist
+                dsheet['netlist-source'] = 'layout'
+            elif os.path.exists(schempath):
+                netlist_path = schnetlist
+                dsheet['netlist-source'] = 'schematic'
+                need_lay_capture = False
+                need_pex_capture = False
+            elif os.path.exists(verilogpath):
+                netlist_path = verilogpath
+                dsheet['netlist-source'] = 'verilog'
+                need_lay_capture = False
+                need_pex_capture = False
+                need_sch_capture = False
+                need_stub_capture = False
+            elif os.path.exists(verilogaltpath):
+                netlist_path = verilogaltpath
+                dsheet['netlist-source'] = 'verilog'
+                need_lay_capture = False
+                need_pex_capture = False
+                need_sch_capture = False
+                need_stub_capture = False
+
+    if need_lay_capture or need_pex_capture:
+        # Layout LVS netlist needs regenerating.  Check for magic layout.
+        if not os.path.isfile(layoutpath):
+            print('Error:  No netlist or layout for project ' + dname + '.')
+            print('(layout master file ' + layoutpath + ' not found.)\n')
+            return False
+
+        # Check for spi/lvs/ directory
+        if not os.path.exists(lvspath):
+            os.makedirs(lvspath)
+
+        # Check for spi/pex/ directory
+        if not os.path.exists(pexpath):
+            os.makedirs(pexpath)
+
+        print("Extracting LVS netlist from layout. . .")
+        mproc = subprocess.Popen(['/ef/apps/bin/magic', '-dnull', '-noconsole',
+		layoutpath], stdin = subprocess.PIPE, stdout=subprocess.PIPE,
+		stderr=subprocess.STDOUT, cwd = dspath + '/mag',
+		universal_newlines = True)
+        mproc.stdin.write("select top cell\n")
+        mproc.stdin.write("expand true\n")
+        mproc.stdin.write("extract all\n")
+        mproc.stdin.write("ext2spice hierarchy on\n")
+        mproc.stdin.write("ext2spice format ngspice\n")
+        mproc.stdin.write("ext2spice scale off\n")
+        mproc.stdin.write("ext2spice renumber off\n")
+        mproc.stdin.write("ext2spice subcircuit on\n")
+        mproc.stdin.write("ext2spice global off\n")
+        # Don't want black box entries, but create them so that we know which
+        # subcircuits are in the ip path, then replace them.
+        mproc.stdin.write("ext2spice blackbox on\n")
+        if need_lay_capture:
+            mproc.stdin.write("ext2spice cthresh infinite\n")
+            mproc.stdin.write("ext2spice rthresh infinite\n")
+            mproc.stdin.write("ext2spice -o " + laynetlist + "\n")
+        if need_pex_capture:
+            mproc.stdin.write("ext2spice cthresh 0.005\n")
+            mproc.stdin.write("ext2spice rthresh 1\n")
+            mproc.stdin.write("ext2spice -o " + pexnetlist + "\n")
+        mproc.stdin.write("quit -noprompt\n")
+        magout = mproc.communicate()[0]
+        printwarn(magout)
+        if mproc.returncode != 0:
+            print('Magic process returned error code ' + str(mproc.returncode) + '\n')
+
+        if need_lay_capture and not os.path.isfile(laynetlist):
+            print('Error:  No LVS netlist extracted from magic.')
+        if need_pex_capture and not os.path.isfile(pexnetlist):
+            print('Error:  No parasitic extracted netlist extracted from magic.')
+
+        if (mproc.returncode != 0) or (need_lay_capture and not os.path.isfile(laynetlist)) or (need_pex_capture and not os.path.isfile(pexnetlist)):
+            return False
+
+        if need_pex_capture and os.path.isfile(pexnetlist):
+            print('Generating include statements for read-only IP blocks in layout, if needed')
+            layout_netlist_includes(pexnetlist, dspath)
+
+    if need_sch_capture or need_stub_capture:
+        # Netlist needs regenerating.  Check for electric schematic
+        if not os.path.isfile(schempath):
+            if os.path.isfile(verilogpath):
+                print('No schematic for project.')
+                print('Using verilog netlist ' + verilogpath + ' for simulation and LVS.')
+                return verilogpath
+            elif os.path.isfile(verilogaltpath):
+                print('No schematic for project.')
+                print('Using verilog netlist ' + verilogaltpath + ' for simulation and LVS.')
+                return verilogaltpath
+            else:
+                print('Error:  No netlist or schematic for project ' + dname + '.')
+                print('(schematic master file ' + schempath + ' not found.)\n')
+                print('Error:  No verilog netlist ' + verilogpath + ' or ' + verilogaltpath + ', either.')
+                return False
+
+        # Check if there is a .java directory, if not (e.g., for remote CACE),
+        # then copy it from the defaults.
+        if not os.path.exists(dspath + '/elec/.java'):
+            shutil.copytree('/ef/efabless/deskel/dotjava', dspath + '/elec/.java',
+			symlinks = True)
+
+    # Fix the LIBDIRS file if needed
+    if not os.path.isfile(dspath + '/elec/LIBDIRS'):
+        fix_libdirs(dspath, create = True)
+    elif need_sch_capture or need_stub_capture:
+        fix_libdirs(dspath)
+
+    if need_sch_capture:
+        print("Generating simulation netlist from schematic. . .")
+        # Generate the netlist
+        print('Calling /ef/efabless/bin/elec2spi -o ')
+        libpath = os.path.split(schempath)[0]
+        libname = os.path.split(libpath)[1]
+        print(schnetlist + ' -TS -NTI ' + libname + ' ' + dname + '.sch\n')
+
+        # elec2spi requires that the /spi/ and /spi/stub directory exists
+        if not os.path.exists(spipath):
+            os.makedirs(spipath)
+
+        eproc = subprocess.Popen(['/ef/efabless/bin/elec2spi',
+		'-o', schnetlist, '-TS', '-NTI', libname, dname + '.sch'],
+		stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+		cwd = dspath + '/elec')
+
+        elecout = eproc.communicate()[0]
+        if eproc.returncode != 0:
+            for line in elecout.splitlines():
+                print(line.decode('utf-8'))
+
+            print('Electric process returned error code ' + str(eproc.returncode) + '\n')
+        else:
+            printwarn(elecout)
+
+        if not os.path.isfile(schnetlist):
+            print('Error: No netlist found for the circuit!\n')
+            print('(schematic netlist for simulation ' + schnetlist + ' not found.)\n')
+
+    if need_stub_capture:
+        print("Generating LVS netlist from schematic. . .")
+        # Generate the netlist
+        print('Calling /ef/efabless/bin/elec2spi -o ')
+        libpath = os.path.split(schempath)[0]
+        libname = os.path.split(libpath)[1]
+        print(stubnetlist + ' -LP -TS -NTI ' + libname + ' ' + dname + '.sch\n')
+
+        # elec2spi requires that the /spi/stub directory exists
+        if not os.path.exists(stubpath):
+            os.makedirs(stubpath)
+
+        eproc = subprocess.Popen(['/ef/efabless/bin/elec2spi',
+		'-o', stubnetlist, '-LP', '-TS', '-NTI', libname, dname + '.sch'],
+		stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+		cwd = dspath + '/elec')
+
+        elecout = eproc.communicate()[0]
+        if eproc.returncode != 0:
+            for line in elecout.splitlines():
+                print(line.decode('utf-8'))
+
+            print('Electric process returned error code ' + str(eproc.returncode) + '\n')
+        else:
+            printwarn(elecout)
+
+        if not os.path.isfile(stubnetlist):
+            print('Error: No netlist found for the circuit!\n')
+            print('(schematic netlist for LVS ' + stubnetlist + ' not found.)\n')
+
+    if need_sch_capture or need_stub_capture:
+        if (not os.path.isfile(schnetlist)) or (not os.path.isfile(stubnetlist)):
+            return False
+
+    return netlist_path
+
+def cleanup_exit(signum, frame):
+    global launchproc
+    print("CACE gensim:  Received termination signal.")
+    if launchproc:
+        print("CACE gensim:  Stopping simulations now.")
+        launchproc.terminate()
+    else:
+        sys.exit(1)
+
+# Main entry point.  Read arguments, print usage or load the json file
+# and call generate_simfiles.
+
+if __name__ == '__main__':
+    faulthandler.register(signal.SIGUSR2)
+    signal.signal(signal.SIGINT, cleanup_exit)
+    signal.signal(signal.SIGTERM, cleanup_exit)
+
+    # Divide up command line into options and arguments
+    options = []
+    arguments = []
+    localmode = False
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    # Read the JSON file
+    root_path = []
+    if len(arguments) > 0:
+        root_path = str(sys.argv[1])
+        arguments = arguments[1:]
+    elif len(options) == 0:
+        # Print usage information when arguments don't match
+        print('Usage:\n')
+        print('   ' + str(sys.argv[0]) + ' [root_path] [options ...]')
+        print('Where [options ...] are one or more of the following:')
+        print(' -simdir <path>')
+        print('      is the location where simulation files and data should be placed.')
+        print(' -datasheetdir <path>')
+        print('      is the location of the JSON file describing the characterization.')
+        print(' -testbenchdir <path>')
+        print('      is the location of the netlists for the characterization methods.')
+        print(' -netlist <path>')
+        print('      is the location of the netlist for the device-under-test.')
+        print(' -layoutdir <path>')
+        print('      is the location of the layout netlist for the device-under-test.')
+        print(' -datasheet <name>')
+        print('      is the name of the datasheet JSON file.')
+        print(' -method <name>, ...')
+        print('      is a list of one or more names of methods to simulated.  If omitted,')
+        print('      all methods are run for a complete characterization.')
+        print(' -local')
+        print('      indicates that cace_gensim is being run locally, not on the CACE')
+        print('      server, simulation conditions should be output along with results;')
+        print('      "local" mode implies that results are not posted to the marketplace')
+        print('      after simulation, and result files are kept.')
+        print(' -keep')
+        print('      test mode:  keep all files after simulation')
+        print(' -plot')
+        print('      test mode:  generate plot (.png) files locally')
+        print(' -nopost')
+        print('      test mode:  do not post results to the marketplace')
+        print(' -nosim')
+        print('      test mode:  set up all files for simulation but do not simulate')
+        sys.exit(0)
+
+    simulation_path = []
+    datasheet_path = []
+    testbench_path = []
+    design_path = []
+    layout_path = []
+    datasheet_name = []
+    methods = []
+    for option in options[:]:
+        result = option.split('=')
+        if result[0] == '-simdir':
+            simulation_path = result[1]
+            options.remove(option)
+        elif result[0] == '-datasheetdir':
+            datasheet_path = result[1]
+            options.remove(option)
+        elif result[0] == '-testbenchdir':
+            testbench_path = result[1]
+            options.remove(option)
+        elif result[0] == '-designdir':
+            design_path = result[1]
+            options.remove(option)
+        elif result[0] == '-layoutdir':
+            layout_path = result[1]
+            options.remove(option)
+        elif result[0] == '-datasheet':
+            datasheet_name = result[1]
+            options.remove(option)
+        elif result[0] == '-method':
+            methods.append(result[1])
+            options.remove(option)
+        elif result[0] == '-bypass':
+            bypassmode = True
+            options.remove(option)
+        elif result[0] == '-local':
+            localmode = True
+
+    # To be valid, must either have a root path or all other options must have been
+    # specified with full paths.
+    if not root_path:
+        err_result = 1
+        if not simulation_path:
+            print('Error:  If root_path is not provided, -simdir is required.')
+        elif simulation_path[0] != '/':
+            print('Error:  If root_path not provided, -simdir must be a full path.')
+        if not testbench_path:
+            print('Error:  If root_path is not provided, -testbenchdir is required.')
+        elif testbench_path[0] != '/':
+            print('Error:  If root_path not provided, -testbenchdir must be a full path.')
+        if not design_path:
+            print('Error:  If root_path is not provided, -designdir is required.')
+        elif design_path[0] != '/':
+            print('Error:  If root_path not provided, -designdir must be a full path.')
+        if not layout_path:
+            print('Error:  If root_path is not provided, -layoutdir is required.')
+        elif layout_path[0] != '/':
+            print('Error:  If root_path not provided, -layoutdir must be a full path.')
+        if not datasheet_path:
+            print('Error:  If root_path is not provided, -datasheetdir is required.')
+        elif datasheet_path[0] != '/':
+            print('Error:  If root_path not provided, -datasheetdir must be a full path.')
+        else:
+            err_result = 0
+
+        if err_result:
+            sys.exit(1)
+
+    # Apply defaults where not provided as command-line options
+    else:
+        if not datasheet_path:
+            datasheet_path = root_path
+        elif not os.path.isabs(datasheet_path):
+            datasheet_path = root_path + '/' + datasheet_path
+        if not datasheet_name:
+            datasheet_name = 'datasheet.json'
+            inputfile = datasheet_path + '/' + datasheet_name
+            # 2nd guess:  'project.json'
+            if not os.path.isfile(inputfile):
+                datasheet_name = 'project.json'
+                inputfile = datasheet_path + '/' + datasheet_name
+            # 3rd guess (legacy behavior):  project directory name + '.json'
+            if not os.path.isfile(inputfile):
+                datasheet_name = os.path.split(datasheet_path)[1] + '.json'
+                inputfile = datasheet_path + '/' + datasheet_name
+            if not os.path.isfile(inputfile):
+                # Return to original datasheet name;  error will be generated.
+                datasheet_name = 'datasheet.json'
+            elif localmode and root_path:
+                # Use normal path to local simulation workspace
+                simulation_path = root_path + '/ngspice/char'
+
+    # Check that datasheet path exists and that the datasheet is there
+    if not os.path.isdir(datasheet_path):
+        print('Error:  Path to datasheet ' + datasheet_path + ' does not exist.')
+        sys.exit(1)
+    if len(os.path.splitext(datasheet_name)) != 2:
+        datasheet_name += '.json'
+    inputfile = datasheet_path + '/' + datasheet_name
+    if not os.path.isfile(inputfile):
+        print('Error:  No datasheet file ' + inputfile )
+        sys.exit(1)
+
+    with open(inputfile) as ifile:
+       datatop = json.load(ifile)
+
+    # Pick up testbench and design paths from options now, since some of them
+    # depend on the request-hash value in the JSON file.
+
+    if not simulation_path:
+        if 'request-hash' in datatop:
+            hashname = datatop['request-hash']
+            simulation_path = root_path + '/' + hashname
+        elif os.path.isdir(root_path + '/ngspice/char'):
+            simulation_path = root_path + '/ngspice/char'
+        else:
+            simulation_path = root_path
+    elif not os.path.isabs(simulation_path):
+        simulation_path = root_path + '/' + simulation_path
+    if not testbench_path:
+        testbench_path = root_path + '/testbench'
+    elif not os.path.isabs(testbench_path):
+        testbench_path = root_path + '/' + testbench_path
+    if not design_path:
+        design_path = root_path + '/spi'
+    elif not os.path.isabs(design_path):
+        design_path = root_path + '/' + design_path
+    if not layout_path:
+        layout_path = root_path + '/mag'
+    elif not os.path.isabs(layout_path):
+        layout_path = root_path + '/' + layout_path
+
+    # Project name should be 'ip-name' in datatop['data-sheet']
+    try:
+        dsheet = datatop['data-sheet']
+    except KeyError:
+        print('Error:  File ' + inputfile + ' is not a datasheet.\n')
+        sys.exit(1)
+    try:
+        name = dsheet['ip-name']
+    except KeyError:
+        print('Error:  File ' + inputfile + ' is missing ip-name.\n')
+        sys.exit(1)
+
+    if not os.path.isdir(testbench_path):
+        print('Warning:  Path ' + testbench_path + ' does not exist.  ' +
+			'Testbench files are not available.\n')
+
+    if not os.path.isdir(design_path):
+        print('Warning:  Path ' + design_path + ' does not exist.  ' +
+			'Netlist files may not be available.\n')
+
+    # Simulation path is where the output is dumped.  If it doesn't
+    # exist, then create it.
+    if not os.path.isdir(simulation_path):
+        print('Creating simulation path ' + simulation_path)
+        os.makedirs(simulation_path)
+
+    if not os.path.isdir(layout_path):
+        print('Creating layout path ' + layout_path)
+        os.makedirs(layout_path)
+
+    if not os.path.exists(layout_path + '/.magicrc'):
+        # Make sure correct .magicrc file exists
+        configdir = os.path.split(layout_path)[0]
+        rcpath = configdir + '/.ef-config/techdir/libs.tech/magic/current'
+        pdkname = os.path.split(os.path.realpath(configdir + '/.ef-config/techdir'))[1]
+        rcfile = rcpath + '/' + pdkname + '.magicrc'
+        if os.path.isdir(rcpath):
+            if os.path.exists(rcfile):
+                shutil.copy(rcfile, layout_path + '/.magicrc')
+
+    # Find the electrical parameter list.  If it exists, then the
+    # template has been loaded.  If not, find the template name,
+    # then load it from known templates.  Templates may be local to
+    # the simulation files.  Priority is (1) templates known to CACE
+    # (for challenges;  cannot be overridden by a user; (2) templates
+    # local to the simulation (user-generated)
+
+    if not 'electrical-params' in dsheet and not 'physical-params' in dsheet:
+        print('Error: Circuit JSON file does not have a valid characterization template!\n')
+        sys.exit(1)
+
+    fullnetlistpath = regenerate_netlists(localmode, root_path, dsheet)
+    if not fullnetlistpath:
+        sys.exit(1)
+
+    netlistpath, netlistname = os.path.split(fullnetlistpath)
+
+    # If there is a 'hints.json' file in the root path, read it and apply to the
+    # electrical parameters.  The file contains exactly one hint record per
+    # electrical parameter, although the hint record may be empty.
+    if os.path.exists(root_path + '/hints.json'):
+        with open(root_path + '/hints.json') as hfile:
+            hintlist = json.load(hfile)
+            i = 0
+            for eparam in dsheet['electrical-params']:
+                if not 'hints' in eparam:
+                    if hintlist[i]:
+                        eparam['hints'] = hintlist[i]
+                i += 1
+
+    # Construct fileinfo dictionary
+    fileinfo = {}
+    fileinfo['project-name'] = name
+    fileinfo['design-netlist-name'] = netlistname
+    fileinfo['design-netlist-path'] = netlistpath
+    fileinfo['testbench-netlist-path'] = testbench_path
+    fileinfo['simulation-path'] = simulation_path
+    fileinfo['root-path'] = root_path
+
+    # Generate the simulation files
+    prescore = generate_simfiles(datatop, fileinfo, arguments, methods, localmode)
+    if prescore == 'fail':
+        # In case of failure
+        options.append('-score=fail')
+
+    # Remove option keys
+    if 'keep' in datatop:
+        options.append('-keep')
+        datatop.pop('keep')
+    if 'plot' in datatop:
+        options.append('-plot')
+        datatop.pop('plot')
+    if 'nopost' in datatop:
+        options.append('-nopost')
+        datatop.pop('nopost')
+    if 'nosim' in datatop:
+        options.append('-nosim')
+        datatop.pop('nosim')
+
+    # Reconstruct the -simdir option for cace_launch
+    options.append('-simdir=' + simulation_path)
+
+    # Reconstruct the -layoutdir option for cace_launch
+    options.append('-layoutdir=' + layout_path)
+
+    # Reconstruct the -netlistdir option for cace_launch
+    options.append('-netlistdir=' + design_path)
+
+    # Reconstruct the -rootdir option for cace_launch
+    if root_path:
+        options.append('-rootdir=' + root_path)
+
+    # Dump the modified JSON file
+    basename = os.path.basename(inputfile)
+    outputfile = simulation_path + '/' + basename
+    with open(outputfile, 'w') as ofile:
+        json.dump(datatop, ofile, indent = 4)
+
+    # Launch simulator as a subprocess and wait for it to finish
+    # Waiting is important, as otherwise child processes get detached and it
+    # becomes very difficult to find them if the simulation needs to be stopped.
+    launchname = apps_path + '/' + 'cace_launch.py'
+
+    # Diagnostic
+    print("Running: " + launchname + ' ' + outputfile)
+    for a in arguments:
+        print(a)
+    for o in options:
+        print(o)
+
+    with subprocess.Popen([launchname, outputfile, *arguments, *options],
+        		stdout=subprocess.PIPE, bufsize = 1,
+			universal_newlines=True) as launchproc:
+        for line in launchproc.stdout:
+            print(line, end='')
+            sys.stdout.flush()
+
+        launchproc.stdout.close()
+        return_code = launchproc.wait()
+        if return_code != 0:
+            raise subprocess.CalledProcessError(return_code, launchname)
+
+    sys.exit(0)
diff --git a/common/cace_launch.py b/common/cace_launch.py
new file mode 100755
index 0000000..2867819
--- /dev/null
+++ b/common/cace_launch.py
@@ -0,0 +1,2344 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+"""
+cace_launch.py
+A simple script that pulls in a JSON file and uses the hash key to find the
+directory of spice simulation netlists associated with that file, and runs
+them.  The output of all files in a category <SKU>_<METHOD>_<PIN>_* is
+analyzed, and the result written back to the data structure.  Then the
+annotated structure is passed back to the marketplace.
+"""
+
+# NOTE:  This file is only a local stand-in for the script that launches
+# and manages jobs in parallel and that communicates job status with the
+# front-end.  This stand-alone version should not be used for any significant
+# project, as all simulations are run sequentially and will tie up resources.
+
+import os
+import sys
+import shutil
+import tarfile
+import json
+import re
+import math
+import signal
+import datetime
+import requests
+import subprocess
+import faulthandler
+from spiceunits import spice_unit_unconvert
+from spiceunits import spice_unit_convert
+
+import file_compressor
+import cace_makeplot
+
+import og_config
+
+# Values imported from og_config:
+#
+mktp_server_url = og_config.mktp_server_url
+# obs: og_server_url = og_config.og_server_url
+simulation_path = og_config.simulation_path
+
+# Variables needing to be global until this file is properly made into a class
+simfiles_path = []
+layout_path = []
+netlist_path = []
+root_path = []
+hashname = ""
+spiceproc = None
+localmode = False
+bypassmode = False
+statdoc = {}
+
+# Send the simulation status to the remote Open Galaxy host
+def send_status(doc):
+    result = requests.post(og_server_url + '/opengalaxy/send_status_cace', json=doc)
+    print('send_status_cace ' + str(result.status_code))
+
+# Make request to server sending annotated json back
+def send_doc(doc):
+    result = requests.post(mktp_server_url + '/cace/save_result', json=doc)
+    print('send_doc ' + str(result.status_code))
+
+# Pure HTTP post here.  Add the file to files object and the hash/filename
+# to the data params.
+def send_file(hash, file, file_name):
+    files = {'file': file.getvalue()}
+    data = {'request-hash': hash, 'file-name': file_name}
+    result = requests.post(mktp_server_url + '/cace/save_result_files', files=files, data=data)
+    print('send_file ' + str(result.status_code))
+
+# Clean up and exit on termination signal
+def cleanup_exit(signum, frame):
+    global root_path
+    global simfiles_path
+    global simulation_path
+    global spiceproc
+    global statdoc
+    global localmode
+    if spiceproc:
+        print("CACE launch:  Termination signal received.")
+        spiceproc.terminate()
+        spiceproc.wait()
+
+    # Remove simulation files
+    print("CACE launch:  Simulations have been terminated.")
+    if localmode == False:
+        test = os.path.split(root_path)[0]
+        if test != simulation_path:
+            print('Error:  Root path is not in the system simulation path.  Not deleting.')
+            print('Root path is ' + root_path + '; simulation path is ' + simulation_path)
+        else:
+            subprocess.run(['rm', '-r', root_path])
+    else:
+        # Remove all .spi files, .data files, .raw files and copy of datasheet
+        os.chdir(simfiles_path)
+        if os.path.exists('datasheet.json'):
+            os.remove('datasheet.json')
+        files = os.listdir(simfiles_path)
+        for filename in files:
+            try:
+                fileext = os.path.splitext(filename)[1]
+            except:
+                pass
+            else:
+                if fileext == '.spi' or fileext == '.data' or fileext == '.raw':
+                    os.remove(filename)
+                elif fileext == '.tv' or fileext == '.tvo' or fileext == '.lxt' or fileext == '.vcd':
+                    os.remove(filename)
+
+    # Post exit status back to Open Galaxy
+    if statdoc and not localmode:
+        status['message'] = 'canceled'
+        send_status(statdoc)
+
+    # Exit
+    sys.exit(0)
+
+# Handling of 2s complement values in calculations (e.g., "1000" is -8, not +8)
+# If a value should be unsigned, then the units for the value should be one bit
+# larger than represented.  e.g., if unit = "4'b" and value = "1000" then value
+# is -8, but if unit = "5'b" and value = "1000" then value is +8.
+
+def twos_complement(val, bits):
+    """compute the 2's compliment of int value val"""
+    if (val & (1 << (bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255
+        val = val - (1 << bits)        # compute negative value
+    return val                         # return positive value as is
+
+# Calculation of results from collected data for an output record,
+# given the type of calculation to perform in 'calctype'.  Known
+# calculations are minimum, maximum, and average (others can be
+# added as needed).
+
+def calculate(record, rawdata, conditions, calcrec, score, units, param):
+    # Calculate result from rawdata based on calctype;  place
+    # result in record['value'].
+
+    # "calcrec" is parsed as "calctype"-"limittype", where:
+    #     "calctype" is one of:  avg, min, max
+    #     "limittype" is one of: above, below, exact
+
+    # "min" alone implies "min-above"
+    # "max" alone implies "max-below"
+    # "avg" alone implies "avg-exact"
+
+    # Future development:
+    # Add "minimax", "maximin", and "typ" to calctypes (needs extra record(s))
+    # Add "range" to limittypes (needs extra record or tuple for target)
+
+    binrex = re.compile(r'([0-9]*)\'([bodh])', re.IGNORECASE)
+
+    data = rawdata
+    if 'filter' in record:
+        # Filter data by condition range.
+        filtspec = record['filter'].split('=')
+        if len(filtspec) == 2:
+            condition = filtspec[0].upper()
+            valuerange = filtspec[1].split(':')
+            # Pick data according to filter, which specifies a condition and value, or condition
+            # and range of values in the form "a:b".  Syntax is limited and needs to be expanded. 
+            if condition in conditions:
+                condvec = conditions[condition]
+                if len(valuerange) == 2:
+                    valuemin = int(valuerange[0])
+                    valuemax = int(valuerange[1])
+                    data = list(i for i, j in zip(rawdata, condvec) if j >= valuemin and j <= valuemax)
+                else:
+                    try:
+                        valueonly = float(valuerange[0])
+                    except ValueError:
+                        valueonly = valuerange[0]
+                    vtype = type(valueonly)
+                    if vtype == type('str') or vtype == type('int'):
+                        data = list(i for i, j in zip(rawdata, condvec) if j == valueonly)
+                        if not data:
+                            print('Error: no data match ' + condition + ' = ' + str(valueonly))
+                            data = rawdata
+                    else:
+                        # Avoid round-off problems from floating-point values
+                        d = valueonly * 0.001
+                        data = list(i for i, j in zip(rawdata, condvec) if j - d < valueonly and j + d > valueonly)
+                        if not data:
+                            print('Error: no data match ' + condition + ' ~= ' + str(valueonly))
+                            data = rawdata
+
+        # For "filter: typical", limit data to those taken for any condition value
+        # which is marked as typical for that condition.
+
+        elif record['filter'] == 'typ' or record['filter'] == 'typical':
+
+            # Create a boolean vector to track which results are under typical conditions
+            typvec = [True] * len(rawdata)
+            for condition in conditions:
+                # Pull record of the condition (this must exist by definition)
+                condrec = next(item for item in param['conditions'] if item['condition'] == condition)
+                if 'typ' not in condrec:
+                    continue
+                try:
+                    valueonly = float(condrec['typ'])
+                except ValueError:
+                    valueonly = condrec['typ']
+                condvec = conditions[condition]
+                typloc = list(i == valueonly for i in condvec)
+                typvec = list(i and j for i, j in zip(typloc, typvec))
+            # Limit data to marked entries
+            data = list(i for i, j in zip(rawdata, typvec) if j)
+    try:
+        calctype, limittype = calcrec.split('-')
+    except ValueError:
+        calctype = calcrec
+        if calctype == 'min':
+            limittype = 'above'
+        elif calctype == 'max':
+            limittype = 'below'
+        elif calctype == 'avg':
+            limittype = 'exact'
+        elif calctype == 'diffmin':
+            limittype = 'above'
+        elif calctype == 'diffmax':
+            limittype = 'below'
+        else:
+            return 0
+    
+    # Quick format sanity check---may need binary or hex conversion
+    # using the new method of letting units be 'b or 'h, etc.
+    # (to be done:  signed conversion, see cace_makeplot.py)
+    if type(data[0]) == type('str'):
+        bmatch = binrex.match(units)
+        if (bmatch):
+            digits = bmatch.group(1)
+            if digits == '':
+                digits = len(data[0])
+            else:
+                digits = int(digits)
+            base = bmatch.group(2)
+            if base == 'b':
+                a = list(int(x, 2) for x in data)
+            elif base == 'o':
+                a = list(int(x, 8) for x in data)
+            elif base == 'd':
+                a = list(int(x, 10) for x in data)
+            else:
+                a = list(int(x, 16) for x in data)
+            data = list(twos_complement(x, digits) for x in a)
+        else:
+            print("Warning: result data do not correspond to specified units.")
+            print("Data = " + str(data))
+            return 0
+
+    # The target and result should both match the specified units, so convert
+    # the target if it is a binary, hex, etc., value.
+    if 'target' in record:
+        targval = record['target']
+        bmatch = binrex.match(units)
+        if (bmatch):
+            digits = bmatch.group(1)
+            base = bmatch.group(2)
+            if digits == '':
+                digits = len(targval)
+            else:
+                digits = int(digits)
+            try:
+                if base == 'b':
+                    a = int(targval, 2)
+                elif base == 'o':
+                    a = int(targval, 8)
+                elif base == 'd':
+                    a = int(targval, 10)
+                else:
+                    a = int(targval, 16)
+                targval = twos_complement(a, digits)
+            except:
+                print("Warning: target data do not correspond to units; assuming integer.")
+
+    # First run the calculation to get the single result value
+
+    if calctype == 'min':
+        # Result is the minimum of the data
+        value = min(data)
+    elif calctype == 'max':
+        # Result is the maximum of the data
+        value = max(data)
+    elif calctype == 'avg':
+        # Result is the average of the data
+        value = sum(data) / len(data)
+    elif calctype[0:3] == 'std':
+        # Result is the standard deviation of the data
+        mean = sum(data) / len(data)
+        value = pow(sum([((i - mean) * (i - mean)) for i in data]) / len(data), 0.5)
+        # For "stdX", where "X" is an integer, multiply the standard deviation by X
+        if len(calctype) > 3:
+            value *= int(calctype[3])
+
+        if len(calctype) > 4:
+            # For "stdXn", subtract X times the standard deviation from the mean
+            if calctype[4] == 'n':
+                value = mean - value
+            # For "stdXp", add X times the standard deviation to the mean
+            elif calctype[4] == 'p':
+                value = mean + value
+    elif calctype == 'diffmax':
+        value = max(data) - min(data)
+    elif calctype == 'diffmin':
+        value = min(data) - max(data)
+    else:
+        return 0
+
+    try:
+        record['value'] = '{0:.4g}'.format(value)
+    except ValueError:
+        print('Warning: Min/Typ/Max value is not not numeric; value is ' + value)
+        return 0
+
+    # Next calculate the score based on the limit type
+
+    if limittype == 'above':
+        # Score a penalty if value is below the target
+        if 'target' in record:
+            targval = float(targval)
+            dopassfail = False
+            if 'penalty' in record:
+                if record['penalty'] == 'fail':
+                    dopassfail = True
+                else:
+                    penalty = float(record['penalty'])
+            else:
+                penalty = 0
+            print('min = ' + str(value))
+            # NOTE: 0.0005 value corresponds to formatting above, so the
+            # value is not marked in error unless it would show a different
+            # value in the display.
+            if value < targval - 0.0005:
+                if dopassfail:
+                    locscore = 'fail'
+                    score = 'fail'
+                    print('fail: target = ' + str(record['target']) + '\n')
+                else:
+                    locscore = (targval - value) * penalty
+                    print('fail: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                    if score != 'fail':
+                        score += locscore
+            elif math.isnan(value):
+                locscore = 'fail'
+                score = 'fail'
+            else:
+                if dopassfail:
+                    locscore = 'pass'
+                else:
+                    locscore = 0
+                print('pass')
+            if dopassfail:
+                record['score'] = locscore
+            else:
+                record['score'] = '{0:.4g}'.format(locscore)
+
+    elif limittype == 'below':
+        # Score a penalty if value is above the target
+        if 'target' in record:
+            targval = float(targval)
+            dopassfail = False
+            if 'penalty' in record:
+                if record['penalty'] == 'fail':
+                    dopassfail = True
+                else:
+                    penalty = float(record['penalty'])
+            else:
+                penalty = 0
+            print('max = ' + str(value))
+            # NOTE: 0.0005 value corresponds to formatting above, so the
+            # value is not marked in error unless it would show a different
+            # value in the display.
+            if value > targval + 0.0005:
+                if dopassfail:
+                    locscore = 'fail'
+                    score = 'fail'
+                    print('fail: target = ' + str(record['target']) + '\n')
+                else:
+                    locscore = (value - targval) * penalty
+                    print('fail: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                    if score != 'fail':
+                        score += locscore
+            elif math.isnan(value):
+                locscore = 'fail'
+                score = 'fail'
+            else:
+                if dopassfail:
+                    locscore = 'pass'
+                else:
+                    locscore = 0
+                print('pass')
+            if dopassfail:
+                record['score'] = locscore
+            else:
+                record['score'] = '{0:.4g}'.format(locscore)
+
+    elif limittype == 'exact':
+        # Score a penalty if value is not equal to the target
+        if 'target' in record:
+            targval = float(targval)
+            dopassfail = False
+            if 'penalty' in record:
+                if record['penalty'] == 'fail':
+                    dopassfail = True
+                else:
+                    penalty = float(record['penalty'])
+            else:
+                penalty = 0
+
+            if value != targval:
+                if dopassfail:
+                    locscore = 'fail'
+                    score = 'fail'
+                    print('off-target failure')
+                else:
+                    locscore = abs(targval - value) * penalty
+                    print('off-target: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                    if score != 'fail':
+                        score += locscore
+            elif math.isnan(value):
+                locscore = 'fail'
+                score = 'fail'
+            else:
+                print('on-target')
+                if dopassfail:
+                    locscore = 'pass'
+                else:
+                    locscore = 0
+
+            if dopassfail:
+                record['score'] = locscore
+            else:
+                record['score'] = '{0:.4g}'.format(locscore)
+
+    elif limittype == 'legacy':
+        # Score a penalty if the value is not equal to the target, except
+        # that a lack of a minimum record implies no penalty below the
+        # target, and lack of a maximum record implies no penalty above
+        # the target.  This is legacy behavior for "typ" records, and is
+        # used if no "calc" key appears in the "typ" record.  "legacy" may
+        # also be explicitly stated, although it is considered deprecated
+        # in favor of "avg-max" and "avg-min".
+
+        if 'target' in record:
+            targval = float(targval)
+            if record['penalty'] == 'fail':
+                # "typical" should never be pass-fail
+                penalty = 0
+            else:
+                penalty = float(record['penalty'])
+            print('typ = ' + str(value))
+            if value != targval:
+                if 'max' in param and value > targval:
+                    # max specified, so values below 'typ' are not costed
+                    # this method deprecated, use 'calc' = 'avg-max' instead.
+                    locscore = (value - targval) * penalty
+                    print('above-target: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                elif 'min' in param and value < targval:
+                    # min specified, so values above 'typ' are not costed
+                    # this method deprecated, use 'calc' = 'avg-min' instead.
+                    locscore = (targval - value) * penalty
+                    print('below-target: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                elif 'max' not in param and 'min' not in param:
+                    # Neither min and max specified, so value is costed on
+                    # both sides of the target.
+                    locscore = abs(targval - value) * penalty
+                    print('off-target: target = ' + str(record['target'])
+					+ ' penalty = ' + str(locscore))
+                else:
+                    locscore = 0
+                if score != 'fail':
+                    score += locscore
+            else:
+                locscore = 0
+                print('on-target')
+            record['score'] = '{0:.4g}'.format(locscore)
+
+    # Note:  Calctype 'none' performs no calculation.  Record is unchanged,
+    # and "score" is returned unchanged.
+
+    return score
+
+def run_and_analyze_lvs(dsheet):
+    ipname = dsheet['ip-name']
+    node = dsheet['node']
+    # Hack---node XH035 should have been specified as EFXH035A; allow
+    # the original one for backwards compatibility.
+    if node == 'XH035':
+        node = 'EFXH035A'
+    mag_path = netlist_path + '/lvs/' + ipname + '.spi'
+    schem_path = netlist_path + '/stub/' + ipname + '.spi'
+
+    if not os.path.exists(schem_path):
+        schem_path = netlist_path + '/' + ipname + '.spi'
+    if not os.path.exists(schem_path):
+        if os.path.exists(root_path + '/verilog'):
+            schem_path = root_path + '/verilog/' + ipname + '.v'
+
+    # Check the netlist to see if the cell to match is a subcircuit.  If
+    # not, then assume it is the top level.
+
+    is_subckt = False
+    subrex = re.compile('^[^\*]*[ \t]*.subckt[ \t]+([^ \t]+).*$', re.IGNORECASE)
+    with open(mag_path) as ifile:
+        spitext = ifile.read()
+
+    dutlines = spitext.replace('\n+', ' ').splitlines()
+    for line in dutlines:
+        lmatch = subrex.match(line)
+        if lmatch:
+            subname = lmatch.group(1)
+            if subname.lower() == ipname.lower():
+                is_subckt = True
+                break
+
+    if is_subckt:
+        layout_arg = mag_path + ' ' + ipname
+    else:
+        layout_arg = mag_path
+
+    # Get PDK name for finding the netgen setup file
+    if os.path.exists(root_path + '/.ef-config'):
+        pdkdir = os.path.realpath(root_path + '/.ef-config/techdir')
+    else:
+        foundry = dsheet['foundry']
+        pdkdir = '/ef/tech/' + foundry + '/' + node
+    lvs_setup = pdkdir + '/libs.tech/netgen/' + node + '_setup.tcl'
+
+    # Run LVS as a subprocess and wait for it to finish.  Use the -json
+    # switch to get a file that is easy to parse.
+
+    print('cace_launch.py:  running /ef/apps/bin/netgen -batch lvs ')
+    print(layout_arg + ' ' + schem_path + ' ' + ipname + ' ' + lvs_setup + ' comp.out -json -blackbox')
+
+    lvsproc = subprocess.run(['/ef/apps/bin/netgen', '-batch', 'lvs',
+		layout_arg, schem_path + ' ' + ipname,
+		lvs_setup, 'comp.out', '-json', '-blackbox'], cwd=layout_path,
+		stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
+
+    with open(layout_path + '/comp.json', 'r') as cfile:
+        lvsdata = json.load(cfile)
+
+    # Count errors in the JSON file
+    failures = 0
+    ncells = len(lvsdata)
+    for c in range(0, ncells):
+        cellrec = lvsdata[c]
+        if c == ncells - 1:
+            topcell = True
+        else:
+            topcell = False
+
+        # Most errors must only be counted for the top cell, because individual
+        # failing cells are flattened and the matching attempted again on the
+        # flattened netlist.
+
+        if topcell:
+            if 'devices' in cellrec:
+                devices = cellrec['devices']
+                devlist = [val for pair in zip(devices[0], devices[1]) for val in pair]
+                devpair = list(devlist[p:p + 2] for p in range(0, len(devlist), 2))
+                for dev in devpair:
+                    c1dev = dev[0]
+                    c2dev = dev[1]
+                    diffdevs = abs(c1dev[1] - c2dev[1])
+                    failures += diffdevs
+
+            if 'nets' in cellrec:
+                nets = cellrec['nets']
+                diffnets = abs(nets[0] - nets[1])
+                failures += diffnets
+
+            if 'badnets' in cellrec:
+                badnets = cellrec['badnets']
+                failures += len(badnets)
+
+            if 'badelements' in cellrec:
+                badelements = cellrec['badelements']
+                failures += len(badelements)
+
+            if 'pins' in cellrec:
+                pins = cellrec['pins']
+                pinlist = [val for pair in zip(pins[0], pins[1]) for val in pair]
+                pinpair = list(pinlist[p:p + 2] for p in range(0, len(pinlist), 2))
+                for pin in pinpair:
+                    if pin[0].lower() != pin[1].lower():
+                        failures += 1
+
+        # Property errors must be counted for every cell
+        if 'properties' in cellrec:
+            properties = cellrec['properties']
+            failures += len(properties)
+
+    return failures
+
+def apply_measure(varresult, measure, variables):
+    # Apply a measurement (record "measure") using vectors found in
+    # "varresult" and produce new vectors which overwrite the original
+    # ones.  Operations may reduce "varresult" vectors to a single value.
+
+    # 'condition' defaults to TIME;  but this only applies to transient analysis data!
+    if 'condition' in measure:
+        condition = measure['condition']
+        if condition == 'RESULT':
+            # 'RESULT' can either be a specified name (not recommended), or else it is
+            # taken to be the variable set as the result variable. 
+            try:
+                activevar = next(item for item in variables if item['condition'] == condition)
+            except StopIteration:
+                try:
+                    activevar = next(item for item in variables if 'result' in item)
+                except StopIteration:
+                    print('Error: measurement condition ' + condition + ' does not exist!')
+                    return 0
+                else:
+                    condition = activevar['condition']
+                
+    else:
+        condition = 'TIME'
+
+    # Convert old-style separate condition, pin to new style combined
+    if 'pin' in measure:
+        if ':' not in measure['condition']:
+            measure['condition'] += ':' + measure['pin']
+        measure.pop('pin', 0)
+
+    try:
+        activevar = next(item for item in variables if item['condition'] == condition)
+    except:
+        activeunit = ''
+    else:
+        if 'unit' in activevar:
+            activeunit = activevar['unit']
+        else:
+            activeunit = ''
+
+    try:
+        activetrace = varresult[condition]
+    except KeyError:
+        print("Measurement error:  Condition " + condition + " does not exist in results.")
+        # No active trace;  cannot continue.
+        return
+
+    rsize = len(activetrace)
+ 
+    if 'TIME' in varresult:
+        timevector = varresult['TIME']
+        try:
+            timevar = next(item for item in variables if item['condition'] == 'TIME')
+        except:
+            timeunit = 's'
+        else:
+            if 'unit' in timevar:
+                timeunit = timevar['unit']
+            else:
+                timeunit = 's'
+    else:
+        timevector = []
+        timeunit = ''
+
+    calctype = measure['calc']
+    # Diagnostic
+    # print("Measure calctype = " + calctype)
+
+    if calctype == 'RESULT':
+        # Change the 'result' marker to the indicated condition.
+        for var in variables:
+            if 'result' in var:
+                var.pop('result')
+
+        activevar['result'] = True
+
+    elif calctype == 'REMOVE':
+        # Remove the indicated condition vector.
+        varresult.pop(condition)
+
+    elif calctype == 'REBASE':
+        # Rebase specified vector (subtract minimum value from all components)
+        base = min(activetrace)
+        varresult[condition] = [i - base for i in activetrace]
+
+    elif calctype == 'ABS':
+        # Take absolute value of activetrace.
+        varresult[condition] = [abs(i) for i in activetrace]
+        
+    elif calctype == 'NEGATE':
+        # Negate the specified vector
+        varresult[condition] = [-i for i in activetrace]
+        
+    elif calctype == 'ADD':
+        if 'value' in measure:
+            v = float(measure['value'])
+            varresult[condition] = [i + v for i in activetrace]
+        else: 
+            # Add the specified vector to the result and replace the result
+            varresult[condition] = [i + j for i, j in zip(activetrace, paramresult)]
+        
+    elif calctype == 'SUBTRACT':
+        if 'value' in measure:
+            v = float(measure['value'])
+            varresult[condition] = [i - v for i in activetrace]
+        else: 
+            # Subtract the specified vector from the result
+            varresult[condition] = [j - i for i, j in zip(activetrace, paramresult)]
+
+    elif calctype == 'MULTIPLY':
+        if 'value' in measure:
+            v = float(measure['value'])
+            varresult[condition] = [i * v for i in activetrace]
+        else: 
+            # Multiply the specified vector by the result (e.g., to get power)
+            varresult[condition] = [j * i for i, j in zip(activetrace, paramresult)]
+        
+    elif calctype == 'CLIP':
+        if timevector == []:
+            return
+        # Clip specified vector to the indicated times
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+            toidx += 1
+        except StopIteration:
+            toidx = len(timevector)
+
+        for key in varresult:
+            vector = varresult[key]
+            varresult[key] = vector[fromidx:toidx]
+
+        rsize = toidx - fromidx
+
+    elif calctype == 'MEAN':
+        if timevector == []:
+            return
+
+        # Get the mean value of all traces in the indicated range.  Results are 
+	# collapsed to the single mean value.
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+            toidx += 1
+        except StopIteration:
+            toidx = len(timevector)
+
+        # Correct time average requires weighting according to the size of the
+        # time slice.
+        tsum = timevector[toidx - 1] - timevector[fromidx]
+
+        for key in varresult:
+            vector = varresult[key]
+            try:
+                # Test if condition is a numeric value
+                varresult[key] = vector[fromidx] + 1
+            except TypeError:
+                # Some conditions like 'corner' cannot be averaged, so just take the
+                # first entry (may want to consider different handling)
+                varresult[key] = [vector[fromidx]]
+            else:
+                vtot = 0.0
+                for i in range(fromidx + 1, toidx):
+                    # Note:  This expression can and should be optimized!
+                    vtot += ((vector[i] + vector[i - 1]) / 2) * (timevector[i] - timevector[i - 1])
+                varresult[key] = [vtot / tsum]
+
+        rsize = 1
+        
+    elif calctype == 'RISINGEDGE':
+        if timevector == []:
+            return
+
+        # RISINGEDGE finds the time of a signal rising edge.
+        # parameters used are:
+        # 'from':   start time of search (default zero)
+        # 'to':     end time of search (default end)
+        # 'number': edge number (default first edge, or zero) (to be done)
+        # 'cross':  measure time when signal crosses this value
+        # 'keep':  determines what part of the vectors to keep
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+        if 'cross' in measure:
+            crossval = float(measure['cross'])
+        else:
+            crossval = (max(activetrace) + min(activetrace)) / 2;
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+            toidx += 1
+        except StopIteration:
+            toidx = len(timevector)
+        try:
+            startidx = next(i for i, j in enumerate(activetrace[fromidx:toidx]) if j < crossval)
+        except StopIteration:
+            startidx = 0
+        startidx += fromidx
+        try:
+            riseidx = next(i for i, j in enumerate(activetrace[startidx:toidx]) if j >= crossval)
+        except StopIteration:
+            riseidx = toidx - startidx - 1
+        riseidx += startidx
+
+        # If not specified, 'keep' defaults to 'INSTANT'.
+        if 'keep' in measure:
+            keeptype = measure['keep']
+            if keeptype == 'BEFORE':
+                istart = 0
+                istop = riseidx
+            elif keeptype == 'AFTER':
+                istart = riseidx
+                istop = len(timevector)
+            else:
+                istart = riseidx
+                istop = riseidx + 1
+        else:
+            istart = riseidx
+            istop = riseidx + 1
+
+        for key in varresult:
+            vector = varresult[key]
+            varresult[key] = vector[istart:istop]
+
+        rsize = istop - istart
+        
+    elif calctype == 'FALLINGEDGE':
+        if timevector == []:
+            return
+
+        # FALLINGEDGE finds the time of a signal rising edge.
+        # parameters used are:
+        # 'from':   start time of search (default zero)
+        # 'to':     end time of search (default end)
+        # 'number': edge number (default first edge, or zero) (to be done)
+        # 'cross':  measure time when signal crosses this value
+        # 'keep':  determines what part of the vectors to keep
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+        if 'cross' in measure:
+            crossval = measure['cross']
+        else:
+            crossval = (max(activetrace) + min(activetrace)) / 2;
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+            toidx += 1
+        except StopIteration:
+            toidx = len(timevector)
+        try:
+            startidx = next(i for i, j in enumerate(activetrace[fromidx:toidx]) if j > crossval)
+        except StopIteration:
+            startidx = 0
+        startidx += fromidx
+        try:
+            fallidx = next(i for i, j in enumerate(activetrace[startidx:toidx]) if j <= crossval)
+        except StopIteration:
+            fallidx = toidx - startidx - 1
+        fallidx += startidx
+
+        # If not specified, 'keep' defaults to 'INSTANT'.
+        if 'keep' in measure:
+            keeptype = measure['keep']
+            if keeptype == 'BEFORE':
+                istart = 0
+                istop = fallidx
+            elif keeptype == 'AFTER':
+                istart = fallidx
+                istop = len(timevector)
+            else:
+                istart = fallidx
+                istop = fallidx + 1
+        else:
+            istart = fallidx
+            istop = fallidx + 1
+
+        for key in varresult:
+            vector = varresult[key]
+            varresult[key] = vector[istart:istop]
+
+        rsize = istop - istart
+
+    elif calctype == 'STABLETIME':
+        if timevector == []:
+            return
+
+        # STABLETIME finds the time at which the signal stabilizes
+        # parameters used are:
+        # 'from':  start time of search (default zero)
+        # 'to':    end time of search (works backwards from here) (default end)
+        # 'slope': measure time when signal rate of change equals this slope
+        # 'keep':  determines what part of the vectors to keep
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+        if 'limit' in measure:
+            limit = float(measure['limit'])
+        else:
+            # Default is 5% higher or lower than final value
+            limit = 0.05
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+        except StopIteration:
+            toidx = len(timevector) - 1
+        finalval = activetrace[toidx]
+        toidx += 1
+        highval = finalval * (1.0 + limit)
+        lowval = finalval * (1.0 - limit)
+        try:
+            breakidx = next(i for i, j in reversed(list(enumerate(activetrace[fromidx:toidx]))) if j >= highval or j <= lowval)
+        except StopIteration:
+            breakidx = 0
+        breakidx += fromidx
+
+        # If not specified, 'keep' defaults to 'INSTANT'.
+        if 'keep' in measure:
+            keeptype = measure['keep']
+            if keeptype == 'BEFORE':
+                istart = 0
+                istop = breakidx
+            elif keeptype == 'AFTER':
+                istart = breakidx
+                istop = len(timevector)
+            else:
+                istart = breakidx
+                istop = breakidx + 1
+        else:
+            istart = breakidx
+            istop = breakidx + 1
+
+        for key in varresult:
+            vector = varresult[key]
+            varresult[key] = vector[istart:istop]
+
+        rsize = istop - istart
+
+    elif calctype == 'INSIDE':
+        if timevector == []:
+            return
+
+        # INSIDE retains only values which are inside the indicated limits
+        # 'min':  minimum value limit to keep results
+        # 'max':  maximum value limit to keep results
+        if 'from' in measure:
+            fromtime = float(spice_unit_convert([timeunit, measure['from'], 'time']))
+        else:
+            fromtime = timevector[0] 
+        if 'to' in measure:
+            totime = float(spice_unit_convert([timeunit, measure['to'], 'time']))
+        else:
+            totime = timevector[-1]
+        if 'min' in measure:
+            minval = float(spice_unit_convert([activeunit, measure['min']]))
+        else:
+            minval = min(activetrace)
+        if 'max' in measure:
+            maxval = float(spice_unit_convert([activeunit, measure['max']]))
+        else:
+            maxval = max(activetrace)
+
+        try:
+            fromidx = next(i for i, j in enumerate(timevector) if j >= fromtime)
+        except StopIteration:
+            fromidx = len(timevector) - 1
+        try:
+            toidx = next(i for i, j in enumerate(timevector) if j >= totime)
+            toidx += 1
+        except StopIteration:
+            toidx = len(timevector)
+        goodidx = list(i for i, j in enumerate(activetrace[fromidx:toidx]) if j >= minval and j <= maxval)
+        # Diagnostic
+        if goodidx == []:
+            print('All vector components failed bounds test.  max = ' + str(max(activetrace[fromidx:toidx])) + '; min = ' + str(min(activetrace[fromidx:toidx])))
+
+        goodidx = [i + fromidx for i in goodidx]
+        for key in varresult:
+            vector = varresult[key]
+            varresult[key] = [vector[i] for i in goodidx]
+
+        rsize = len(goodidx)
+
+    return rsize
+
+def read_ascii_datafile(file, *args):
+    # Read a file of data produced by the 'wrdata' command in ngspice
+    # (simple ASCII data in columnar format)
+    # No unit conversions occur at this time.
+    #
+    # Arguments always include the analysis variable vector.  If additional
+    # arguments are present in "args", they are value vectors representing
+    # additional columns in the data file, and should be treated similarly
+    # to the analysis variable.  Note, however, that the wrdata format
+    # redundantly puts the analysis variable in every other column.
+
+    if not args:
+        print('Error:  testbench does not specify contents of data file!')
+        return
+
+    dmatrix = []
+    filepath = simfiles_path + '/' + file
+    if not os.path.isfile(filepath):
+        # Handle ngspice's stupid handling of file extensions for the argument
+        # passed to the 'wrdata' command, which sometimes adds the .data
+        # extension and sometimes doesn't, regardless of whether the argument
+        # has an extension or not.  Method here is to always include the
+        # extension in the argument, then look for possible ".data.data" files.
+        if os.path.isfile(filepath + '.data'):
+            filepath = filepath + '.data'
+        else:
+            return 0
+
+    with open(filepath, 'r') as afile:
+        for line in afile.readlines():
+            ldata = line.split()
+            if ldata:
+                # Note: dependent variable (e.g., TIME) is repeated
+                # every other column, so record this only once, then
+                # read the remainder while skipping every other column.
+                dvec = []
+                dvec.append(float(ldata[0]))
+                dvec.extend(list(map(float, ldata[1::2])))
+                dmatrix.append(dvec)
+
+        # Transpose dmatrix
+        try:
+            dmatrix = list(map(list, zip(*dmatrix)))
+        except TypeError:
+            print("last line data are " + str(ldata))
+            print("dmatrix is " + str(dmatrix))
+
+        for dvalues, dvec in zip(dmatrix, args):
+            dvec.extend(dvalues)
+
+        try:
+            rval = len(ldata[0])
+        except TypeError:
+            rval = 1
+        return rval
+
+if __name__ == '__main__':
+
+    # Exit in response to terminate signal by terminating ngspice processes
+    faulthandler.register(signal.SIGUSR2)
+    signal.signal(signal.SIGINT, cleanup_exit)
+    signal.signal(signal.SIGTERM, cleanup_exit)
+    options = []
+    arguments = []
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    # track the circuit score (for simulation;  layout handled separately)
+    # (initial score may be overridden by passing -score=value to cace_launch.py)
+    score = 0.0
+
+    # read the JSON file
+    keepmode = False
+    plotmode = False
+    postmode = True
+    if len(arguments) > 0:
+        inputfile = arguments[0]
+    else:
+        raise SyntaxError('Usage: ' + sys.argv[0] + ' json_file [-options]\n')
+
+    if os.path.splitext(inputfile)[1] != '.json':
+        raise SyntaxError('Usage: ' + sys.argv[0] + ' json_file [-options]\n')
+
+    for item in options:
+        result = item.split('=')
+        if result[0] == '-keep':
+            keepmode = True
+        elif result[0] == '-plot':
+            plotmode = True
+        elif result[0] == '-nosim':
+            # Diagnostic
+            print('No simulations specified. . . cace_launch exiting.\n')
+            sys.exit(0)
+        elif result[0] == '-nopost':
+            postmode = False
+            keepmode = True
+        elif result[0] == '-simdir':
+            simfiles_path = result[1]
+        elif result[0] == '-layoutdir':
+            layout_path = result[1]
+        elif result[0] == '-netlistdir':
+            netlist_path = result[1]
+        elif result[0] == '-rootdir':
+            root_path = result[1]
+        elif result[0] == '-local':
+            localmode = True
+            bypassmode = False
+            postmode = False
+            keepmode = False
+        elif result[0] == '-bypass':
+            bypassmode = True
+            localmode = True
+            postmode = True
+            keepmode = False
+        elif result[0] == '-score':
+            score = result[1]
+        else:
+            raise SyntaxError('Bad option ' + item + ', options are -keep, -nosim, -nopost, -local, and -simdir=\n')
+
+    # Various information could be obtained from the input JSON file
+    # name, but it will be assumed that all information should be
+    # obtained from the contents of the JSON file itself.
+
+    with open(inputfile) as ifile:
+       datatop = json.load(ifile)
+
+    # Option passing through the JSON:  use "nopost" or "keep" defined at the top level.
+    if 'nopost' in datatop:
+        postmode = False
+        datatop.pop('nopost')
+    if 'keep' in datatop:
+        keepmode = True
+        datatop.pop('keep')
+    if 'local' in datatop:
+        localmode = True
+        datatop.pop('local')
+
+    if 'request-hash' in datatop:
+        hashname = datatop['request-hash']
+    else:
+        print("Document JSON missing request-hash.")
+        sys.exit(1)
+
+    # Simfiles should be put in path specified by -simdir, or else
+    # put them in the working directory.  Normally "-simdir" will
+    # be given on the command line.
+
+    if not simfiles_path:
+        if root_path:
+            simfiles_path = root_path + '/' + hashname
+        else:
+            simfiles_path = og_config.simulation_path + '/' + hashname
+
+    if not os.path.isdir(simfiles_path):
+        print('Error:  Simulation folder ' + simfiles_path + ' does not exist.')
+        sys.exit(1)
+
+    if not layout_path:
+        if root_path:
+            layout_path = root_path + '/mag'
+
+    if not netlist_path:
+        if root_path:
+            netlist_path = root_path + '/spi'
+
+    # Change location to the simulation directory
+    os.chdir(simfiles_path)
+
+    # pull out the relevant part of the JSON file, which is "data-sheet"
+    dsheet = datatop['data-sheet']
+
+    # Prepare a dictionary for the status and pass critical values from datatop.
+    try:
+        statdoc['UID'] = datatop['UID']
+        statdoc['request-hash'] = datatop['request-hash']
+        if 'project-folder' in datatop:
+            statdoc['project'] = datatop['project-folder']
+        else:
+            statdoc['project'] = dsheet['ip-name']
+        status = {}
+        status['message'] = 'initializing'
+        status['completed'] = '0'
+        status['total'] = 'unknown'
+        status['hash'] = datatop['request-hash']
+        statdoc['status'] = status
+        if not localmode:
+            send_status(statdoc)
+    except KeyError:
+        if not localmode:
+            print("Failed to generate status record.")
+        else:
+            pass
+
+    # find the eparamlist.  If it exists, then the template has been
+    # loaded.  If not, find the template name, then load it from known
+    # templates.
+
+    if 'electrical-params' in dsheet:
+        eparamlist = dsheet['electrical-params']
+    else:
+        eparamlist = []
+    if 'physical-params' in dsheet:
+        pparamlist = dsheet['physical-params']
+    else:
+        pparamlist = []
+
+    if eparamlist == [] and pparamlist == []:
+        print('Circuit JSON file does not have a characterization template!')
+        sys.exit(0)
+
+    simulations = 0
+    has_aux_files = False
+
+    # Diagnostic:  find and print the number of files to be simulated
+    # Names are methodname, pinname, and simulation number.
+    totalsims = 0
+    filessimmed = []
+    for param in eparamlist:
+        if 'testbenches' in param:
+            totalsims += len(param['testbenches'])
+    print('Total files to simulate: ' + str(totalsims))
+
+    # Status
+    if statdoc and not localmode:
+        status['message'] = 'starting'
+        status['completed'] = '0'
+        status['total'] = str(totalsims)
+        send_status(statdoc)
+
+    for param in eparamlist:
+        # Process only entries in JSON that have 'testbenches' record
+        if 'testbenches' not in param:
+            continue
+
+        # Information needed to construct the filenames
+        simtype = param['method']
+
+        # For methods with ":", the filename is the part before the colon.
+        methodname = simtype.split(":")
+        if len(methodname) > 1:
+            testbench = methodname[0]
+            submethod = ":" + methodname[1]
+        else:
+            testbench = simtype
+            submethod = ""
+
+        # Simple outputs are followed by a single value
+        outrex = re.compile("[ \t]*\"?([^ \t\"]+)\"?(.*)$", re.IGNORECASE)
+        # conditions always follow as key=value pairs
+        dictrex = re.compile("[ \t]*([^ \t=]+)=([^ \t=]+)(.*)$", re.IGNORECASE)
+        # conditions specified as min:step:max match a result vector.
+        steprex = re.compile("[ \t]*([^:]+):([^:]+):([^:]+)$", re.IGNORECASE)
+        # specification of units as a binary, hex, etc., string in verilog format
+        binrex = re.compile(r'([0-9]*)\'([bodh])', re.IGNORECASE)
+
+        paramresult = []	# List of results
+        paramname = 'RESULT'	# Name of the result parameter (default 'RESULT')
+        condresult = {}		# Dictionary of condition names and values for each result
+        simfailures = 0		# Track simulations that don't generate results
+
+        # Run ngspice on each prepared simulation file
+        # FYI; ngspice generates output directly to the TTY, bypassing stdout
+        # and stdin, so that it can update the simulation time at the bottom
+        # of the screen without scrolling.  Subvert this in ngspice, if possible.
+        # It is a bad practice of ngspice to output to the TTY in batch mode. . .
+
+        testbenches = param['testbenches']
+        print('Files to simulate method ' + testbenches[0]['prefix'] + ': ' + str(len(testbenches)))
+
+        for testbench in testbenches:
+            filename = testbench['filename']
+            filessimmed.append(filename)
+            fileprefix = testbench['prefix']
+            # All output lines start with prefix
+            outrexall = re.compile(fileprefix + submethod + "[ \t]+=?[ \t]*(.+)$", re.IGNORECASE)
+            # "measure" statements act on results of individual simulations,
+            # so keep the results separate until after measurements have been made
+            locparamresult = []
+            loccondresult = {}
+            locvarresult = {}
+
+            # Cosimulation:  If there is a '.tv' file in the simulation directory
+            # with the same root name as the netlist file, then run iverilog and
+            # vvp.  vvp will call ngspice from the verilog.
+            verilog = os.path.splitext(filename)[0] + '.tv'
+            my_env = os.environ.copy()
+            if os.path.exists(verilog):
+                cosim = True
+                simulator = '/ef/apps/bin/vvp'
+                simargs = ['-M.', '-md_hdl_vpi']
+                filename = verilog + 'o'
+                # Copy the d_hdl object file into the simulation directory
+                shutil.copy('/ef/efabless/lib/iverilog/d_hdl_vpi.vpi', simfiles_path)
+                # Generate the output executable (.tvo) file for vvp.
+                subprocess.call(['/ef/apps/bin/iverilog', '-o' + filename, verilog])
+                # Specific version of ngspice must be used for cosimulation
+                # (Deprecated; default version of ngspice now supports cosimulation)
+                # my_env['NGSPICE_VERSION'] = 'cosim1'
+
+                # There must not be a file 'simulator_pipe' in the directory or vvp will fail.
+                if os.path.exists('simulator_pipe'):
+                    os.remove('simulator_pipe')
+            else:
+                cosim = False
+                simulator = '/ef/apps/bin/ngspice'
+                simargs = ['-b']
+                # Do not generate LXT files, as CACE does not have any methods to handle
+                # the data in them anyway.
+                my_env['NGSPICE_LXT2NO'] = '1'
+
+            # ngspice writes to both stdout and stderr;  capture all
+            # output equally.  Print each line in real-time, flush the
+            # output buffer, and then accumulate the lines for processing.
+
+            # Note:  bufsize = 1 and universal_newlines = True sets line-buffered output
+
+            print('Running: ' + simulator + ' ' + ' '.join(simargs) + ' ' + filename)
+
+            with subprocess.Popen([simulator, *simargs, filename],
+			stdout=subprocess.PIPE,
+			bufsize=1, universal_newlines=True, env=my_env) as spiceproc:
+                for line in spiceproc.stdout:
+                    print(line, end='')
+                    sys.stdout.flush()
+
+                    # Each netlist can have as many results as there are in
+                    # the "measurements" list for the electrical parameter,
+                    # grouped according to common testbench netlist file and
+                    # common set of conditions.
+
+                    matchline = outrexall.match(line)
+                    if matchline:
+                        # Divide result into tokens.  Space-separated values in quotes
+                        # become a result vector;  all other entries should be in the
+                        # form <key>=<value>.  Result value becomes "result":[<vector>]
+                        # dictionary entry.
+                        rest = matchline.group(1)
+
+                        # ASCII file format handling:  Data are in the indicated
+                        # file in pairs of analysis variable (e.g., TIME for transients)
+                        # and 'result'.  Note that the analysis variable is
+                        # always the first and every other column of the data file.
+                        # The primary result is implicit.  All other columns
+                        # must be explicitly called out on the echo line.
+                        if '.data' in rest:
+                            print('Reading data from ASCII file.')
+
+                            # "variables" are similar to conditions but describe what is
+                            # being output from ngspice.  There should be one entry for
+                            # each (unique) column in the data file, matching the names
+                            # given in the testbench file.
+
+                            if 'variables' in param:
+                                pvars = param['variables']
+                                # Convert any old-style condition, pin
+                                for var in pvars:
+                                    if 'pin' in var:
+                                        if not ':' in var['condition']:
+                                            var['condition'] += ':' + var['pin']
+                                        var.pop('pin')
+                            else:
+                                pvars = []
+
+                            # Parse all additional variables.  At least one (the
+                            # analysis variable) must be specified.
+                            data_args = []
+                            extra = rest.split()
+
+                            if len(extra) == 1:
+                                # If the testbench specifies no vectors, then they
+                                # must all be specified in order in 'variables' in
+                                # the datasheet entry for the electrical parameters.
+                                for var in pvars:
+                                    extra.append(var['condition'])
+                                if not pvars:
+                                    print('Error:  No variables specified in testbench or datasheet.')
+                                    rest = ''
+
+                            if len(extra) > 1:
+                                for varname in extra[1:]:
+                                    if varname not in locvarresult:
+                                        locvarresult[varname] = []
+                                    data_args.append(locvarresult[varname])
+
+                                rsize = read_ascii_datafile(extra[0], *data_args)
+
+                                # All values in extra[1:] should be param['variables'].  If not, add
+                                # an entry and flag a warning because information may be incomplete.
+
+                                for varname in extra[1:]:
+                                    try:
+                                        var = next(item for item in pvars if item['condition'] == varname)
+                                    except StopIteration:
+                                        print('Variable ' + varname + ' not specified;  ', end='')
+                                        print('information may be incomplete.')
+                                        var = {}
+                                        var['condition'] = varname
+                                        pvars.append(var)                                    
+
+                                # By default, the 2nd result is the result
+                                if len(extra) > 2:
+                                    varname = extra[2]
+                                    varrec = next(item for item in pvars if item['condition'] == varname)
+                                    varrec['result'] = True
+                                    print('Setting condition ' + varname + ' as the result vector.')
+
+                                # "measure" records are applied to individual simulation outputs,
+                                # usually to reduce a time-based vector to a single value by
+                                # measuring a steady-state value, peak-peak, frequency, etc.
+
+                                if 'measure' in param:
+                                    # Diagnostic
+                                    # print('Applying measurements.')
+
+                                    for measure in param['measure']:
+                                        # Convert any old-style condition, pin
+                                        if 'pin' in measure:
+                                            if not ':' in measure['condition']:
+                                                measure['condition'] += ':' + measure['pin']
+                                            measure.pop('pin')
+                                        rsize = apply_measure(locvarresult, measure, pvars)
+                                        # Diagnostic
+                                        # print("after measure, rsize = " + str(rsize))
+                                        # print("locvarresult = " + str(locvarresult))
+    
+                                    # Now recast locvarresult back into loccondresult.
+                                    for varname in locvarresult:
+                                        varrec = next(item for item in pvars if item['condition'] == varname)
+                                        if 'result' in varrec:
+                                            # print('Result for ' + varname + ' = ' + str(locvarresult[varname]))
+                                            locparamresult = locvarresult[varname]
+                                            paramname = varname
+                                        else:
+                                            # print('Condition ' + varname + ' = ' + str(locvarresult[varname]))
+                                            loccondresult[varname] = locvarresult[varname]
+                                        # Diagnostic
+                                        # print("Variable " + varname + " length = " + str(len(locvarresult[varname])))
+                                    rest = ''
+
+                                else:
+                                    # For plots, there is not necessarily any measurements.  Just
+                                    # copy values into locparamresult and loccondresult.
+                                    for varname in locvarresult:
+                                        varrec = next(item for item in pvars if item['condition'] == varname)
+                                        if 'result' in varrec:
+                                            # print('Result for ' + varname + ' = ' + str(locvarresult[varname]))
+                                            locparamresult = locvarresult[varname]
+                                            rsize = len(locparamresult)
+                                            paramname = varname
+                                        else:
+                                            # print('Condition ' + varname + ' = ' + str(locvarresult[varname]))
+                                            loccondresult[varname] = locvarresult[varname]
+                                    rest = ''
+                        else:
+                            rsize = 0
+
+                        # To-do:  Handle raw files in similar manner to ASCII files.
+                          
+                        while rest:
+                            # This code depends on values coming first, followed by conditions.
+                            matchtext = dictrex.match(rest)
+                            if matchtext:
+                                # Diagnostic!
+                                condname = matchtext.group(1)
+                                # Append to the condition list
+                                if condname not in loccondresult:
+                                    loccondresult[condname] = []
+
+                                # Find the condition name in the condition list, so values can
+                                # be converted back to the expected units.
+                                try:
+                                    condrec = next(item for item in param['conditions'] if item['condition'] == condname)
+                                except StopIteration:
+                                    condunit = ''
+                                else:
+                                    condunit = condrec['unit']
+
+                                rest = matchtext.group(3)
+                                matchstep = steprex.match(matchtext.group(2))
+                                if matchstep:
+                                    # condition is in form min:step:max, and the
+                                    # number of values must match rsize.
+                                    cmin = float(matchstep.group(1))
+                                    cstep = float(matchstep.group(2))
+                                    cmax = float(matchstep.group(3))
+                                    cnum = int(round((cmax + cstep - cmin) / cstep))
+                                    if cnum != rsize:
+                                        print("Warning: Number of conditions (" + str(cnum) + ") is not")
+                                        print("equal to the number of results (" + str(rsize) + ")")
+                                        # Back-calculate the correct step size.  Usually this
+                                        # means that the testbench did not add margin to the
+                                        # DC or AC stop condition, and the steps fell 1 short of
+                                        # the max.
+                                        if rsize > 1:
+                                            cstep = (float(cmax) - float(cmin)) / float(rsize - 1)
+
+                                    condvec = []
+                                    for r in range(rsize):
+                                        condvec.append(cmin)
+                                        cmin += cstep
+
+                                    cresult = spice_unit_unconvert([condunit, condvec])
+                                    condval = loccondresult[condname]
+                                    for cr in cresult:
+                                        condval.append(str(cr))
+
+                                else:
+                                    # If there is a vector of results but only one condition, copy the
+                                    # condition for each result.  Note that value may not be numeric.
+
+                                    # (To do:  Apply 'measure' records here)
+                                    condval = loccondresult[condname]
+                                    try:
+                                        test = float(matchtext.group(2))
+                                    except ValueError:
+                                        cval = matchtext.group(2)
+                                    else:
+                                        cval = str(spice_unit_unconvert([condunit, test]))
+                                    for r in range(rsize):
+                                        condval.append(cval)
+                            else:
+                                # Not a key=value pair, so must be a result value
+                                matchtext = outrex.match(rest)
+                                if matchtext:
+                                    rest = matchtext.group(2)
+                                    rsize += 1
+                                    # Result value units come directly from the param record.
+                                    if 'unit' in param:
+                                        condunit = param['unit']
+                                    else:
+                                        condunit = ''
+                                    if binrex.match(condunit):
+                                        # Digital result with units 'b, 'h, etc. are kept as strings.
+                                        locparamresult.append(matchtext.group(1))
+                                    else:
+                                        locparamresult.append(float(matchtext.group(1)))
+                                else:
+                                    print('Error:  Result line cannot be parsed.')
+                                    print('Bad part of line is: ' + rest)
+                                    print('Full line is: ' + line)
+                                    break
+
+                        # Values passed in testbench['conditions'] are common to each result
+                        # value.  From one line there are rsize values, so append each known
+                        # condition to loccondresult rsize times.
+                        for condrec in testbench['conditions']:
+                            condname = condrec[0]
+                            if condname in locvarresult:
+                                print('Error:  name ' + condname + ' is both a variable and a condition!')
+                                print('Ignoring the condition.')
+                                continue
+                            if condname not in loccondresult:
+                                loccondresult[condname] = []
+                            condval = loccondresult[condname]
+                            if 'unit' in condrec:
+                                condunit = condrec['unit']
+                            else:
+                                condunit = ''
+                            for r in range(rsize):
+                                if condname.split(':')[0] == 'DIGITAL' or condname == 'CORNER':
+                                    # Values that are known to be strings
+                                    condval.append(condrec[2])
+                                elif binrex.match(condunit):
+                                    # Alternate digital specification using units 'b, 'h, etc.
+                                    condval.append(condrec[2])
+                                elif condname == 'ITERATIONS':
+                                    # Values that are known to be integers
+                                    condval.append(int(float(condrec[2])))
+                                else:
+                                    # All other values to be treated as floats unless
+                                    # they are non-numeric, in which case they are
+                                    # treated as strings and copied as-is.
+                                    try:
+                                        condval.append(float(condrec[2]))
+                                    except ValueError:
+                                        # Values that are not numeric just get copied
+                                        condval.append(condrec[2])
+
+                spiceproc.stdout.close()
+                return_code = spiceproc.wait()
+                if return_code != 0:
+                    raise subprocess.CalledProcessError(return_code, 'ngspice')
+
+                if len(locparamresult) > 0:
+                    # Fold local results into total results
+                    paramresult.extend(locparamresult)
+                    for key in loccondresult:
+                        if not key in condresult:
+                            condresult[key] = loccondresult[key]
+                        else:
+                            condresult[key].extend(loccondresult[key])
+
+                else:
+                    # Catch simulation failures
+                    simfailures += 1
+
+            simulations += 1
+
+            # Clean up pipe file after cosimulation, also the .lxt file and .tvo files
+            if cosim:
+                if os.path.exists('simulator_pipe'):
+                    os.remove('simulator_pipe')
+                # Remove all '.tvo', '.lxt', and '.vcd' files from the work area.
+                if keepmode == False:
+                    files = os.listdir(simfiles_path)
+                    for filename in files:
+                        try:
+                            fileext = os.path.splitext(filename)[1]
+                        except:
+                            pass
+                        else:
+                            if fileext == '.lxt' or fileext == '.vcd' or fileext == '.tvo' or fileext == '.vpi':
+                                os.remove(filename)
+               
+
+            # Other files to clean up
+            if os.path.exists('b3v32check.log'):
+                os.remove('b3v32check.log')
+
+            # Status
+            if statdoc and not localmode:
+                if simulations < totalsims:
+                    status['message'] = 'in progress'
+                else:
+                    status['message'] = 'completed'
+                status['completed'] = str(simulations)
+                status['total'] = str(totalsims)
+                send_status(statdoc)
+
+        # Evaluate concatentated results after all files for this electrical parameter
+        # have been run through simulation.
+
+        if paramresult:
+            print(simtype + ':')
+
+            # Diagnostic
+            # print("paramresult length " + str(len(paramresult)))
+            # for key in condresult:
+            #     print("condresult length " + str(len(condresult[key])))
+
+            # Write out all results into the JSON file.
+            # Results are a list of lists;  the first list is a list of
+            # methods, and the rest are sets of values corresponding to unique
+            # conditions.  The first item in each lists is the result value
+            # for that set of conditions.
+
+            # Always keep results, even for remote CACE.
+
+            outnames = [paramname]
+            outunits = []
+
+            if 'unit' in param:
+                outunits.append(param['unit'])
+            else:
+                outunits.append('')
+            for key in condresult:
+                outnames.append(key)
+                try:
+                    condrec = next(item for item in param['conditions'] if item['condition'] == key) 
+                except:
+                    try:
+                        condrec = next(item for item in param['variables'] if item['condition'] == key) 
+                    except:
+                        outunits.append('')
+                    else:
+                        if 'unit' in condrec:
+                            outunits.append(condrec['unit'])
+                            # 'variable' entries need to be unconverted
+                            cconv = spice_unit_unconvert([condrec['unit'], condresult[key]])
+                            condresult[key] = cconv
+                        else:
+                            outunits.append('')
+                else:
+                    if 'unit' in condrec:
+                        outunits.append(condrec['unit'])
+                    else:
+                        outunits.append('')
+
+            # Evaluate a script to transform the output, if there is an 'evaluate'
+            # record in the electrical parameter.
+
+            if 'evaluate' in param:
+
+                evalrec = param['evaluate']
+                try:
+                    tool = evalrec['tool']
+                except:
+                    print("Error:  Evaluate record does not indicate a tool to run.")
+                    break
+                else:
+                    if tool != 'octave' and tool != 'matlab':
+                        print("Error:  CASE does not know how to use tool '" + tool + "'")
+                        break
+
+                try:
+                    script = evalrec['script']
+                except:
+                    print("Error:  Evaluate record does not indicate a script to run.")
+                    break
+                else:
+                    if os.path.isdir(root_path + '/testbench'):
+                        tb_path = root_path + '/testbench/' + script
+                        if not os.path.exists(tb_path):
+                            if os.path.exists(tb_path + '.m'):
+                                tb_path += '.m'
+                            else:
+                                print("Error:  No script '" + script + "' found in testbench path.")
+                                break
+                    else:
+                        print("Error:  testbench directory not found in root path.")
+                        break
+
+                # General purpose tool-based evaluation.  For complex operations of
+                # any kind, dump the simulation results to a file "results.json" and
+                # invoke the specified tool, which should read the results and
+                # generate an output in the form of modified 'paramresult'.
+                # e.g., input is an array of transient vectors, output is an FFT
+                # analysis.  Input is a voltage, output is an INL value.  Note that
+                # 'unit' is the unit produced by the script.  The script is supposed
+                # to know what units it gets as input and what it produces as output.
+
+                # Create octave-compatible output with structures for the condition
+                # names, units, and data.
+                with open('results.dat', 'w') as ofile:
+                    print('# Created by cace_gensim.py', file=ofile)
+                    print('# name: results', file=ofile)
+                    print('# type: scalar struct', file=ofile)
+                    print('# ndims: 2', file=ofile)
+                    print('# 1 1', file=ofile)
+                    numentries = len(outnames)
+                    print('# length: ' + str(2 + numentries), file=ofile)
+                    print('# name: NAMES', file=ofile)
+                    print('# type: cell', file=ofile)
+                    print('# rows: ' + str(numentries), file=ofile)
+                    print('# columns: 1', file=ofile)
+                    for name in outnames:
+                        print('# name: <cell-element>', file=ofile)
+                        print('# type: sq_string', file=ofile)
+                        print('# elements: 1', file=ofile)
+                        print('# length: ' + str(len(name)), file=ofile)
+                        print(name, file=ofile)
+                        print('', file=ofile)
+                        print('', file=ofile)
+
+                    print('', file=ofile)
+                    print('', file=ofile)
+                    print('# name: UNITS', file=ofile)
+                    print('# type: cell', file=ofile)
+                    print('# rows: ' + str(len(outunits)), file=ofile)
+                    print('# columns: 1', file=ofile)
+                    for unit in outunits:
+                        print('# name: <cell-element>', file=ofile)
+                        print('# type: sq_string', file=ofile)
+                        print('# elements: 1', file=ofile)
+                        print('# length: ' + str(len(unit)), file=ofile)
+                        print(unit, file=ofile)
+                        print('', file=ofile)
+                        print('', file=ofile)
+                    print('', file=ofile)
+                    print('', file=ofile)
+
+                    # Each condition is output as a 1D array with structure
+                    # entry name equal to the condition name.  If the units
+                    # is empty then the array is a string.  Otherwise, the
+                    # array is numeric (as far as octave is concerned).
+
+                    # First entry is the result (paramresult).  This should never
+                    # be a string (at least not in this version of CACE)
+
+                    idx = 0
+                    print('# name: ' + outnames[idx], file=ofile)
+                    units = outunits[idx]
+                    print('# type: matrix', file=ofile)
+                    print('# rows: ' + str(len(paramresult)), file=ofile)
+                    print('# columns: 1', file=ofile)
+                    for value in paramresult:
+                        print(' ' + str(value), file=ofile)
+                    print('', file=ofile)
+                    print('', file=ofile)
+
+                    idx += 1
+                    # The rest of the entries are the conditions.  Note that the
+                    # name must be a valid octave variable (letters, numbers,
+                    # underscores) and so cannot use the condition name.  However,
+                    # each condition name is held in the names list, so it can be
+                    # recovered.  Each condition is called CONDITION2, CONDITION3,
+                    # etc.
+
+                    for key, entry in condresult.items():
+
+                        print('# name: CONDITION' + str(idx + 1), file=ofile)
+                        units = outunits[idx]
+                        if units == '':
+                            # Use cell array for strings
+                            print('# type: cell', file=ofile)
+                            print('# rows: ' + str(len(entry)), file=ofile)
+                            print('# columns: 1', file=ofile)
+                            for value in entry:
+                                print('# name: <cell-element>', file=ofile)
+                                print('# type: sq_string', file=ofile)
+                                print('# elements: 1', file=ofile)
+                                print('# length: ' + str(len(str(value))), file=ofile)
+                                print(str(value), file=ofile)
+                                print('', file=ofile)
+                                print('', file=ofile)
+                        else:
+                            print('# type: matrix', file=ofile)
+                            print('# rows: ' + str(len(entry)), file=ofile)
+                            print('# columns: 1', file=ofile)
+                            for value in entry:
+                                print(' ' + str(value), file=ofile)
+
+                        print('', file=ofile)
+                        print('', file=ofile)
+                        idx += 1
+
+                # Now run the specified octave script on the result.  Script
+                # generates an output file.  stdout/stderr can be ignored.
+                # May want to watch stderr for error messages and/or handle
+                # exit status.
+
+                postproc = subprocess.Popen(['/ef/apps/bin/octave-cli', tb_path],
+			stdout = subprocess.PIPE)
+                rvalues = postproc.communicate()[0].decode('ascii').splitlines()
+
+                # Replace paramresult with the numeric result
+                paramresult = list(float(item) for item in rvalues)
+
+            # pconv is paramresult scaled to the units used by param.
+            if 'unit' in param:
+                pconv = spice_unit_unconvert([param['unit'], paramresult])
+            else:
+                pconv = paramresult
+
+            outresult = []
+            outresult.append(outnames)
+            outresult.append(outunits)
+
+            for p in range(len(pconv)):
+                outvalues = []
+                outvalues.append(str(pconv[p]))
+                for key, value in condresult.items():
+                    try:
+                        outvalues.append(str(value[p]))
+                    except IndexError:
+                        # Note:  This should not happen. . . 
+                        print("Error:  number of values in result and conditions do not match!")
+                        print("Result: " + str(len(pconv)))
+                        print("Conditions: " + str(len(condresult)))
+                        break
+
+                outresult.append(outvalues)
+
+            param['results'] = outresult
+
+            if 'unit' in param:
+                units = param['unit']
+            else:
+                units = ''
+
+            # Catch simulation failures.
+            if simfailures > 0:
+                print('Simulation failures:  ' + str(simfailures))
+                score = 'fail'
+
+            if 'min' in param:
+                minrec = param['min']
+                if 'calc' in minrec:
+                    calc = minrec['calc']
+                else:
+                    calc = 'min-above'
+                minscore = calculate(minrec, pconv, condresult, calc, score, units, param)
+                if score != 'fail':
+                    score = minscore
+
+            if 'max' in param:
+                maxrec = param['max']
+                if 'calc' in maxrec:
+                    calc = maxrec['calc']
+                else:
+                    calc = 'max-below'
+                maxscore = calculate(maxrec, pconv, condresult, calc, score, units, param)
+                if score != 'fail':
+                    score = maxscore
+
+            if 'typ' in param:
+                typrec = param['typ']
+                if 'calc' in typrec:
+                    calc = typrec['calc']
+                else:
+                    calc = 'avg-legacy'
+                typscore = calculate(typrec, pconv, condresult, calc, score, units, param)
+                if score != 'fail':
+                    score = typscore
+
+            if 'plot' in param:
+                # If not in localmode, or if in plotmode then create a plot and
+                # save it to a file.
+                plotrec = param['plot']
+                if localmode == False or bypassmode == True or plotmode == True:
+                    if 'variables' in param:
+                        variables = param['variables']
+                    else:
+                        variables = []
+                    result = cace_makeplot.makeplot(plotrec, param['results'], variables)
+                    # New behavior implemented 3/28/2017:  Always keep results.
+                    # param.pop('results')
+                    if result:
+                        plotrec['status'] = 'done'
+                        has_aux_files = True
+                    else:
+                        print('Failure:  No plot from file ' + filename + '\n')
+                else:
+                    plotrec['status'] = 'done'
+        else:
+            try:
+                print('Failure:  No output from file ' + filename + '\n')
+            except NameError:
+                print('Failure:  No simulation file, so no output\n')
+                continue
+
+            # Handle errors where simulation generated no output.
+            # This is the one case where 'typ' can be treated as pass-fail.
+            # "score" will be set to "fail" for any of "min", "max", and
+            # "typ" that exists in the electrical parameters record and
+            # which specifies a target value.  "value" is set to "failure"
+            # for display.
+            score = 'fail'
+            if 'typ' in param:
+                typrec = param['typ']
+                if 'target' in typrec:
+                    typrec['score'] = 'fail'
+                typrec['value'] = 'failure'
+            if 'max' in param:
+                maxrec = param['max']
+                if 'target' in maxrec:
+                    maxrec['score'] = 'fail'
+                maxrec['value'] = 'failure'
+            if 'min' in param:
+                minrec = param['min']
+                if 'target' in minrec:
+                    minrec['score'] = 'fail'
+                minrec['value'] = 'failure'
+
+        # Pop the testbenches record, which has been replaced by the 'results' record.
+        param.pop('testbenches')
+
+        # Final cleanup step:  Remove any remaining '.tv' files from the work area.
+        if keepmode == False:
+            files = os.listdir(simfiles_path)
+            for filename in files:
+                try:
+                    fileext = os.path.splitext(filename)[1]
+                except:
+                    pass
+                else:
+                    if fileext == '.tv':
+                        os.remove(filename)
+
+    # Report the final score, and save it to the JSON data
+
+    print('Completed ' + str(simulations) + ' of ' + str(totalsims) + ' simulations');
+    print('Circuit pre-extraction simulation total score (lower is better) = '
+			+ str(score))
+
+    if score == 'fail':
+        dsheet['score'] = 'fail'
+    else:
+        dsheet['score'] = '{0:.4g}'.format(score)
+
+    # Now handle physical parameters
+    netlist_source = dsheet['netlist-source']
+    areaval = 0.0
+
+    totalchecks = 0
+    for param in pparamlist:
+        if 'check' in param:
+            totalchecks += 1
+    print('Total physical parameters to check: ' + str(totalchecks))
+
+    for param in pparamlist:
+        # Process only entries in JSON that have the 'check' record
+        if 'check' not in param:
+            continue
+        if param['check'] != 'true':
+            continue
+
+        cond = param['condition']
+
+        if cond == 'device_area':
+            areaest = 0
+            ipname = dsheet['ip-name']
+            foundry = dsheet['foundry']
+            node = dsheet['node']
+            # Hack---node XH035 should have been specified as EFXH035A; allow
+            # the original one for backwards compatibility.
+            if node == 'XH035':
+                node = 'EFXH035A'
+
+            if layout_path and netlist_path:
+
+                # Run the device area (area estimation) script
+                if os.path.exists(netlist_path + '/' + ipname + '.spi'):
+                    estproc = subprocess.Popen(['/ef/efabless/bin/layout_estimate.py',
+				netlist_path + '/' + ipname + '.spi', node.lower()],
+				stdout=subprocess.PIPE,
+				cwd = layout_path, universal_newlines = True)
+                    outlines = estproc.communicate()[0]
+                    arealine = re.compile('.*=[ \t]*([0-9]+)[ \t]*um\^2')
+                    for line in outlines.splitlines():
+                        lmatch = arealine.match(line)
+                        if lmatch:
+                            areaum2 = lmatch.group(1)
+                            areaest = int(areaum2)
+
+            if areaest > 0:
+                score = 'pass'
+                maxrec = param['max']
+                targarea = float(maxrec['target'])
+                maxrec['value'] = str(areaest)
+                if 'penalty' in maxrec:
+                    if maxrec['penalty'] == 'fail':
+                        if areaest > targarea:
+                            score = 'fail'
+                        else:
+                            score = 'pass'
+                    else:
+                        try:
+                            if areaest > targarea:
+                                score = str((areaest - targarea) * float(maxrec['penalty']))
+                            else:
+                                score = 'pass'
+                        except:
+                            if areaest > targarea:
+                                score = maxrec['penalty']
+                            else:
+                                score = 'pass'
+                else:
+                    score = 'pass'
+                maxrec['score'] = score
+
+        if cond == 'area' or cond == 'height' or cond == 'width':
+
+            # First time for any of these, run the check and get values
+
+            if areaval == 0 and not netlist_source == 'schematic':
+
+                ipname = dsheet['ip-name']
+                foundry = dsheet['foundry']
+                node = dsheet['node']
+                # Hack---node XH035 should have been specified as EFXH035A; allow
+                # the original one for backwards compatibility.
+                if node == 'XH035':
+                    node = 'EFXH035A'
+
+                if layout_path:
+
+                    # Find the layout directory and check if there is a layout
+                    # for the cell there.  If not, use the layout estimation
+                    # script.  Result is either an actual area or an area estimate.
+
+                    if os.path.exists(layout_path + '/' + ipname + '.mag'):
+                        areaproc = subprocess.Popen(['/ef/apps/bin/magic',
+				'-dnull', '-noconsole', layout_path + '/' + ipname + '.mag'],
+				stdin = subprocess.PIPE, stdout = subprocess.PIPE,
+				cwd = layout_path, universal_newlines = True)
+                        areaproc.stdin.write("select top cell\n")
+                        areaproc.stdin.write("box\n")
+                        areaproc.stdin.write("quit -noprompt\n")
+                        outlines = areaproc.communicate()[0]
+                        magrex = re.compile('microns:[ \t]+([0-9.]+)[ \t]*x[ \t]*([0-9.]+)[ \t]+.*[ \t]+([0-9.]+)[ \t]*$')
+                        for line in outlines.splitlines():
+                            lmatch = magrex.match(line)
+                            if lmatch:
+                                widthval = float(lmatch.group(1))
+                                heightval = float(lmatch.group(2))
+                                areaval = float(lmatch.group(3))
+
+                if areaval > 0:
+
+                    # Now work through the physical parameters --- pass 1
+                    # If area was estimated, then find target width and height
+                    # for estimating actual width and height.
+
+                    for checkparam in dsheet['physical-params']:
+                        checkcond = checkparam['condition']
+                        maxrec = checkparam['max']
+                        if checkcond == 'area':
+                            targarea = float(maxrec['target'])
+                        elif checkcond == 'width':
+                            targwidth = float(maxrec['target'])
+                        elif checkcond == 'height':
+                            targheight = float(maxrec['target'])
+
+            maxrec = param['max']
+            unit = param['unit']
+
+            if cond == 'area':
+                if areaval > 0:
+                    maxrec['value'] = str(areaval)
+                    if areaval > targarea:
+                        score = 'fail'
+                        maxrec['score'] = 'fail'
+                    else:
+                        maxrec['score'] = 'pass'
+            elif cond == 'width':
+                if areaval > 0:
+                    maxrec['value'] = str(widthval)
+                    if widthval > targwidth:
+                        score = 'fail'
+                        maxrec['score'] = 'fail'
+                    else:
+                        maxrec['score'] = 'pass'
+
+            elif cond == 'height':
+                if areaval > 0:
+                    maxrec['value'] = str(heightval)
+                    if heightval > targheight:
+                        score = 'fail'
+                        maxrec['score'] = 'fail'
+                    else:
+                        maxrec['score'] = 'pass'
+
+        elif cond == 'DRC_errors':
+
+            ipname = dsheet['ip-name']
+
+            if layout_path and not netlist_source == 'schematic':
+                if os.path.exists(layout_path + '/' + ipname + '.mag'):
+
+                    # Find the layout directory and check if there is a layout
+                    # for the cell there.
+
+                    areaproc = subprocess.Popen(['/ef/apps/bin/magic',
+				'-dnull', '-noconsole', layout_path + '/' + ipname + '.mag'],
+				stdin = subprocess.PIPE, stdout = subprocess.PIPE,
+				cwd = layout_path, universal_newlines = True)
+                    areaproc.stdin.write("drc on\n")
+                    areaproc.stdin.write("select top cell\n")
+                    areaproc.stdin.write("drc check\n")
+                    areaproc.stdin.write("drc catchup\n")
+                    areaproc.stdin.write("set dcount [drc list count total]\n")
+                    areaproc.stdin.write("puts stdout \"drc = $dcount\"\n")
+                    outlines = areaproc.communicate()[0]
+                    magrex = re.compile('drc[ \t]+=[ \t]+([0-9.]+)[ \t]*$')
+                    for line in outlines.splitlines():
+                        # Diagnostic
+                        print(line)
+                        lmatch = magrex.match(line)
+                        if lmatch:
+                            drccount = int(lmatch.group(1))
+                            maxrec = param['max']
+                            maxrec['value'] = str(drccount)
+                            if drccount > 0:
+                                maxrec['score'] = 'fail'
+                            else:
+                                maxrec['score'] = 'pass'
+
+        # Check on LVS from comp.out file (must be more recent than both netlists)
+        elif cond == 'LVS_errors':
+            ipname = dsheet['ip-name']
+            foundry = dsheet['foundry']
+            node = dsheet['node']
+            # Hack---node XH035 should have been specified as EFXH035A; allow
+            # the original one for backwards compatibility.
+            if node == 'XH035':
+                node = 'EFXH035A'
+
+            # To do even a precheck, the layout path must exist and must be populated
+            # with the .magicrc file.
+            if not os.path.exists(layout_path):
+                os.makedirs(layout_path)
+            if not os.path.exists(layout_path + '/.magicrc'):
+                pdkdir = '/ef/tech/' + foundry + '/' + node + '/libs.tech/magic/current'
+                if os.path.exists(pdkdir + '/' + node + '.magicrc'):
+                    shutil.copy(pdkdir + '/' + node + '.magicrc', layout_path + '/.magicrc')
+
+            # Netlists should have been generated by cace_gensim.py
+            has_layout_nl = os.path.exists(netlist_path + '/lvs/' + ipname + '.spi')
+            has_schem_nl = os.path.exists(netlist_path + '/' + ipname + '.spi')
+            has_vlog_nl = os.path.exists(root_path + '/verilog/' + ipname + '.v')
+            has_stub_nl = os.path.exists(netlist_path + '/stub/' + ipname + '.spi')
+            if has_layout_nl and has_stub_nl and not netlist_source == 'schematic':
+                failures = run_and_analyze_lvs(dsheet)
+            elif has_layout_nl and has_vlog_nl and not netlist_source == 'schematic':
+                failures = run_and_analyze_lvs(dsheet)
+            elif netlist_path and has_schem_nl:
+                if not has_layout_nl or not has_stub_nl:
+                    if not has_layout_nl:
+                        print("Did not find layout LVS netlist " + netlist_path + '/lvs/' + ipname + '.spi')
+                    if not has_stub_nl:
+                        print("Did not find schematic LVS netlist " + netlist_path + '/' + ipname + '.spi')
+                print("Running layout device pre-check.")
+                if localmode == True:
+                    if keepmode == True:
+                        precheck_opts = ['-log', '-debug']
+                    else:
+                        precheck_opts = ['-log']
+                    print('/ef/efabless/bin/layout_precheck.py ' + netlist_path + '/' + ipname + '.spi ' + node.lower() + ' ' + ' '.join(precheck_opts))
+                    chkproc = subprocess.Popen(['/ef/efabless/bin/layout_precheck.py',
+				netlist_path + '/' + ipname + '.spi', node.lower(), *precheck_opts],
+				stdout=subprocess.PIPE,
+				cwd = layout_path, universal_newlines = True)
+                else:
+                    chkproc = subprocess.Popen(['/ef/efabless/bin/layout_precheck.py',
+				netlist_path + '/' + ipname + '.spi', node.lower()],
+				stdout=subprocess.PIPE,
+				cwd = layout_path, universal_newlines = True)
+                outlines = chkproc.communicate()[0]
+                failline = re.compile('.*=[ \t]*([0-9]+)[ \t]*')
+                for line in outlines.splitlines():
+                    lmatch = failline.match(line)
+                    if lmatch:
+                        failures = int(lmatch.group(1))
+            else:
+                failures = -1
+
+            if failures >= 0:
+                maxrec = param['max']
+                maxrec['value'] = str(failures)
+                if failures > int(maxrec['target']):
+                    score = 'fail'
+                    maxrec['score'] = 'fail'
+                else:
+                    maxrec['score'] = 'pass'
+
+        # Pop the 'check' record, which has been replaced by the 'value' record.
+        param.pop('check')
+
+    # Remove 'project-folder' from document if it exists, as this document
+    # is no longer related to an Open Galaxy account.
+    if 'project-folder' in datatop:
+        datatop.pop('project-folder')
+
+    # Write the annotated JSON file (NOTE:  In the absence of further
+    # processing on the CACE side, this file is just getting deleted
+    # right after it's made.  But the file appears to be correctly
+    # pushed back to the marketplace server, so this can be removed.
+
+    filem = os.path.splitext(inputfile)
+    if filem[1]:
+        outputfile = filem[0] + '_anno' + filem[1]
+    else:
+        outputfile = inputfile + '_anno.json'
+
+    with open(outputfile, 'w') as ofile:
+        json.dump(datatop, ofile, indent = 4)
+
+    # Create tarball of auxiliary files and send them as well.
+    # Note that the files themselves are tarballed, not the directory
+
+    if has_aux_files:
+        tar = file_compressor.tar_directory_contents(simfiles_path + '/simulation_files')
+        if 'ip-name' in dsheet:
+            tarballname = dsheet['ip-name'] + '_result_files.tar.gz'
+        else:
+            tarballname = 'result_files.tar.gz'
+
+    # In addition to dumping the file locally, also send back to the
+    # marketplace, along with the tarball of simulation-generated files.
+    if postmode == True:
+        send_doc(datatop)
+        if has_aux_files:
+            send_file(hashname, tar, tarballname)
+    else:
+        print('Posting to marketplace was disabled by -nopost\n')
+
+    # Clean up by removing simulation directory
+    if keepmode == False:
+        if localmode == True:
+            print('Simulation results retained per -local option\n')
+            # If cace_gensim and cace_launch are run locally, keep the results
+            # since they won't be posted, but remove all other generated files.
+            os.chdir(simfiles_path)
+            if os.path.exists('datasheet.json'):
+                os.remove('datasheet.json')
+            for filename in filessimmed:
+                os.remove(filename)
+                # Remove any generated ASCII data files
+                dfile = os.path.splitext(filename)[0] + '.data'
+                if os.path.exists(dfile):
+                    os.remove(dfile)
+                # Stupid ngspice handling of wrdata command. . .
+                dfile = os.path.splitext(filename)[0] + '.data.data'
+                if os.path.exists(dfile):
+                    os.remove(dfile)
+                # Remove any generated raw files
+                dfile = os.path.splitext(filename)[0] + '.raw'
+                if os.path.exists(dfile):
+                    os.remove(dfile)
+                # Remove any cosim verilog files
+                verilog = os.path.splitext(filename)[0] + '.tv'
+                if os.path.exists(verilog):
+                    os.remove(verilog)
+        else:
+            # Remove the entire simulation directory.  To avoid horrible
+            # consequences of, e.g., "-rootdir /" insist that the last path
+            # component of root_path must be the hashname.
+            test = os.path.split(root_path)[0]
+            if test != simulation_path:
+                print('Error:  Root path is not in the system simulation path.  Not deleting.')
+                print('Root path is ' + root_path + '; simulation path is ' + simulation_path)
+            else:
+                subprocess.run(['rm', '-rf', root_path])
+    else:
+        print('Simulation directory retained per -keep option\n')
+
+    sys.exit(0)
diff --git a/common/cace_makeplot.py b/common/cace_makeplot.py
new file mode 100755
index 0000000..75611b3
--- /dev/null
+++ b/common/cace_makeplot.py
@@ -0,0 +1,310 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+"""
+cace_makeplot.py
+Plot routines for CACE using matplotlib
+"""
+
+import re
+import os
+import matplotlib
+from matplotlib.figure import Figure
+from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
+from matplotlib.backends.backend_agg import FigureCanvasAgg
+
+def twos_comp(val, bits):
+    """compute the 2's compliment of int value val"""
+    if (val & (1 << (bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255
+        val = val - (1 << bits)        # compute negative value
+    return val                         # return positive value as is
+
+def makeplot(plotrec, results, variables, parent = None):
+    """
+    Given a plot record from a spec sheet and a full set of results, generate
+    a plot.  The name of the plot file and the vectors to plot, labels, legends,
+    and so forth are all contained in the 'plotrec' dictionary.
+    """
+
+    binrex = re.compile(r'([0-9]*)\'([bodh])', re.IGNORECASE)
+    # Organize data into plot lines according to formatting
+
+    if 'type' in plotrec:
+        plottype = plotrec['type']
+    else:
+        plottype = 'xyplot'
+
+    # Find index of X data in results
+    if plottype == 'histogram':
+        xname = 'RESULT'
+    else:
+        xname = plotrec['xaxis']
+    rlen = len(results[0])
+    try:
+        xidx = next(r for r in range(rlen) if results[0][r] == xname)
+    except StopIteration:
+        return None
+                            
+    # Find unique values of each variable (except results, traces, and iterations)
+    steps = [[0]]
+    traces = [0]
+    bmatch = binrex.match(results[1][0])
+    if bmatch:
+        digits = bmatch.group(1)
+        if digits == '':
+            digits = len(results[2][0])
+        else:
+            digits = int(digits)
+        cbase = bmatch.group(2)
+        if cbase == 'b':
+            base = 2
+        elif cbase == 'o':
+            base = 8
+        elif cbase == 'd':
+            base = 10
+        else:
+            base = 16
+        binconv = [[base, digits]]
+    else:
+        binconv = [[]]
+
+    for i in range(1, rlen):
+        lsteps = []
+
+        # results labeled 'ITERATIONS', 'RESULT', 'TRACE', or 'TIME' are treated as plot vectors
+        isvector = False
+        if results[0][i] == 'ITERATIONS':
+            isvector = True
+        elif results[0][i] == 'RESULT':
+            isvector = True
+        elif results[0][i] == 'TIME':
+            isvector = True
+        elif results[0][i].split(':')[0] == 'TRACE':
+            isvector = True
+
+        # results whose labels are in the 'variables' list are treated as plot vectors
+        if isvector == False:
+            if variables:
+                try:
+                    varrec = next(item for item in variables if item['condition'] == results[0][i])
+                except StopIteration:
+                    pass
+                else:
+                    isvector = True
+
+        # those results that are not traces are stepped conditions (unless they are constant)
+        if isvector == False:
+            try:
+                for item in list(a[i] for a in results[2:]):
+                    if item not in lsteps:
+                        lsteps.append(item)
+            except IndexError:
+                # Diagnostic
+                print("Error: Failed to find " + str(i) + " items in result set")
+                print("Results set has " + len(results[0]) + " entries")
+                print(str(results[0]))
+                for x in range(2, len(results)):
+                    if len(results[x]) <= i:
+                        print("Failed at entry " + str(x))
+                        print(str(results[x]))
+                        break
+
+        # 'ITERATIONS' and 'TIME' are the x-axis variable, so don't add them to traces
+        # (but maybe just check that xaxis name is not made into a trace?)
+        elif results[0][i] != 'ITERATIONS' and results[0][i] != 'TIME':
+            traces.append(i)
+        steps.append(lsteps)
+
+        # Mark which items need converting from digital.  Format is verilog-like.  Use
+        # a format width that is larger than the actual number of digits to force
+        # unsigned conversion.
+        bmatch = binrex.match(results[1][i])
+        if bmatch:
+            digits = bmatch.group(1)
+            if digits == '':
+                digits = len(results[2][i])
+            else:
+                digits = int(digits)
+            cbase = bmatch.group(2)
+            if cbase == 'b':
+                base = 2
+            elif cbase == 'o':
+                base = 8
+            elif cbase == 'd':
+                base = 10
+            else:
+                base = 16
+            binconv.append([base, digits])
+        else:
+            binconv.append([])
+        
+    # Support older method of declaring a digital vector
+    if xname.split(':')[0] == 'DIGITAL':
+        binconv[xidx] = [2, len(results[2][0])]
+
+    # Which stepped variables (ignoring X axis variable) have more than one value?
+    watchsteps = list(i for i in range(1, rlen) if len(steps[i]) > 1 and i != xidx)
+
+    # Diagnostic
+    # print("Stepped conditions are: ")
+    # for j in watchsteps:
+    #      print(results[0][j] + '  (' + str(len(steps[j])) + ' steps)')
+
+    # Collect results.  Make a separate record for each unique set of stepped conditions
+    # encountered.  Record has (X, Y) vector and a list of conditions.
+    pdata = {}
+    for item in results[2:]:
+        if xname.split(':')[0] == 'DIGITAL' or binconv[xidx] != []:
+            base = binconv[xidx][0]
+            digits = binconv[xidx][1]
+            # Recast binary strings as integers
+            # Watch for strings that have been cast to floats (need to find the source of this)
+            if '.' in item[xidx]:
+                item[xidx] = item[xidx].split('.')[0]
+            a = int(item[xidx], base)
+            b = twos_comp(a, digits)
+            xvalue = b
+        else:
+            xvalue = item[xidx]
+
+        slist = []
+        for j in watchsteps:
+             slist.append(item[j])
+        istr = ','.join(slist)
+        if istr not in pdata:
+            stextlist = []
+            for j in watchsteps:
+                if results[1][j] == '':
+                    stextlist.append(results[0][j] + '=' + item[j])
+                else:
+                    stextlist.append(results[0][j] + '=' + item[j] + ' ' + results[1][j])
+            pdict = {}
+            pdata[istr] = pdict
+            pdict['xdata'] = []
+            if stextlist:
+                tracelegnd = False
+            else:
+                tracelegnd = True
+
+            for i in traces:
+                aname = 'ydata' + str(i)
+                pdict[aname] = []
+                alabel = 'ylabel' + str(i)
+                tracename = results[0][i]
+                if ':' in tracename:
+                    tracename = tracename.split(':')[1]
+
+                if results[1][i] != '' and not binrex.match(results[1][i]):
+                    tracename += ' (' + results[1][i] + ')'
+
+                pdict[alabel] = tracename
+
+            pdict['sdata'] = ' '.join(stextlist)
+        else:
+            pdict = pdata[istr]
+        pdict['xdata'].append(xvalue)
+
+        for i in traces:
+            # For each trace, convert the value from digital to integer if needed
+            if binconv[i] != []:
+                base = binconv[i][0]
+                digits = binconv[i][1]
+                a = int(item[i], base)
+                b = twos_comp(a, digits)
+                yvalue = b
+            else:
+                yvalue = item[i]
+
+            aname = 'ydata' + str(i)
+            pdict[aname].append(yvalue)
+
+    fig = Figure()
+    if parent == None:
+        canvas = FigureCanvasAgg(fig)
+    else:
+        canvas = FigureCanvasTkAgg(fig, parent)
+
+    # With no parent, just make one plot and put the legend off to the side.  The
+    # 'extra artists' capability of print_figure will take care of the bounding box.
+    # For display, prepare two subplots so that the legend takes up the space of the
+    # second one.
+    if parent == None:
+        ax = fig.add_subplot(111)
+    else:
+        ax = fig.add_subplot(121)
+
+    fig.hold(True)
+    for record in pdata:
+        pdict = pdata[record]
+
+        # Check if xdata is numeric
+        try:
+            test = float(pdict['xdata'][0])
+        except ValueError:
+            numeric = False
+            xdata = [i for i in range(len(pdict['xdata']))]
+        else:
+            numeric = True
+            xdata = list(map(float,pdict['xdata']))
+
+        if plottype == 'histogram':
+            ax.hist(xdata, histtype='barstacked', label=pdict['sdata'], stacked=True)
+        else:
+            for i in traces:
+                aname = 'ydata' + str(i)
+                alabl = 'ylabel' + str(i)
+                ax.plot(xdata, pdict[aname], label=pdict[alabl] + ' ' + pdict['sdata'])
+                # Diagnostic
+                # print("Y values for " + aname + ": " + str(pdict[aname]))
+
+        if not numeric:
+            ax.set_xticks(xdata)
+            ax.set_xticklabels(pdict['xdata'])
+
+    if 'xlabel' in plotrec:
+        if results[1][xidx] == '' or binrex.match(results[1][xidx]):
+            ax.set_xlabel(plotrec['xlabel'])
+        else:
+            ax.set_xlabel(plotrec['xlabel'] + ' (' + results[1][xidx] + ')')
+    else:
+        # Automatically generate X axis label if not given alternate text
+        xtext = results[0][xidx]
+        if results[1][xidx] != '':
+            xtext += ' (' + results[1][xidx] + ')'
+        ax.set_xlabel(xtext)
+
+    if 'ylabel' in plotrec:
+        if results[1][0] == '' or binrex.match(results[1][0]):
+            ax.set_ylabel(plotrec['ylabel'])
+        else:
+            ax.set_ylabel(plotrec['ylabel'] + ' (' + results[1][0] + ')')
+    else:
+        # Automatically generate Y axis label if not given alternate text
+        ytext = results[0][0]
+        if results[1][0] != '' or binrex.match(results[1][0]):
+            ytext += ' (' + results[1][0] + ')'
+        ax.set_ylabel(ytext)
+
+    ax.grid(True)
+    if watchsteps or tracelegnd:
+        legnd = ax.legend(loc = 2, bbox_to_anchor = (1.05, 1), borderaxespad=0.)
+    else:
+        legnd = None
+
+    if legnd:
+        legnd.draggable()
+
+    if parent == None:
+        if not os.path.exists('simulation_files'):
+            os.makedirs('simulation_files')
+
+        filename = 'simulation_files/' + plotrec['filename']
+        # NOTE: print_figure only makes use of bbox_extra_artists if
+        # bbox_inches is set to 'tight'.  This forces a two-pass method
+        # that calculates the real maximum bounds of the figure.  Otherwise
+        # the legend gets clipped.
+        if legnd:
+            canvas.print_figure(filename, bbox_inches = 'tight',
+                        bbox_extra_artists = [legnd])
+        else:
+            canvas.print_figure(filename, bbox_inches = 'tight')
+
+    return canvas
diff --git a/common/consoletext.py b/common/consoletext.py
index 03276fb..820d465 100755
--- a/common/consoletext.py
+++ b/common/consoletext.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/ef/efabless/opengalaxy/venv/bin/python3
 #
 #--------------------------------------------------------
 """
@@ -16,7 +16,7 @@
 import tkinter
 
 class ConsoleText(tkinter.Text):
-    linelimit = 500
+    linelimit = 10000
     class IORedirector(object):
         '''A general class for redirecting I/O to this Text widget.'''
         def __init__(self,text_area):
diff --git a/common/create_project.py b/common/create_project.py
index 3c4cb4a..787274f 100755
--- a/common/create_project.py
+++ b/common/create_project.py
@@ -56,8 +56,9 @@
     if pdkname:
         if pdkname.startswith('/'):
             pdkpath = pdkname
+            pdkname = os.path.split(pdkpath)[1]
         else:
-            pdkpath = os.path.join('PREFIX', 'pdk', pdkname)
+            pdkpath = os.path.join('/usr/share', 'pdk', pdkname)
     else:
         try:
             pdkpath = os.getenv()['PDK_PATH']
@@ -119,6 +120,10 @@
     else:
         dongspice = True
 
+    if not os.path.isdir(pdkpath + '/.ef-config') and not os.path.isdir(pdkpath + '/.config'):
+        print('PDK does not contain .config or .ef-config directory, cannot create project.')
+        sys.exit(1)
+        
     if domagic or donetgen or doxschem or dongspice:
         print('Creating project ' + projectname)
         os.makedirs(projectpath)
@@ -126,6 +131,14 @@
         print('No setup files were found . . .  bailing.')
         sys.exit(1)
 
+    if os.path.isdir(pdkpath + '/.ef-config'):
+        os.makedirs(projectpath + '/.ef-config')
+        os.symlink(pdkpath, projectpath + '/.ef-config/techdir')
+    elif os.path.isdir(pdkpath + '/.config'):
+        os.makedirs(projectpath + '/.config')
+        os.symlink(pdkpath, projectpath + '/.config/techdir')
+        
+    
     if domagic:
         magpath = os.path.join(projectpath, 'mag')
         os.makedirs(magpath)
diff --git a/common/editparam.py b/common/editparam.py
new file mode 100755
index 0000000..30a9d06
--- /dev/null
+++ b/common/editparam.py
@@ -0,0 +1,647 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#-----------------------------------------------------------
+# Parameter editing for the Open Galaxy characterization tool
+#-----------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# March 28, 2017
+# Version 0.1
+#--------------------------------------------------------
+
+import os
+import re
+import tkinter
+from tkinter import ttk
+
+class Condition(object):
+    def __init__(self, parent = None):
+        self.min = tkinter.StringVar(parent)
+        self.typ = tkinter.StringVar(parent)
+        self.max = tkinter.StringVar(parent)
+        self.step = tkinter.StringVar(parent)
+        self.steptype = tkinter.StringVar(parent)
+        self.unit = tkinter.StringVar(parent)
+        self.condition = tkinter.StringVar(parent)
+        self.display = tkinter.StringVar(parent)
+
+class Limit(object):
+    def __init__(self, parent = None):
+        self.target = tkinter.StringVar(parent)
+        self.penalty = tkinter.StringVar(parent)
+        self.calc = tkinter.StringVar(parent)
+        self.limit = tkinter.StringVar(parent)
+
+class EditParam(tkinter.Toplevel):
+    """Characterization tool electrical parameter editor."""
+
+    def __init__(self, parent=None, fontsize = 11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        s.configure('bg.TFrame', background='gray40')
+        self.parent = parent
+        self.withdraw()
+        self.title('Electrical parameter editor')
+        self.sframe = tkinter.Frame(self)
+        self.sframe.grid(column = 0, row = 0, sticky = "news")
+
+        # Keep current parameter
+        self.param = None
+
+        #-------------------------------------------------------------
+        # Add the entries that are common to all electrical parameters
+
+        self.selmethod = tkinter.StringVar(self)
+        self.display = tkinter.StringVar(self)
+        self.unit = tkinter.StringVar(self)
+        self.minrec = Limit(self)
+        self.typrec = Limit(self)
+        self.maxrec = Limit(self)
+        self.cond = []
+
+        #--------------------------------------------------------
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 2, sticky = "news")
+
+        self.bbar.apply_button = ttk.Button(self.bbar, text='Apply',
+		command=self.apply, style = 'normal.TButton')
+        self.bbar.apply_button.grid(column=0, row=0, padx = 5)
+
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=1, row=0, padx = 5)
+
+        self.rowconfigure(0, weight = 1)
+        self.rowconfigure(1, weight = 0)
+        self.columnconfigure(0, weight = 1)
+
+        self.protocol("WM_DELETE_WINDOW", self.close)
+
+    def grid_configure(self, padx, pady):
+        return
+
+    def redisplay(self):
+        return
+
+    def populate(self, param):
+        # Remove all existing contents
+        for widget in self.sframe.winfo_children():
+            widget.destroy()
+
+        # Add major frames
+
+        frame1 = ttk.Frame(self.sframe)
+        frame1.grid(column = 0, row = 0, sticky = 'news')
+        frame2 = ttk.Frame(self.sframe)
+        frame2.grid(column = 0, row = 1, sticky = 'news')
+        frame3 = ttk.Frame(self.sframe)
+        frame3.grid(column = 0, row = 2, sticky = 'news')
+
+        # The Conditions area is the one that grows
+        self.sframe.rowconfigure(2, weight=1)
+        self.sframe.columnconfigure(0, weight=1)
+
+        ttk.Separator(frame3, orient='horizontal').grid(row = 0, column = 0,
+			sticky = 'news')
+
+        # The conditions list can get very big, so build out a
+        # scrolled canvas.
+
+        frame3.canvas = tkinter.Canvas(frame3)
+        frame3.canvas.grid(row = 1, column = 0, sticky = 'nswe')
+        frame3.canvas.dframe = ttk.Frame(frame3.canvas, style='bg.TFrame')
+        # Save the canvas widget, as we need to access it from places like
+        # the scrollbar callbacks.
+        self.canvas = frame3.canvas
+        # Place the frame in the canvas
+        frame3.canvas.create_window((0,0), window=frame3.canvas.dframe,
+			anchor="nw")
+        # Make sure the main window resizes, not the scrollbars.
+        frame3.rowconfigure(1, weight=1)
+        frame3.columnconfigure(0, weight = 1)
+        # X scrollbar for conditions list
+        main_xscrollbar = ttk.Scrollbar(frame3, orient = 'horizontal')
+        main_xscrollbar.grid(row = 2, column = 0, sticky = 'nswe')
+        # Y scrollbar for conditions list
+        main_yscrollbar = ttk.Scrollbar(frame3, orient = 'vertical')
+        main_yscrollbar.grid(row = 1, column = 1, sticky = 'nswe')
+        # Attach console to scrollbars
+        frame3.canvas.config(xscrollcommand = main_xscrollbar.set)
+        main_xscrollbar.config(command = frame3.canvas.xview)
+        frame3.canvas.config(yscrollcommand = main_yscrollbar.set)
+        main_yscrollbar.config(command = frame3.canvas.yview)
+
+        # Make sure that scrollwheel pans the window
+        frame3.canvas.bind_all("<Button-4>", self.on_mousewheel)
+        frame3.canvas.bind_all("<Button-5>", self.on_mousewheel)
+
+        # Set up configure callback
+        frame3.canvas.dframe.bind("<Configure>", self.frame_configure)
+
+        # Get list of methods from testbench folder
+        dspath = os.path.split(self.parent.cur_datasheet)[0]
+        tbpath = dspath + '/testbench'
+        tbfiles = os.listdir(tbpath)
+        methods = []
+        for spifile in tbfiles:
+            if os.path.splitext(spifile)[1] == '.spi':
+                methods.append(os.path.splitext(spifile)[0].upper())
+
+        # Get list of pins from parent datasheet
+        dsheet = self.parent.datatop['data-sheet']
+        pins = dsheet['pins']
+        pinlist = []
+        for pin in pins:
+            pinlist.append(pin['name'])
+        pinlist.append('(none)')
+
+        # Add common elements
+        frame1.ldisplay = ttk.Label(frame1, text='Description:',
+			style= 'blue.TLabel', anchor = 'e')
+        frame1.lmethod = ttk.Label(frame1, text='Method:',
+			style= 'blue.TLabel', anchor = 'e')
+        frame1.lunit = ttk.Label(frame1, text='Unit:',
+			style= 'blue.TLabel', anchor = 'e')
+
+        # If 'pin' exists (old style), append it to 'condition' and remove.
+        if 'pin' in param:
+            if 'method' in param and ':' not in param['method']:
+                param['method'] += ':' + param['pin']
+            param.pop('pin', 0)
+
+        # Find method and apply to OptionMenu
+        if 'method' in param:
+            self.selmethod.set(param['method'])
+        else:
+            self.selmethod.set('(none)')
+
+
+        frame1.display = ttk.Entry(frame1, textvariable = self.display)
+        frame1.method = ttk.OptionMenu(frame1, self.selmethod, self.selmethod.get(), *methods)
+        frame1.unit = ttk.Entry(frame1, textvariable = self.unit)
+
+        frame1.ldisplay.grid(column = 0, row = 0, sticky = 'news', padx=5, pady=5)
+        frame1.display.grid(column = 1, row = 0, sticky = 'news', padx=5, pady=3)
+        frame1.lmethod.grid(column = 0, row = 1, sticky = 'news', padx=5, pady=5)
+        frame1.method.grid(column = 1, row = 1, sticky = 'news', padx=5, pady=3)
+        frame1.lunit.grid(column = 0, row = 2, sticky = 'news', padx=5, pady=5)
+        frame1.unit.grid(column = 1, row = 2, sticky = 'news', padx=5, pady=3)
+
+        frame1.columnconfigure(0, weight = 0)
+        frame1.columnconfigure(1, weight = 1)
+
+        frame1.display.delete(0, 'end')
+        if 'display' in param:
+            frame1.display.insert(0, param['display'])
+        else:
+            frame1.display.insert(0, '(none)')
+
+        frame1.unit.delete(0, 'end')
+        if 'unit' in param:
+            frame1.unit.insert(0, param['unit'])
+        else:
+            frame1.unit.insert(0, '(none)')
+
+        ttk.Separator(frame1, orient='horizontal').grid(row = 4, column = 0,
+			columnspan = 2, sticky = 'nsew')
+
+        # Calculation types
+        calctypes = ["min", "max", "avg", "diffmin", "diffmax", "(none)"]
+        limittypes = ["above", "below", "exact", "legacy", "(none)"]
+
+        # Add min/typ/max (To-do:  Add plot)
+
+        frame2min = ttk.Frame(frame2, borderwidth = 2, relief='groove')
+        frame2min.grid(row = 0, column = 0, padx = 2, pady = 2, sticky = 'news')
+        ttk.Label(frame2min, text="Minimum:", style = 'blue.TLabel',
+			anchor = 'w').grid(row = 0, column = 0, padx = 5,
+			sticky = 'news')
+        if 'min' in param:
+            minrec = param['min']
+        else:
+            minrec = {}
+        ttk.Label(frame2min, text="Target:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 1, column = 0, padx = 5,
+			sticky = 'news')
+        frame2min.tmin = ttk.Entry(frame2min, textvariable = self.minrec.target)
+        frame2min.tmin.grid(row = 1, column = 1, padx = 5, sticky = 'news')
+        frame2min.tmin.delete(0, 'end')
+        if 'target' in minrec:
+            frame2min.tmin.insert(0, minrec['target'])
+        ttk.Label(frame2min, text="Penalty:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 2, column = 0, padx = 5,
+			sticky = 'news')
+        frame2min.pmin = ttk.Entry(frame2min, textvariable = self.minrec.penalty)
+        frame2min.pmin.grid(row = 2, column = 1, padx = 5, sticky = 'news')
+        frame2min.pmin.delete(0, 'end')
+        if 'penalty' in minrec:
+            frame2min.pmin.insert(0, minrec['penalty'])
+        if 'calc' in minrec:
+            calcrec = minrec['calc']
+            try:
+                calctype, limittype = calcrec.split('-')
+            except ValueError:
+                calctype = calcrec
+                if calctype == 'min':
+                    limittype = 'above'
+                elif calctype == 'max':
+                    limittype = 'below'
+                elif calctype == 'avg':
+                    limittype = 'exact'
+                elif calctype == 'diffmin':
+                    limittype = 'above'
+                elif calctype == 'diffmax':
+                    limittype = 'below'
+                else:
+                    limittype = '(none)'
+        else:
+            calctype = 'min'
+            limittype = 'above'
+
+        ttk.Label(frame2min, text="Calculation:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 3, column = 0, padx = 5,
+			sticky = 'news')
+        self.cmin = tkinter.StringVar(self)
+        self.cmin.set(calctype)
+        frame2min.cmin = ttk.OptionMenu(frame2min, self.cmin, calctype, *calctypes)
+        frame2min.cmin.grid(row = 3, column = 1, padx = 5, sticky = 'news')
+        ttk.Label(frame2min, text="Limit:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 4, column = 0, padx = 5,
+			sticky = 'news')
+        self.lmin = tkinter.StringVar(self)
+        self.lmin.set(limittype)
+        frame2min.lmin = ttk.OptionMenu(frame2min, self.lmin, limittype, *limittypes)
+        frame2min.lmin.grid(row = 4, column = 1, padx = 5, sticky = 'news')
+
+        frame2typ = ttk.Frame(frame2, borderwidth = 2, relief='groove')
+        frame2typ.grid(row = 0, column = 1, padx = 2, pady = 2, sticky = 'news')
+        ttk.Label(frame2typ, text="Typical:", style = 'blue.TLabel',
+			anchor = 'w').grid(row = 0, column = 0, padx = 5,
+			sticky = 'news')
+        if 'typ' in param:
+            typrec = param['typ']
+        else:
+            typrec = {}
+        ttk.Label(frame2typ, text="Target:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 1, column = 0, padx = 5,
+			sticky = 'news')
+        frame2typ.ttyp = ttk.Entry(frame2typ, textvariable = self.typrec.target)
+        frame2typ.ttyp.grid(row = 1, column = 1, padx = 5, sticky = 'news')
+        frame2typ.ttyp.delete(0, 'end')
+        if 'target' in typrec:
+            frame2typ.ttyp.insert(0, typrec['target'])
+        ttk.Label(frame2typ, text="Penalty:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 2, column = 0, padx = 5,
+			sticky = 'news')
+        frame2typ.ptyp = ttk.Entry(frame2typ, textvariable = self.typrec.penalty)
+        frame2typ.ptyp.grid(row = 2, column = 1, padx = 5, sticky = 'news')
+        frame2typ.ptyp.delete(0, 'end')
+        if 'penalty' in typrec:
+            frame2typ.ptyp.insert(0, typrec['penalty'])
+        if 'calc' in typrec:
+            calcrec = typrec['calc']
+            try:
+                calctype, limittype = calcrec.split('-')
+            except ValueError:
+                calctype = calcrec
+                if calctype == 'min':
+                    limittype = 'above'
+                elif calctype == 'max':
+                    limittype = 'below'
+                elif calctype == 'avg':
+                    limittype = 'exact'
+                elif calctype == 'diffmin':
+                    limittype = 'above'
+                elif calctype == 'diffmax':
+                    limittype = 'below'
+                else:
+                    limittype = '(none)'
+        else:
+            calctype = 'avg'
+            limittype = 'exact'
+
+        ttk.Label(frame2typ, text="Calculation:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 3, column = 0, padx = 5,
+			sticky = 'news')
+        self.ctyp = tkinter.StringVar(self)
+        self.ctyp.set(calctype)
+        frame2typ.ctyp = ttk.OptionMenu(frame2typ, self.ctyp, calctype, *calctypes)
+        frame2typ.ctyp.grid(row = 3, column = 1, padx = 5, sticky = 'news')
+        ttk.Label(frame2typ, text="Limit:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 4, column = 0, padx = 5,
+			sticky = 'news')
+        self.ltyp = tkinter.StringVar(self)
+        self.ltyp.set(limittype)
+        frame2typ.ltyp = ttk.OptionMenu(frame2typ, self.ltyp, limittype, *limittypes)
+        frame2typ.ltyp.grid(row = 4, column = 1, padx = 5, sticky = 'news')
+
+        frame2max = ttk.Frame(frame2, borderwidth = 2, relief='groove')
+        frame2max.grid(row = 0, column = 2, padx = 2, pady = 2, sticky = 'news')
+        ttk.Label(frame2max, text="Maximum:", style = 'blue.TLabel',
+			anchor = 'w').grid(row = 0, column = 0, padx = 5,
+			sticky = 'news')
+        if 'max' in param:
+            maxrec = param['max']
+        else:
+            maxrec = {}
+        ttk.Label(frame2max, text="Target:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 1, column = 0, padx = 5,
+			sticky = 'news')
+        frame2max.tmax = ttk.Entry(frame2max, textvariable = self.maxrec.target)
+        frame2max.tmax.grid(row = 1, column = 1, padx = 5, sticky = 'news')
+        frame2max.tmax.delete(0, 'end')
+        if 'target' in maxrec:
+            frame2max.tmax.insert(0, maxrec['target'])
+        ttk.Label(frame2max, text="Penalty:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 2, column = 0, padx = 5,
+			sticky = 'news')
+        frame2max.pmax = ttk.Entry(frame2max, textvariable = self.maxrec.penalty)
+        frame2max.pmax.grid(row = 2, column = 1, padx = 5, sticky = 'news')
+        frame2max.pmax.delete(0, 'end')
+        if 'penalty' in maxrec:
+            frame2max.pmax.insert(0, maxrec['penalty'])
+        if 'calc' in maxrec:
+            calcrec = maxrec['calc']
+            try:
+                calctype, limittype = calcrec.split('-')
+            except ValueError:
+                calctype = calcrec
+                if calctype == 'min':
+                    limittype = 'above'
+                elif calctype == 'max':
+                    limittype = 'below'
+                elif calctype == 'avg':
+                    limittype = 'exact'
+                elif calctype == 'diffmin':
+                    limittype = 'above'
+                elif calctype == 'diffmax':
+                    limittype = 'below'
+                else:
+                    limittype = '(none)'
+        else:
+            calctype = 'max'
+            limittype = 'below'
+
+        ttk.Label(frame2max, text="Calculation:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 3, column = 0, padx = 5,
+			sticky = 'news')
+        self.cmax = tkinter.StringVar(self)
+        self.cmax.set(calctype)
+        frame2max.cmax = ttk.OptionMenu(frame2max, self.cmax, calctype, *calctypes)
+        frame2max.cmax.grid(row = 3, column = 1, padx = 5, sticky = 'news')
+        ttk.Label(frame2max, text="Limit:", anchor = 'e',
+			style = 'normal.TLabel').grid(row = 4, column = 0, padx = 5,
+			sticky = 'news')
+        self.lmax = tkinter.StringVar(self)
+        self.lmax.set(limittype)
+        frame2max.lmax = ttk.OptionMenu(frame2max, self.lmax, limittype, *limittypes)
+        frame2max.lmax.grid(row = 4, column = 1, padx = 5, sticky = 'news')
+	
+        dframe = frame3.canvas.dframe
+
+        ttk.Label(dframe, text="Conditions:", style = 'blue.TLabel',
+			anchor='w').grid(row = 0, column = 0, padx = 5, sticky = 'news', columnspan = 5)
+
+        # Add conditions
+
+        condtypes = ["VOLTAGE", "DIGITAL", "CURRENT", "RISETIME", "FALLTIME",
+		"RESISTANCE", "CAPACITANCE", "TEMPERATURE", "FREQUENCY",
+		"CORNER", "SIGMA", "ITERATIONS", "(none)"]
+
+        steptypes = ['linear', 'log', '(none)']
+
+        n = 0
+        r = 1
+        self.crec = []
+        self.cond = []
+        for cond in param['conditions']:
+            # If over 5 columns of conditions, create a new row.
+            if n >= 5:
+                r += 1
+                n = 0
+            # New column
+            frame3c = ttk.Frame(dframe, borderwidth = 2, relief='groove')
+            frame3c.grid(row = r, column = n, padx = 2, pady = 2, sticky = 'news')
+
+            crec = Condition(self)
+            # Condition description
+            ttk.Label(frame3c, text='Description:', style='normal.TLabel',
+			anchor='e').grid(row = 0, column = 0, padx = 5, sticky = 'news')
+            c1 = ttk.Entry(frame3c, textvariable = crec.display)
+            c1.grid(row = 0, column = 1, padx = 5, sticky = 'news')
+            c1.delete(0, 'end')
+            if 'display' in cond:
+                c1.insert(0, cond['display'])
+            else:
+                c1.insert(0, '(none)')
+            # Condition type (pulldown menu)
+            if 'condition' in cond:
+                crec.condition.set(cond['condition'])
+            else:
+                crec.condition.set('(none)')
+            ttk.Label(frame3c, text='Condition:', style='normal.TLabel',
+			anchor='e').grid(row = 1, column = 0, padx = 5, sticky = 'news')
+            c2 = ttk.OptionMenu(frame3c, crec.condition, crec.condition.get(), *condtypes)
+            c2.grid(row = 1, column = 1, padx = 5, sticky = 'news')
+            # Condition unit
+            ttk.Label(frame3c, text='Unit:', style='normal.TLabel',
+			anchor='e').grid(row = 3, column = 0, padx = 5, sticky = 'news')
+            c4 = ttk.Entry(frame3c, textvariable = crec.unit)
+            c4.grid(row = 3, column = 1, padx = 5, sticky = 'news')
+            c4.delete(0, 'end')
+            if 'unit' in cond:
+                c4.insert(0, cond['unit'])
+            else:
+                c4.insert(0, '(none)')
+            # Condition min
+            ttk.Label(frame3c, text='Minimum:', style='normal.TLabel',
+			anchor='e').grid(row = 4, column = 0, padx = 5, sticky = 'news')
+            c5 = ttk.Entry(frame3c, textvariable = crec.min)
+            c5.grid(row = 4, column = 1, padx = 5, sticky = 'news')
+            c5.delete(0, 'end')
+            if 'min' in cond:
+                c5.insert(0, cond['min'])
+            else:
+                c5.insert(0, '(none)')
+            # Condition typ
+            ttk.Label(frame3c, text='Typical:', style='normal.TLabel',
+			anchor='e').grid(row = 5, column = 0, padx = 5, sticky = 'news')
+            c6 = ttk.Entry(frame3c, textvariable = crec.typ)
+            c6.grid(row = 5, column = 1, padx = 5, sticky = 'news')
+            c6.delete(0, 'end')
+            if 'typ' in cond:
+                c6.insert(0, cond['typ'])
+            else:
+                c6.insert(0, '(none)')
+            # Condition max
+            ttk.Label(frame3c, text='Maximum:', style='normal.TLabel',
+			anchor='e').grid(row = 6, column = 0, padx = 5, sticky = 'news')
+            c7 = ttk.Entry(frame3c, textvariable = crec.max)
+            c7.grid(row = 6, column = 1, padx = 5, sticky = 'news')
+            c7.delete(0, 'end')
+            if 'max' in cond:
+                c7.insert(0, cond['max'])
+            else:
+                c7.insert(0, '(none)')
+            # Condition steptype
+            ttk.Label(frame3c, text='Step type:', style='normal.TLabel',
+			anchor='e').grid(row = 7, column = 0, padx = 5, sticky = 'news')
+            c8 = ttk.OptionMenu(frame3c, crec.steptype, crec.steptype.get(), *steptypes)
+            c8.grid(row = 7, column = 1, padx = 5, sticky = 'news')
+            if 'linstep' in cond:
+                crec.steptype.set('linear')
+            elif 'logstep' in cond:
+                crec.steptype.set('log')
+            else:
+                crec.steptype.set('(none)')
+            # Condition step
+            ttk.Label(frame3c, text='Step:', style='normal.TLabel',
+			anchor='e').grid(row = 8, column = 0, padx = 5, sticky = 'news')
+            c9 = ttk.Entry(frame3c, textvariable = crec.step)
+            c9.grid(row = 8, column = 1, padx = 5, sticky = 'news')
+            c9.delete(0, 'end')
+            if 'linstep' in cond:
+                c9.insert(0, cond['linstep'])
+            elif 'logstep' in cond:
+                c9.insert(0, cond['logstep'])
+            else:
+                c9.insert(0, '(none)')
+
+            n += 1
+            self.cond.append(crec)
+            # Condition remove
+            c10 = ttk.Button(frame3c, text='Remove', style='normal.TButton',
+			command = lambda cond=cond: self.remove_condition(cond))
+            c10.grid(row = 9, column = 1, padx = 5, sticky = 'news')
+
+        # Add 'add condition' button
+        dframe.bcond = ttk.Button(dframe, text="Add Condition",
+			style = 'blue.TButton', command = self.add_condition)
+        if n >= 5:
+            dframe.bcond.grid(row = r + 1, column = 0, padx = 5, pady = 3, sticky = 'nsw')
+        else:
+            dframe.bcond.grid(row = r, column = n, padx = 5, pady = 3, sticky = 'new')
+
+        # Set the current parameter
+        self.param = param
+
+    def on_mousewheel(self, event):
+        if event.num == 5:
+            self.canvas.yview_scroll(1, "units")
+        elif event.num == 4:
+            self.canvas.yview_scroll(-1, "units")
+
+    def frame_configure(self, event):
+        self.update_idletasks()
+        self.canvas.configure(scrollregion=self.canvas.bbox("all"))
+
+    def add_condition(self):
+        # Add a new condition
+        newcond = {}
+        newcond['condition'] = '(none)'
+        self.param['conditions'].append(newcond)
+        self.populate(self.param)
+
+    def remove_condition(self, cond):
+        # Remove and existing condition
+        condlist = self.param['conditions']
+        eidx = condlist.index(cond)
+        condlist.pop(eidx)
+        self.populate(self.param)
+
+    def apply(self):
+        # Apply the values back to the parameter record
+        self.param['method'] = self.selmethod.get()
+        unit = self.unit.get()
+        if not (unit == '(none)' or unit == ''):
+            self.param['unit'] = unit
+        display = self.display.get()
+        if not (display == '(none)' or display == ''):
+            self.param['display'] = display
+        targmin = self.minrec.target.get()
+        if not (targmin == '(none)' or targmin == ''):
+            pmin = {}
+            pmin['target'] = targmin
+            pmin['penalty'] = self.minrec.penalty.get()
+            cmin = self.minrec.calc.get()
+            if not (cmin == '(none)' or cmin == ''):
+                lmin = self.minrec.limit.get()
+                if not (lmin == '(none)' or lmin == ''):
+                    pmin['calc'] = cmin + '-' + lmin
+                else:
+                    pmin['calc'] = cmin
+            self.param['min'] = pmin
+        targtyp = self.typrec.target.get()
+        if not (targtyp == '(none)' or targtyp == ''):
+            ptyp= {}
+            ptyp['target'] = targtyp
+            ptyp['penalty'] = self.typrec.penalty.get()
+            ctyp = self.typrec.calc.get()
+            if not (ctyp == '(none)' or ctyp == ''):
+                ltyp = self.typrec.limit.get()
+                if not (ltyp == '(none)' or ltyp == ''):
+                    ptyp['calc'] = ctyp + '-' + ltyp
+                else:
+                    ptyp['calc'] = ctyp
+            self.param['typ'] = ptyp
+        targmax = self.maxrec.target.get()
+        if not (targmax == '(none)' or targmax == ''):
+            pmax= {}
+            pmax['target'] = targmax
+            pmax['penalty'] = self.maxrec.penalty.get()
+            cmax = self.maxrec.calc.get()
+            if not (cmax == '(none)' or cmax == ''):
+                lmax = self.maxrec.limit.get()
+                if not (lmax == '(none)' or lmax == ''):
+                    pmax['calc'] = cmax + '-' + lmax
+                else:
+                    pmax['calc'] = cmax 
+            self.param['max'] = pmax
+
+        condlist = []
+        for crec in self.cond:
+            cond = {}
+            cname = crec.condition.get()
+            if cname == '(none)' or cname == '':
+                continue
+            cond['condition'] = cname
+            display = crec.display.get()
+            if not (display == '(none)' or display == ''):
+                cond['display'] = display
+            min = crec.min.get()
+            if not (min == '(none)' or min == ''):
+                cond['min'] = min
+            typ = crec.typ.get()
+            if not (typ == '(none)' or typ == ''):
+                cond['typ'] = typ
+            max = crec.max.get()
+            if not (max == '(none)' or max == ''):
+                cond['max'] = max
+            unit = crec.unit.get()
+            if not (unit == '(none)' or unit == ''):
+                cond['unit'] = unit
+            steptype = crec.steptype.get()
+            step = crec.step.get()
+            if not (step == '(none)' or step == ''):
+                if steptype == 'linear':
+                    cond['linstep'] = step
+                elif steptype == 'log':
+                    cond['logstep'] = step
+            condlist.append(cond)
+        self.param['conditions'] = condlist
+
+        self.parent.create_datasheet_view()
+        return
+
+    def close(self):
+        # pop down settings window
+        self.withdraw()
+
+    def open(self):
+        # pop up settings window
+        self.deiconify()
+        self.lift()
diff --git a/common/failreport.py b/common/failreport.py
new file mode 100755
index 0000000..7a1159b
--- /dev/null
+++ b/common/failreport.py
@@ -0,0 +1,534 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#--------------------------------------------------------------------
+# Characterization Report Window for the Open Galaxy project manager
+#
+#--------------------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# September 12, 2016
+# Version 0.1
+#----------------------------------------------------------
+
+import os
+import base64
+import subprocess
+
+import tkinter
+from tkinter import ttk
+
+import tooltip
+import cace_makeplot
+
+class FailReport(tkinter.Toplevel):
+    """Open Galaxy failure report window."""
+
+    def __init__(self, parent=None, fontsize=11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('bg.TFrame', background='gray40')
+        s.configure('italic.TLabel', font=('Helvetica', fontsize, 'italic'), anchor = 'west')
+        s.configure('title.TLabel', font=('Helvetica', fontsize, 'bold italic'),
+                        foreground = 'brown', anchor = 'center')
+        s.configure('normal.TLabel', font=('Helvetica', fontsize))
+        s.configure('red.TLabel', font=('Helvetica', fontsize), foreground = 'red')
+        s.configure('green.TLabel', font=('Helvetica', fontsize), foreground = 'green4')
+        s.configure('blue.TLabel', font=('Helvetica', fontsize), foreground = 'blue')
+        s.configure('brown.TLabel', font=('Helvetica', fontsize, 'italic'),
+			foreground = 'brown', anchor = 'center')
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3,
+			relief = 'raised')
+        s.configure('red.TButton', font=('Helvetica', fontsize), foreground = 'red',
+			border = 3, relief = 'raised')
+        s.configure('green.TButton', font=('Helvetica', fontsize), foreground = 'green4',
+			border = 3, relief = 'raised')
+        s.configure('title.TButton', font=('Helvetica', fontsize, 'bold italic'),
+                        foreground = 'brown', border = 0, relief = 'groove')
+
+        self.withdraw()
+        self.title('Open Galaxy Local Characterization Report')
+        self.root = parent.root
+        self.rowconfigure(0, weight = 1)
+        self.columnconfigure(0, weight = 1)
+
+        # Scrolled frame:  Need frame, then canvas and scrollbars;  finally, the
+        # actual grid of results gets placed in the canvas.
+        self.failframe = ttk.Frame(self)
+        self.failframe.grid(column = 0, row = 0, sticky = 'nsew')
+        self.mainarea = tkinter.Canvas(self.failframe)
+        self.mainarea.grid(row = 0, column = 0, sticky = 'nsew')
+
+        self.mainarea.faildisplay = ttk.Frame(self.mainarea)
+        self.mainarea.create_window((0,0), window=self.mainarea.faildisplay,
+			anchor="nw", tags="self.frame")
+
+        # Create a frame for displaying plots, but don't put it in the grid.
+        # Make it resizeable.
+        self.plotframe = ttk.Frame(self)
+        self.plotframe.rowconfigure(0, weight = 1)
+        self.plotframe.columnconfigure(0, weight = 1)
+
+        # Main window resizes, not the scrollbars
+        self.failframe.rowconfigure(0, weight = 1)
+        self.failframe.columnconfigure(0, weight = 1)
+        # Add scrollbars
+        xscrollbar = ttk.Scrollbar(self.failframe, orient = 'horizontal')
+        xscrollbar.grid(row = 1, column = 0, sticky = 'nsew')
+        yscrollbar = ttk.Scrollbar(self.failframe, orient = 'vertical')
+        yscrollbar.grid(row = 0, column = 1, sticky = 'nsew')
+        # Attach viewing area to scrollbars
+        self.mainarea.config(xscrollcommand = xscrollbar.set)
+        xscrollbar.config(command = self.mainarea.xview)
+        self.mainarea.config(yscrollcommand = yscrollbar.set)
+        yscrollbar.config(command = self.mainarea.yview)
+        # Set up configure callback
+        self.mainarea.faildisplay.bind("<Configure>", self.frame_configure)
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 1, sticky = "news")
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=0, row=0, padx = 5)
+        # Table button returns to table view but is only displayed for plots.
+        self.bbar.table_button = ttk.Button(self.bbar, text='Table', style = 'normal.TButton')
+
+        self.protocol("WM_DELETE_WINDOW", self.close)
+        tooltip.ToolTip(self.bbar.close_button,
+			text='Close detail view of conditions and results')
+
+        self.sortdir = False
+        self.data = []
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def frame_configure(self, event):
+        self.update_idletasks()
+        self.mainarea.configure(scrollregion=self.mainarea.bbox("all"))
+
+    def check_failure(self, record, calc, value):
+        if not 'target' in record:
+            return None
+        else:
+            target = record['target']
+
+        if calc == 'min':
+            targval = float(target)
+            if value < targval:
+                return True
+        elif calc == 'max':
+            targval = float(target)
+            if value > targval:
+                return True
+        else:
+            return None
+
+    # Given an electrical parameter 'param' and a condition name 'condname', find
+    # the units of that condition.  If the condition isn't found in the local
+    # parameters, then it is searched for in 'globcond'.
+
+    def findunit(self, condname, param, globcond):
+        unit = ''
+        try:
+            loccond = next(item for item in param['conditions'] if item['condition'] == condname)
+        except StopIteration:
+            try:
+                globitem = next(item for item in globcond if item['condition'] == condname)
+            except (TypeError, StopIteration):
+                unit = ''	# No units
+            else:
+                if 'unit' in globitem:
+                    unit = globitem['unit']
+                else:
+                    unit = ''	# No units
+        else:
+            if 'unit' in loccond:
+                unit = loccond['unit']
+            else:
+                unit = ''	# No units
+        return unit
+
+    def size_plotreport(self):
+        self.update_idletasks()
+        width = self.plotframe.winfo_width()
+        height = self.plotframe.winfo_height()
+        if width < 3 * height:
+            self.plotframe.configure(width=height * 3)
+
+    def size_failreport(self):
+        # Attempt to set the datasheet viewer width to the interior width
+        # but do not set it larger than the available desktop.
+
+        self.update_idletasks()
+        width = self.mainarea.faildisplay.winfo_width()
+        screen_width = self.root.winfo_screenwidth()
+        if width > screen_width - 20:
+            self.mainarea.configure(width=screen_width - 20)
+        else:
+            self.mainarea.configure(width=width)
+
+        # Likewise for the height, up to the desktop height.  Note that this
+        # needs to account for both the button bar at the bottom of the GUI
+        # window plus the bar at the bottom of the desktop.
+        height = self.mainarea.faildisplay.winfo_height()
+        screen_height = self.root.winfo_screenheight()
+        if height > screen_height - 120:
+            self.mainarea.configure(height=screen_height - 120)
+        else:
+            self.mainarea.configure(height=height)
+
+    def table_to_histogram(self, globcond, filename):
+        # Switch from a table view to a histogram plot view, using the
+        # result as the X axis variable and count for the Y axis.
+
+        # Destroy existing contents.
+        for widget in self.plotframe.winfo_children():
+            widget.destroy()
+
+        param = self.data
+        plotrec = {}
+        plotrec['xaxis'] = param['method']
+        plotrec['xlabel'] = param['method']
+        plotrec['ylabel'] = 'COUNT'
+        plotrec['type'] = 'histogram'
+        if 'unit' in param:
+            plotrec['xlabel'] += ' (' + param['unit'] + ')'
+
+        results = param['results']
+
+        if 'variables' in param:
+            variables = param['variables']
+        else:
+            variables = []
+        # faild = self.mainarea.faildisplay	# definition for convenience
+        self.failframe.grid_forget()
+        self.plotframe.grid(row = 0, column = 0, sticky = 'nsew')
+        canvas = cace_makeplot.makeplot(plotrec, results, variables, parent = self.plotframe)
+        if 'display' in param:
+            ttk.Label(self.plotframe, text=param['display'], style='title.TLabel').grid(row=1, column=0)
+        canvas.show()
+        canvas.get_tk_widget().grid(row=0, column=0, sticky = 'nsew')
+        # Finally, open the window if it was not already open.
+        self.open()
+
+    def table_to_plot(self, condition, globcond, filename):
+        # Switch from a table view to a plot view, using the condname as
+        # the X axis variable.
+
+        # Destroy existing contents.
+        for widget in self.plotframe.winfo_children():
+            widget.destroy()
+
+        param = self.data
+        plotrec = {}
+        plotrec['xaxis'] = condition
+        plotrec['xlabel'] = condition
+        # Note: cace_makeplot adds text for units, if available
+        plotrec['ylabel'] = param['method']
+        plotrec['type'] = 'xyplot'
+
+        results = param['results']
+
+        if 'variables' in param:
+            variables = param['variables']
+        else:
+            variables = []
+
+        # faild = self.mainarea.faildisplay	# definition for convenience
+        self.failframe.grid_forget()
+        self.plotframe.grid(row = 0, column = 0, sticky = 'nsew')
+        canvas = cace_makeplot.makeplot(plotrec, results, variables, parent = self.plotframe)
+        if 'display' in param:
+            ttk.Label(self.plotframe, text=param['display'], style='title.TLabel').grid(row=1, column=0)
+        canvas.show()
+        canvas.get_tk_widget().grid(row=0, column=0, sticky = 'nsew')
+        # Display the button to return to the table view
+        # except for transient and Monte Carlo simulations which are too large to tabulate.
+        if not condition == 'TIME':
+            self.bbar.table_button.grid(column=1, row=0, padx = 5)
+            self.bbar.table_button.configure(command=lambda param=param, globcond=globcond,
+			filename=filename: self.display(param, globcond, filename))
+
+        # Finally, open the window if it was not already open.
+        self.open()
+
+    def display(self, param=None, globcond=None, filename=None):
+        # (Diagnostic)
+        # print('failure report:  passed parameter ' + str(param))
+
+        # Destroy existing contents.
+        for widget in self.mainarea.faildisplay.winfo_children():
+            widget.destroy()
+
+        if not param:
+            param = self.data
+
+        # 'param' is a dictionary pulled in from the annotate datasheet.
+        # If the failure display was called, then 'param' should contain
+        # record called 'results'.  If the parameter has no results, then
+        # there is nothing to do.
+
+        if filename and 'plot' in param:
+            simfiles = os.path.split(filename)[0] + '/ngspice/char/simulation_files/'
+            self.failframe.grid_forget()
+            self.plotframe.grid(row = 0, column = 0, sticky = 'nsew')
+
+            # Clear the plotframe and remake
+            for widget in self.plotframe.winfo_children():
+                widget.destroy()
+
+            plotrec = param['plot']
+            results = param['results']
+            if 'variables' in param:
+                variables = param['variables']
+            else:
+                variables = []
+            canvas = cace_makeplot.makeplot(plotrec, results, variables, parent = self.plotframe)
+            if 'display' in param:
+                ttk.Label(self.plotframe, text=param['display'],
+				style='title.TLabel').grid(row=1, column=0)
+            canvas.show()
+            canvas.get_tk_widget().grid(row=0, column=0, sticky = 'nsew')
+            self.data = param
+            # Display the button to return to the table view
+            self.bbar.table_button.grid(column=1, row=0, padx = 5)
+            self.bbar.table_button.configure(command=lambda param=param, globcond=globcond,
+			filename=filename: self.display(param, globcond, filename))
+
+        elif not 'results' in param:
+            print("No results to build a report with.")
+            return
+
+        else:
+            self.data = param
+            self.plotframe.grid_forget()
+            self.failframe.grid(column = 0, row = 0, sticky = 'nsew')
+            faild = self.mainarea.faildisplay	# definition for convenience
+            results = param['results']
+            names = results[0]
+            units = results[1]
+            results = results[2:]
+
+            # Check for transient simulation
+            if 'TIME' in names:
+                # Transient data are (usually) too numerous to tabulate, so go straight to plot
+                self.table_to_plot('TIME', globcond, filename)
+                return
+
+            # Check for Monte Carlo simulation
+            if 'ITERATIONS' in names:
+                # Monte Carlo data are too numerous to tabulate, so go straight to plot
+                self.table_to_histogram(globcond, filename)
+                return
+
+            # Numerically sort by result (to be done:  sort according to up/down
+            # criteria, which will be retained per header entry)
+            results.sort(key = lambda row: float(row[0]), reverse = self.sortdir)
+
+            # To get ranges, transpose the results matrix, then make unique
+            ranges = list(map(list, zip(*results)))
+            for r, vrange in enumerate(ranges):
+                try:
+                    vmin = min(float(v) for v in vrange)
+                    vmax = max(float(v) for v in vrange)
+                    if vmin == vmax:
+                        ranges[r] = [str(vmin)]
+                    else:
+                        ranges[r] = [str(vmin), str(vmax)]
+                except ValueError:
+                    ranges[r] = list(set(vrange))
+                    pass
+
+            faild.titlebar = ttk.Frame(faild)
+            faild.titlebar.grid(row = 0, column = 0, sticky = 'ewns')
+
+            faild.titlebar.label1 = ttk.Label(faild.titlebar, text = 'Electrical Parameter: ',
+			style = 'italic.TLabel')
+            faild.titlebar.label1.pack(side = 'left', padx = 6, ipadx = 3)
+            if 'display' in param:
+                faild.titlebar.label2 = ttk.Label(faild.titlebar, text = param['display'],
+			style = 'normal.TLabel')
+                faild.titlebar.label2.pack(side = 'left', padx = 6, ipadx = 3)
+                faild.titlebar.label3 = ttk.Label(faild.titlebar, text = '  Method: ',
+			style = 'italic.TLabel')
+                faild.titlebar.label3.pack(side = 'left', padx = 6, ipadx = 3)
+            faild.titlebar.label4 = ttk.Label(faild.titlebar, text = param['method'],
+			style = 'normal.TLabel')
+            faild.titlebar.label4.pack(side = 'left', padx = 6, ipadx = 3)
+
+            if 'min' in param:
+                if 'target' in param['min']:
+                    faild.titlebar.label7 = ttk.Label(faild.titlebar, text = '  Min Limit: ',
+			style = 'italic.TLabel')
+                    faild.titlebar.label7.pack(side = 'left', padx = 3, ipadx = 3)
+                    faild.titlebar.label8 = ttk.Label(faild.titlebar, text = param['min']['target'],
+    			style = 'normal.TLabel')
+                    faild.titlebar.label8.pack(side = 'left', padx = 6, ipadx = 3)
+                    if 'unit' in param:
+                        faild.titlebar.label9 = ttk.Label(faild.titlebar, text = param['unit'],
+				style = 'italic.TLabel')
+                        faild.titlebar.label9.pack(side = 'left', padx = 3, ipadx = 3)
+            if 'max' in param:
+                if 'target' in param['max']:
+                    faild.titlebar.label10 = ttk.Label(faild.titlebar, text = '  Max Limit: ',
+			style = 'italic.TLabel')
+                    faild.titlebar.label10.pack(side = 'left', padx = 6, ipadx = 3)
+                    faild.titlebar.label11 = ttk.Label(faild.titlebar, text = param['max']['target'],
+    			style = 'normal.TLabel')
+                    faild.titlebar.label11.pack(side = 'left', padx = 6, ipadx = 3)
+                    if 'unit' in param:
+                        faild.titlebar.label12 = ttk.Label(faild.titlebar, text = param['unit'],
+	    			style = 'italic.TLabel')
+                        faild.titlebar.label12.pack(side = 'left', padx = 3, ipadx = 3)
+
+            # Simplify view by removing constant values from the table and just listing them
+            # on the second line.
+
+            faild.constants = ttk.Frame(faild)
+            faild.constants.grid(row = 1, column = 0, sticky = 'ewns')
+            faild.constants.title = ttk.Label(faild.constants, text = 'Constant Conditions: ',
+			style = 'italic.TLabel')
+            faild.constants.title.grid(row = 0, column = 0, padx = 6, ipadx = 3)
+            j = 0
+            for condname, unit, range in zip(names, units, ranges):
+                if len(range) == 1:
+                    labtext = condname
+                    # unit = self.findunit(condname, param, globcond)
+                    labtext += ' = ' + range[0] + ' ' + unit + ' '
+                    row = int(j / 3)
+                    col = 1 + (j % 3)
+                    ttk.Label(faild.constants, text = labtext,
+				style = 'blue.TLabel').grid(row = row,
+				column = col, padx = 6, sticky = 'nsew')
+                    j += 1
+
+            body = ttk.Frame(faild, style = 'bg.TFrame')
+            body.grid(row = 2, column = 0, sticky = 'ewns')
+
+            # Print out names
+            j = 0
+            for condname, unit, range in zip(names, units, ranges):
+                # Now find the range for each entry from the global and local conditions.
+                # Use local conditions if specified, otherwise default to global condition.
+                # Each result is a list of three numbers for min, typ, and max.  List
+                # entries may be left unfilled.
+
+                if len(range) == 1:
+                    continue
+    
+                labtext = condname
+                plottext = condname
+                if j == 0:
+                    # Add unicode arrow up/down depending on sort direction
+                    labtext += ' \u21e9' if self.sortdir else ' \u21e7'
+                    header = ttk.Button(body, text=labtext, style = 'title.TButton',
+				command = self.changesort)
+                    tooltip.ToolTip(header, text='Reverse order of results')
+                else:
+                    header = ttk.Button(body, text=labtext, style = 'title.TLabel',
+				command = lambda plottext=plottext, globcond=globcond,
+				filename=filename: self.table_to_plot(plottext, globcond, filename))
+                    tooltip.ToolTip(header, text='Plot results with this condition on the X axis')
+                header.grid(row = 0, column = j, sticky = 'ewns')
+
+                # Second row is the measurement unit
+                # if j == 0:
+                #     # Measurement unit of result in first column
+                #     if 'unit' in param:
+                #         unit = param['unit']
+                #     else:
+                #         unit = ''    # No units
+                # else:
+                #     # Measurement unit of condition in other columns
+                #     # Find condition in local conditions else global conditions
+                #     unit = self.findunit(condname, param, globcond)
+
+                unitlabel = ttk.Label(body, text=unit, style = 'brown.TLabel')
+                unitlabel.grid(row = 1, column = j, sticky = 'ewns')
+
+                # (Pick up limits when all entries have been processed---see below)
+                j += 1
+
+            # Now list entries for each failure record.  These should all be in the
+            # same order.
+            m = 2
+            for result in results:
+                m += 1
+                j = 0
+                condition = result[0]
+                lstyle = 'normal.TLabel'
+                value = float(condition)
+                if 'min' in param:
+                    minrec = param['min']
+                    if 'calc' in minrec:
+                        calc = minrec['calc']
+                    else:
+                        calc = 'min'
+                    if self.check_failure(minrec, calc, value):
+                        lstyle = 'red.TLabel'
+                if 'max' in param:
+                    maxrec = param['max']
+                    if 'calc' in maxrec:
+                        calc = maxrec['calc']
+                    else:
+                        calc = 'max'
+                    if self.check_failure(maxrec, calc, value):
+                        lstyle = 'red.TLabel'
+
+                for condition, range in zip(result, ranges):
+                    if len(range) > 1:
+                        pname = ttk.Label(body, text=condition, style = lstyle)
+                        pname.grid(row = m, column = j, sticky = 'ewns')
+                        j += 1
+
+            # Row 2 contains the ranges of each column
+            j = 1
+            k = 1
+            for vrange in ranges[1:]:
+                if len(vrange) > 1:
+
+                    condlimits = '( '
+                
+                    # This is a bit of a hack;  results are assumed floating-point
+                    # unless they can't be resolved as a number.  So numerical values
+                    # that should be treated as integers or strings must be handled
+                    # here according to the condition type.
+                    if names[k].split(':')[0] == 'DIGITAL':
+                        for l in vrange:
+                            condlimits += str(int(float(l))) + ' '
+                    else:
+                        for l in vrange:
+                            condlimits += l + ' '
+                    condlimits += ')'
+                    header = ttk.Label(body, text=condlimits, style = 'blue.TLabel')
+                    header.grid(row = 2, column = j, sticky = 'ewns')
+                    j += 1
+                k += 1
+
+            # Add padding around widgets in the body of the failure report, so that
+            # the frame background comes through, making a grid.
+            for child in body.winfo_children():
+                child.grid_configure(ipadx = 5, ipady = 1, padx = 2, pady = 2)
+
+            # Resize the window to fit in the display, if necessary.
+            self.size_failreport()
+
+        # Don't put the button at the bottom to return to table view.
+        self.bbar.table_button.grid_forget()
+        # Finally, open the window if it was not already open.
+        self.open()
+
+    def changesort(self):
+        self.sortdir = False if self.sortdir == True else True
+        self.display(param=None)
+
+    def close(self):
+        # pop down failure report window
+        self.withdraw()
+
+    def open(self):
+        # pop up failure report window
+        self.deiconify()
+        self.lift()
diff --git a/common/foundry_nodes.py b/common/foundry_nodes.py
new file mode 100755
index 0000000..b431eff
--- /dev/null
+++ b/common/foundry_nodes.py
@@ -0,0 +1,131 @@
+#!/usr/bin/python
+
+# foundry_nodes.py ---
+#
+# This script runs in cloudV and discovers the set of foundry-
+# node names that are on the system and which support digital
+# synthesis.  It returns the list of discovered nodes in JSON
+# data format (output to stdout).
+#
+# Note that as this runs on the cloudV server, it is written
+# in python 2.7 syntax
+
+from __future__ import print_function
+
+import os
+import re
+import sys
+import json
+import glob
+
+def print_usage():
+     print('Usage: foundry_nodes.py [all|<tag>]')
+
+if __name__ == '__main__':
+    arguments = []
+    for item in sys.argv[1:]:
+        arguments.append(item)
+
+    dolist = 'active'
+    if len(arguments) == 1:
+        dolist = arguments[0]
+    elif len(arguments) != 0:
+        print_usage()
+        sys.exit(0)
+
+    # Search the /ef/ tree and find all foundry name (root name of the
+    # directory above the foundry node name).  If nothing is found,
+    # assume that this is the project name.  If the project name
+    # is already set, then generate an error.
+
+    proclist = []
+
+    for procnode in glob.glob('/ef/tech/*/*/.ef-config/nodeinfo.json'):
+        try:
+            with open(procnode, 'r') as ifile:
+                process = json.load(ifile)
+        except:
+            pass
+        else:
+            nodename = process['node']
+            rootpath = os.path.split(procnode)[0]
+            techroot = os.path.realpath(rootpath + '/techdir')
+            qflowlist = glob.glob(techroot + '/libs.tech/qflow/' + nodename + '*.sh')
+
+            
+            if 'status' in process:
+                status = process['status']
+            else:
+                # Default behavior is that nodes with "LEGACY" in the name
+                # are inactive, and all others are active.
+                if 'LEGACY' in nodename:
+                    status = 'legacy'
+                else:
+                    status = 'active'
+
+            # Do not record process nodes that do not match the status-match
+            # argument (unless the argument is "all").
+            if dolist != 'all':
+                if status != dolist:
+                    continue
+
+            # Try to find available standard cell sets if they are not marked
+            # as an entry in the nodeinfo.json file.  If they are an entry in nodeinfo.json,
+            # check if it is just a list of names.  If so, look up each name entry and
+            # expand it into a dictionary.  If it is already a dictionary, then just copy
+            # it to the output.
+            validcells = []
+            if 'stdcells' in process:
+                for name in process['stdcells'][:]:
+                    if type(name) is not dict:
+                        process['stdcells'].remove(name)
+                        validcells.append(name)
+
+            stdcells = []
+            for qflowdefs in qflowlist:
+                stdcellname = os.path.split(qflowdefs)[1]
+                validname = os.path.splitext(stdcellname)[0]
+                if validcells == [] or validname in validcells:
+
+                    stdcelldef = {}
+                    stdcelldef['name'] = validname
+
+                    # Read the qflow .sh file for the name of the preferred liberty format
+                    # file and the name of the verilog file.  Pull the path name from the
+                    # verilog file since there may be other supporting files that need to
+                    # be read from that path.
+
+                    # Diagnostic
+                    # print("Reading file " + qflowdefs, file=sys.stderr)
+                    with open(qflowdefs, 'r') as ifile:
+                        nodevars = ifile.read().splitlines()
+
+                    try:
+                        libline = next(item for item in nodevars if 'libertyfile' in item and item.strip()[0] != '#')
+                        lfile = libline.split('=')[1].strip()
+                        lfile = lfile.split()[0]
+                        stdcelldef['libertyfile'] = lfile
+                    except:
+                        pass
+
+                    try:
+                        vlgline = next(item for item in nodevars if 'verilogfile' in item and item.strip()[0] != '#')
+                        vfile = vlgline.split('=')[1].strip()
+                        vfile = vfile.split()[0]
+                        if os.path.exists(vfile):
+                            stdcelldef['verilogpath'] = os.path.split(vfile)[0]
+                        else:
+                            print("Warning:  bad verilogfile path " + vfile, file=sys.stderr)
+                    except:
+                        pass
+    
+                    stdcells.append(stdcelldef)
+
+            process['stdcells'] = stdcells
+            proclist.append(process)
+
+    if len(proclist) > 0:
+        json.dump(proclist, sys.stdout, indent=4)
+    else:
+        print("Error, no process nodes found!", file=sys.stderr)
+
diff --git a/common/helpwindow.py b/common/helpwindow.py
new file mode 100755
index 0000000..1f0e1a8
--- /dev/null
+++ b/common/helpwindow.py
@@ -0,0 +1,230 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#--------------------------------------------------------
+# Help Window for the Open Galaxy project manager
+#
+#--------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# September 12, 2016
+# Version 0.1
+#--------------------------------------------------------
+
+import re
+import tkinter
+from tkinter import ttk
+
+class HelpWindow(tkinter.Toplevel):
+    """Open Galaxy help window."""
+
+    def __init__(self, parent=None, fontsize = 11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        self.protocol("WM_DELETE_WINDOW", self.close)
+
+        self.withdraw()
+        self.title('Open Galaxy Help')
+
+        self.helptitle = ttk.Label(self, style='title.TLabel', text = '(no text)')
+        self.helptitle.grid(column = 0, row = 0, sticky = "news")
+        self.helpbar = ttk.Separator(self, orient='horizontal')
+        self.helpbar.grid(column = 0, row = 1, sticky = "news")
+
+        self.hframe = tkinter.Frame(self)
+        self.hframe.grid(column = 0, row = 2, sticky = "news")
+        self.hframe.helpdisplay = ttk.Frame(self.hframe)
+        self.hframe.helpdisplay.pack(side = 'left', fill = 'both', expand = 'true')
+
+        self.hframe.helpdisplay.helptext = tkinter.Text(self.hframe.helpdisplay, wrap='word')
+        self.hframe.helpdisplay.helptext.pack(side = 'top', fill = 'both', expand = 'true')
+        # Add scrollbar to help window
+        self.hframe.scrollbar = ttk.Scrollbar(self.hframe)
+        self.hframe.scrollbar.pack(side='right', fill='y')
+        # attach help window to scrollbar
+        self.hframe.helpdisplay.helptext.config(yscrollcommand = self.hframe.scrollbar.set)
+        self.hframe.scrollbar.config(command = self.hframe.helpdisplay.helptext.yview)
+
+        self.hframe.toc = ttk.Treeview(self.hframe, selectmode='browse')
+        self.hframe.toc.bind('<<TreeviewSelect>>', self.toc_to_page)
+        self.hframe.toc.bind('<<TreeviewOpen>>', self.toc_toggle)
+        self.hframe.toc.bind('<<TreeviewClose>>', self.toc_toggle)
+        self.hframe.toc.tag_configure('title', font=('Helvetica', fontsize, 'bold italic'),
+                        foreground = 'brown', anchor = 'center')
+        self.hframe.toc.heading('#0', text = "Table of Contents")
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 3, sticky = "news")
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=0, row=0, padx = 5)
+
+        self.bbar.prev_button = ttk.Button(self.bbar, text='Prev',
+		command=self.prevpage, style = 'normal.TButton')
+        self.bbar.prev_button.grid(column=1, row=0, padx = 5)
+
+        self.bbar.next_button = ttk.Button(self.bbar, text='Next',
+		command=self.nextpage, style = 'normal.TButton')
+        self.bbar.next_button.grid(column=2, row=0, padx = 5)
+
+        self.bbar.contents_button = ttk.Button(self.bbar, text='Table of Contents',
+		command=self.page_to_toc, style = 'normal.TButton')
+        self.bbar.contents_button.grid(column=3, row=0, padx = 5)
+
+        self.rowconfigure(0, weight=0)
+        self.rowconfigure(1, weight=0)
+        self.rowconfigure(2, weight=1)
+        self.rowconfigure(3, weight=0)
+        self.columnconfigure(0, weight=1)
+
+        # Help pages
+        self.pages = []
+        self.pageno = -1		# No page
+        self.toggle = False
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def redisplay(self):
+        # remove contents
+        if self.pageno >= 0 and self.pageno < len(self.pages):
+            self.hframe.helpdisplay.helptext.delete('1.0', 'end')
+            self.hframe.helpdisplay.helptext.insert('end', self.pages[self.pageno]['text'])
+            self.helptitle.configure(text = self.pages[self.pageno]['title'])
+
+    def toc_toggle(self, event):
+        self.toggle = True
+
+    def toc_to_page(self, event):
+        treeview = event.widget
+        selection = treeview.item(treeview.selection())
+
+        # Make sure any open/close callback is handled first!
+        self.update_idletasks()
+        if self.toggle:
+            # Item was opened or closed, so consider this a 'false select' and
+            # do not go to the page.
+            self.toggle = False
+            return
+
+        if 'values' in selection:
+            pagenum = selection['values'][0]
+        else:
+            print('Unknown page selected.')
+            pagenum = 0
+
+        # Display a page after displaying the table of contents
+        self.hframe.toc.pack_forget()
+        self.hframe.scrollbar.pack_forget()
+        self.hframe.helpdisplay.pack(side='left', fill='both', expand = 'true')
+        self.hframe.scrollbar.pack(side='right', fill='y')
+        self.hframe.scrollbar.config(command = self.hframe.helpdisplay.helptext.yview)
+        # Enable Prev and Next buttons
+        self.bbar.prev_button.configure(state='enabled')
+        self.bbar.next_button.configure(state='enabled')
+        # Redisplay
+        self.page(pagenum)
+
+    def page_to_toc(self):
+        # Display the table of contents after displaying a page
+        self.hframe.scrollbar.pack_forget()
+        self.hframe.helpdisplay.pack_forget()
+        self.hframe.toc.pack(side='left', fill='both', expand = 'true')
+        self.hframe.scrollbar.pack(side='right', fill='y')
+        self.hframe.scrollbar.config(command = self.hframe.toc.yview)
+        # Disable Prev and Next buttons
+        self.bbar.prev_button.configure(state='disabled')
+        self.bbar.next_button.configure(state='disabled')
+
+    # Simple add page with a single block of plain text
+    def add_page(self, toc_text, text_block):
+        newdict = {}
+        newdict['text'] = text_block
+        newdict['title'] = toc_text
+        self.pages.append(newdict)
+        newpageno = len(self.pages)
+        self.hframe.toc.insert('', 'end', text=str(newpageno) + '.  ' + toc_text,
+		tag='title', value = newpageno - 1)
+        if self.pageno < 0:
+            self.pageno = 0	# First page
+
+    # Fill the help text from a file.  The format of the file is:
+    # <page_num>
+    # <title>
+    # <text>
+    # '.'
+    # Text is multi-line and ends when '.' is encountered by itself
+
+    def add_pages_from_file(self, filename):
+        endpagerex = re.compile('^\.$')
+        newpagerex = re.compile('^[0-9\.]+$')
+        commentrex = re.compile('^[\-]+$')
+        hierarchy = ''
+        print('Loading help text from file ' + filename)
+        with open(filename, 'r') as f:
+            toc_text = []
+            page_text = []
+            for line in f:
+                if newpagerex.match(line) or endpagerex.match(line):
+                    if toc_text and page_text:
+                        newdict = {}
+                        self.pages.append(newdict)
+                        newpageno = len(self.pages)
+                        if '.' in hierarchy:
+                            pageinfo = hierarchy.rsplit('.', 1)
+                            if pageinfo[1] == '':
+                                parentid = ''
+                                pageid = pageinfo[0]
+                            else:
+                                parentid = pageinfo[0]
+                                pageid = pageinfo[1]
+                        else:
+                            parentid = ''
+                            pageid = hierarchy
+                        if parentid:
+                            pageid = parentid + '.' + pageid
+                        newdict['text'] = page_text
+                        newdict['title'] = pageid + '.  ' + toc_text
+                        self.hframe.toc.insert(parentid, 'end',
+				text=newdict['title'], tag='title',
+				value = newpageno - 1, iid = pageid)
+                    if newpagerex.match(line):
+                        hierarchy = line.rstrip()
+                        toc_text = []
+                elif not toc_text:
+                    toc_text = line.rstrip()
+                    page_text = []
+                elif not commentrex.match(line):
+                    if not page_text:
+                        page_text = line
+                    else:
+                        page_text += line
+
+    def nextpage(self):
+        # Go to next page
+        if self.pageno < len(self.pages) - 1:
+            self.pageno += 1
+            self.redisplay()
+
+    def prevpage(self):
+        # Go to previous page
+        if self.pageno > 0:
+            self.pageno -= 1
+            self.redisplay()
+
+    def page(self, pagenum):
+        # Go to indicated page
+        if pagenum >= 0 and pagenum < len(self.pages):
+            self.pageno = pagenum 
+            self.redisplay()
+
+    def close(self):
+        # pop down help window
+        self.withdraw()
+
+    def open(self):
+        # pop up help window
+        self.deiconify()
+        self.lift()
diff --git a/common/listboxchoice.py b/common/listboxchoice.py
new file mode 100755
index 0000000..df6b895
--- /dev/null
+++ b/common/listboxchoice.py
@@ -0,0 +1,58 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+# Simple listbox with scrollbar and select button
+
+import re
+import tkinter
+from tkinter import ttk
+
+class ListBoxChoice(ttk.Frame):
+    def __init__(self, parent, fontsize=11, *args, **kwargs):
+        ttk.Frame.__init__(self, parent, *args, **kwargs)
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+
+    def populate(self, title, list=[]):
+        self.value = None
+        self.list = list[:]
+        
+        ttk.Label(self, text=title).pack(padx=5, pady=5)
+
+        listFrame = ttk.Frame(self)
+        listFrame.pack(side='top', padx=5, pady=5, fill='both', expand='true')
+        
+        scrollBar = ttk.Scrollbar(listFrame)
+        scrollBar.pack(side='right', fill='y')
+        self.listBox = tkinter.Listbox(listFrame, selectmode='single')
+        self.listBox.pack(side='left', fill='x', expand='true')
+        scrollBar.config(command=self.listBox.yview)
+        self.listBox.config(yscrollcommand=scrollBar.set)
+        self.list.sort(key=natsort_key)
+        for item in self.list:
+            self.listBox.insert('end', item)
+
+        if len(self.list) == 0:
+            self.listBox.insert('end', '(no items)')
+        else:
+            buttonFrame = ttk.Frame(self)
+            buttonFrame.pack(side='bottom')
+
+            selectButton = ttk.Button(buttonFrame, text="Select", command=self._select,
+			style='normal.TButton')
+            selectButton.pack(side='left', padx = 5)
+            listFrame.bind("<Return>", self._select)
+
+    def natsort_key(s, _nsre=re.compile('([0-9]+)')):
+        # 'natural' sort function.  To make this alphabetical independently of
+        # capitalization, use "else text.lower()" instead of "else text" below.
+        return [int(text) if text.isdigit() else text for text in _nsre.split(s)]
+
+    def _select(self, event=None):
+        try:
+            firstIndex = self.listBox.curselection()[0]
+            self.value = self.list[int(firstIndex)]
+        except IndexError:
+            self.value = None
+
+    def value(self):
+        return self.value
diff --git a/common/make_icon_from_soft.py b/common/make_icon_from_soft.py
new file mode 100644
index 0000000..b866463
--- /dev/null
+++ b/common/make_icon_from_soft.py
@@ -0,0 +1,617 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3 -B
+#--------------------------------------------------------
+# make_icon_from_soft.py --
+#
+# Create an electric icon (manually) from information taken from
+# a verilog module.
+#-----------------------------------------------------------------
+
+import os
+import re
+import sys
+import json
+import datetime
+import subprocess
+
+def create_symbol(projectpath, verilogfile, project, destfile=None, debug=False, dolist=False):
+    if not os.path.exists(projectpath):
+        print('No path to project ' + projectpath)
+        return 1
+
+    if not os.path.isfile(verilogfile):
+        print('No path to verilog file ' + verilogfile)
+        return 1
+
+    if not os.path.exists(projectpath + '/elec'):
+        print('No electric subdirectory /elec/ in project.')
+        return 1
+
+    if not destfile:
+
+        delibdir = projectpath + '/elec/' + project + '.delib'
+        if not os.path.isdir(delibdir):
+            print('No electric library ' + project + '.delib in project.')
+            return 1
+
+        if os.path.isfile(delibdir + '/' + project + '.ic'):
+            print('Symbol file ' + project + '.ic exists already.')
+            print('Please remove it if you want to overwrite it.')
+            return 1
+
+        # By default, put the icon file in the project's electric library
+        destfile = projectpath + '/elec/' + project + '.delib/' + project + '.ic'
+        desthdr = projectpath + '/elec/' + project + '.delib/header'
+
+    else:
+        if os.path.isfile(destfile):
+            print('Symbol file ' + project + '.ic exists already.')
+            print('Please remove it if you want to overwrite it.')
+            return 1
+
+        destdir = os.path.split(destfile)[0]
+        desthdr = destdir + '/header'
+        if not os.path.isdir(destdir):
+            os.makedirs(destdir)
+
+    # Original verilog source can be very complicated to parse.  Run through
+    # qflow's vlog2Verilog tool to get a much simplified header, which also
+    # preprocesses the verilog, handles parameters, etc.
+
+    vdir = os.path.split(verilogfile)[0]
+    vtempfile = vdir + '/vtemp.out'
+    p = subprocess.run(['/ef/apps/ocd/qflow/current/share/qflow/bin/vlog2Verilog',
+			'-p', '-o', vtempfile, verilogfile], stdout = subprocess.PIPE)
+
+    if not os.path.exists(vtempfile):
+        print('Error:  Failed to create preprocessed verilog from ' + verilogfile)
+        return 1
+
+    # Okay, ready to go.  Now read the verilog source file and get the list
+    # of pins.
+
+    commstr1 = '/\*.*\*/'
+    commstr2 = '//[^\n]*\n'
+    c1rex = re.compile(commstr1)
+    c2rex = re.compile(commstr2)
+
+    # Find and isolate the module and its pin list.
+    modstr = 'module[ \t]+' + project + '[ \t]*\(([^\)]+)\)[ \t\n]*;'
+    modrex = re.compile(modstr)
+
+    # End parsing on any of these tokens
+    endrex = re.compile('[ \t]*(initial|function|task|always)')
+
+    inpins = []
+    outpins = []
+    iopins = []
+    invecs = []
+    outvecs = []
+    iovecs = []
+
+    with open(vtempfile, 'r') as ifile:
+        vlines = ifile.read()
+
+        # Remove comments
+        vlines2 = c2rex.sub('\n', c1rex.sub('', vlines))
+
+        # Find and isolate the module pin list
+        modpinslines = modrex.findall(vlines2)
+
+        modpinsstart = modrex.search(vlines2)
+        if modpinsstart:
+            startc = modpinsstart.span()[0]
+        else:
+            startc = 0
+        modpinsend = endrex.search(vlines2[startc:])
+        if modpinsend:
+            endc = modpinsend.span()[0]
+        else:
+            endc = len(vlines2)
+
+        vlines2 = vlines2[startc:endc]
+
+        # Find the module (there should be only one) and get pins if in the
+        # format with input / output declarations in the module heading.
+
+        pinlist = []
+        if len(modpinslines) > 0:
+            modpins = modpinslines[0]
+            pinlist = re.sub('[\t\n]', '', modpins).split(',')
+
+        # If each pinlist entry is only one word, then look for following
+        # lines "input", "output", etc., and compile them into a similar
+        # list.  Then parse each list entry.
+
+        knownreal = {}
+        knownpower = {}
+        knownground = {}
+            
+        if len(pinlist) > 0 and len(pinlist[0].split()) == 1:
+
+            invecrex  = re.compile('\n[ \t]*input[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*([^;]+);')
+            insigrex  = re.compile('\n[ \t]*input[ \t]+([^\[;]+);')
+            outvecrex = re.compile('\n[ \t]*output[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*([^;]+);')
+            outsigrex = re.compile('\n[ \t]*output[ \t]+([^;\[]+);')
+            iovecrex  = re.compile('\n[ \t]*inout[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*([^;]+);')
+            iosigrex  = re.compile('\n[ \t]*inout [ \t]+([^;\[]+);')
+
+            # Find input, output, and inout lines
+            for test in insigrex.findall(vlines2):
+                pinname = list(item.strip() for item in test.split(','))
+                inpins.extend(pinname)
+            for test in outsigrex.findall(vlines2):
+                pinname = list(item.strip() for item in test.split(','))
+                outpins.extend(pinname)
+            for test in iosigrex.findall(vlines2):
+                pinname = list(item.strip() for item in test.split(','))
+                iopins.extend(pinname)
+            for test in invecrex.finditer(vlines2):
+                tpin = test.group(3).split(',')
+                for pin in tpin:
+                    pinname = pin.strip() + '[' + test.group(1) + ':' + test.group(2) + ']'
+                    invecs.append(pinname)
+            for test in outvecrex.finditer(vlines2):
+                tpin = test.group(3).split(',')
+                for pin in tpin:
+                    pinname = pin.strip() + '[' + test.group(1) + ':' + test.group(2) + ']'
+                    outvecs.append(pinname)
+            for test in iovecrex.finditer(vlines2):
+                tpin = test.group(3).split(',')
+                for pin in tpin:
+                    pinname = pin.strip() + '[' + test.group(1) + ':' + test.group(2) + ']'
+                    iovecs.append(pinname)
+          
+            # Apply syntax checks (to do:  check for "real" above)
+            powerrec = re.compile('VDD|VCC', re.IGNORECASE)
+            groundrec = re.compile('VSS|GND|GROUND', re.IGNORECASE)
+            for pinname in inpins + outpins + iopins + invecs + outvecs + iovecs: 
+                pmatch = powerrec.match(pinname)
+                gmatch = groundrec.match(pinname)
+                if pmatch:
+                    knownpower[pinname] = True
+                if gmatch:
+                    knownground[pinname] = True
+        else:
+
+            # Get pin lists from module pin list.  These are simpler to
+            # parse, since they have to be enumerated one by one.
+
+            invecrex  = re.compile('[ \t]*input[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*(.+)')
+            insigrex  = re.compile('[ \t]*input[ \t]+([a-zA-Z_][^ \t]+)')
+            outvecrex = re.compile('[ \t]*output[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*(.+)')
+            outsigrex = re.compile('[ \t]*output[ \t]+([a-zA-Z_][^ \t]+)')
+            iovecrex  = re.compile('[ \t]*inout[ \t]*\[[ \t]*([0-9]+)[ \t]*:[ \t]*([0-9]+)[ \t]*\][ \t]*(.+)')
+            iosigrex  = re.compile('[ \t]*inout[ \t]+([a-zA-Z_][^ \t]+)')
+            realrec = re.compile('[ \t]+real[ \t]+')
+            logicrec = re.compile('[ \t]+logic[ \t]+')
+            wirerec = re.compile('[ \t]+wire[ \t]+')
+            powerrec = re.compile('VDD|VCC', re.IGNORECASE)
+            groundrec = re.compile('VSS|GND|GROUND', re.IGNORECASE)
+
+            for pin in pinlist:
+                # Pull out any reference to "real", "logic", or "wire" to get pin name
+                ppin = realrec.sub(' ', logicrec.sub(' ', wirerec.sub(' ', pin.strip())))
+                pinname = None
+
+                # Make syntax checks
+                rmatch = realrec.match(pin)
+                pmatch = powerrec.match(pin)
+                gmatch = groundrec.match(pin)
+
+                imatch = insigrex.match(ppin)
+                if imatch:
+                   pinname = imatch.group(1)
+                   inpins.append(pinname)
+                omatch = outsigrex.match(ppin)
+                if omatch:
+                   pinname = omatch.group(1)
+                   outpins.append(pinname)
+                bmatch = iosigrex.match(ppin)
+                if bmatch:
+                   pinname = bmatch.group(1)
+                   iopins.append(pinname)
+                ivmatch = invecrex.match(ppin)
+                if ivmatch:
+                   pinname = ivmatch.group(3) + '[' + ivmatch.group(1) + ':' + ivmatch.group(2) + ']'
+                   invecs.append(pinname)
+                ovmatch = outvecrex.match(ppin)
+                if ovmatch:
+                   pinname = ovmatch.group(3) + '[' + ovmatch.group(1) + ':' + ovmatch.group(2) + ']'
+                   outvecs.append(pinname)
+                bvmatch = iovecrex.match(ppin)
+                if bvmatch:
+                   pinname = bvmatch.group(3) + '[' + bvmatch.group(1) + ':' + bvmatch.group(2) + ']'
+                   iovecs.append(pinname)
+
+                # Apply syntax checks
+                if pinname:
+                    if rmatch:
+                        knownreal[pinname] = True
+                    if pmatch and rmatch:
+                        knownpower[pinname] = True
+                    if gmatch and rmatch:
+                        knownground[pinname] = True
+
+    if (os.path.exists(vtempfile)):
+        os.remove(vtempfile)
+
+    if len(inpins) + len(outpins) + len(iopins) + len(invecs) + len(outvecs) + len(iovecs) == 0:
+        print('Failure to parse pin list for module ' + project + ' out of verilog source.')
+        return 1
+
+    if debug:
+        print("Input pins of module " + project + ":")
+        for pin in inpins:
+            print(pin)
+        print("Output pins of module " + project + ":")
+        for pin in outpins:
+            print(pin)
+        print("Bidirectional pins of module " + project + ":")
+        for pin in iopins:
+            print(pin)
+    
+    # If "dolist" is True, then create a list of pin records in the style used by
+    # project.json, and return the list.
+
+    if dolist == True:
+        pinlist = []
+        for pin in inpins:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownreal:
+                pinrec["type"] = 'signal'
+            else:
+                pinrec["type"] = 'digital'
+            pinrec["Vmin"] = "-0.5"
+            pinrec["Vmax"] = "VDD + 0.3"
+            pinrec["dir"] = "input"
+            pinlist.append(pinrec)
+        for pin in outpins:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownreal:
+                pinrec["type"] = 'signal'
+            else:
+                pinrec["type"] = 'digital'
+            pinrec["Vmin"] = "-0.5"
+            pinrec["Vmax"] = "VDD + 0.3"
+            pinrec["dir"] = "output"
+            pinlist.append(pinrec)
+        for pin in iopins:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownpower:
+                pinrec["type"] = 'power'
+                pinrec["Vmin"] = "3.6"
+                pinrec["Vmax"] = "3.0"
+            elif pin in knownground:
+                pinrec["type"] = 'ground'
+                pinrec["Vmin"] = "0"
+                pinrec["Vmax"] = "0"
+            elif pin in knownreal:
+                pinrec["type"] = 'signal'
+                pinrec["Vmin"] = "-0.5"
+                pinrec["Vmax"] = "VDD + 0.3"
+            else:
+                pinrec["type"] = 'digital'
+                pinrec["Vmin"] = "-0.5"
+                pinrec["Vmax"] = "VDD + 0.3"
+            pinrec["dir"] = "inout"
+            pinlist.append(pinrec)
+        for pin in invecs:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownreal:
+                pinrec["type"] = 'signal'
+            else:
+                pinrec["type"] = 'digital'
+            pinrec["dir"] = "input"
+            pinrec["Vmin"] = "-0.5"
+            pinrec["Vmax"] = "VDD + 0.3"
+            pinlist.append(pinrec)
+        for pin in outvecs:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownreal:
+                pinrec["type"] = 'signal'
+            else:
+                pinrec["type"] = 'digital'
+            pinrec["dir"] = "output"
+            pinrec["Vmin"] = "-0.5"
+            pinrec["Vmax"] = "VDD + 0.3"
+            pinlist.append(pinrec)
+        for pin in iovecs:
+            pinrec = {}
+            pinrec["name"] = pin
+            pinrec["description"] = "(add description here)"
+            if pin in knownpower:
+                pinrec["type"] = 'power'
+                pinrec["Vmin"] = "3.6"
+                pinrec["Vmax"] = "3.0"
+            elif pin in knownground:
+                pinrec["type"] = 'ground'
+                pinrec["Vmin"] = "0"
+                pinrec["Vmax"] = "0"
+            elif pin in knownreal:
+                pinrec["type"] = 'signal'
+                pinrec["Vmin"] = "-0.5"
+                pinrec["Vmax"] = "VDD + 0.3"
+            else:
+                pinrec["type"] = 'digital'
+                pinrec["Vmin"] = "-0.5"
+                pinrec["Vmax"] = "VDD + 0.3"
+            pinrec["dir"] = "inout"
+            pinlist.append(pinrec)
+    
+        return pinlist
+
+    # Okay, we've got all the pins, now build the symbol.
+
+    leftpins = len(inpins) + len(invecs)
+    rightpins = len(outpins) + len(outvecs)
+    # Arbitrarily, bidirectional pins are put on bottom and vectors on top.
+    toppins = len(iovecs)
+    botpins = len(iopins)
+
+    height = 2 + max(leftpins, rightpins) * 10
+    width = 82 + max(toppins, botpins) * 10
+
+    # Enforce minimum height (minimum width enforced above)
+    if height < 40:
+        height = 40
+
+    # Run electric -v to get version string
+    p = subprocess.run(['/ef/apps/bin/electric', '-v'], stdout = subprocess.PIPE)
+    vstring = p.stdout.decode('utf-8').rstrip()
+
+    # Get timestamp
+    timestamp = str(int(datetime.datetime.now().strftime("%s")) * 1000)
+
+    with open(destfile, 'w') as ofile:
+        print('H' + project + '|' + vstring, file=ofile)
+        print('', file=ofile)
+        print('# Cell ' + project + ';1{ic}', file=ofile)
+        print('C' + project + ';1{ic}||artwork|' + timestamp + '|' + timestamp + '|E', file=ofile)
+        print('Ngeneric:Facet-Center|art@0||0|0||||AV', file=ofile)
+        print('NBox|art@1||0|0|' + str(width) + '|' + str(height) + '||', file=ofile)
+        pnum = 0
+
+        # Title
+        print('Ngeneric:Invisible-Pin|pin@' + str(pnum) + '||0|5|||||ART_message(BD5G5;)S' + project, file=ofile)
+
+        pnum += 1
+        # Fill in left side pins
+        px = -(width / 2)
+        py = -(height / 2) + 5
+        for pin in inpins:
+            print('Nschematic:Wire_Pin|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            py += 10
+        for pin in invecs:
+            print('Nschematic:Bus_Pin|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            py += 10
+
+        # Fill in right side pins
+        px = (width / 2)
+        py = -(height / 2) + 5
+        for pin in outpins:
+            print('Nschematic:Wire_Pin|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            py += 10
+        for pin in outvecs:
+            print('Nschematic:Bus_Pin|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            py += 10
+
+        # Fill in bottom side pins
+        py = -(height / 2)
+        px = -(width / 2) + 45
+        for pin in iopins:
+            print('Nschematic:Wire_Pin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py - 10) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py - 10) + '|1|1||', file=ofile)
+            pnum += 1
+            px += 10
+
+        # Fill in top side pins
+        py = (height / 2)
+        px = -(width / 2) + 45
+        for pin in iovecs:
+            print('Nschematic:Bus_Pin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py + 10) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|1|1||', file=ofile)
+            pnum += 1
+            print('NPin|pin@' + str(pnum) + '||' + str(px) + '|' + str(py + 10) + '|1|1||', file=ofile)
+            pnum += 1
+            px += 10
+
+        # Start back at pin 1 and retain the same order when drawing wires
+        pnum = 1
+        nnum = 0
+
+        px = -(width / 2)
+        py = -(height / 2) + 5
+        for pin in inpins:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py), file=ofile)
+            pnum += 2
+            nnum += 1
+            py += 10
+
+        for pin in invecs:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px - 10) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py), file=ofile)
+            pnum += 2
+            nnum += 1
+            py += 10
+
+        px = (width / 2)
+        py = -(height / 2) + 5
+        for pin in outpins:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py), file=ofile)
+            pnum += 2
+            nnum += 1
+            py += 10
+
+        for pin in outvecs:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px + 10) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py), file=ofile)
+            pnum += 2
+            nnum += 1
+            py += 10
+
+        py = -(height / 2)
+        px = -(width / 2) + 45 
+        for pin in iopins:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py - 10), file=ofile)
+            pnum += 2
+            nnum += 1
+            px += 10
+
+        py = (height / 2)
+        px = -(width / 2) + 45
+        for pin in iovecs:
+            pnum += 1
+            print('ASolid|net@' + str(nnum) + '|||FS0|pin@' + str(pnum) + '||' + str(px) + '|' + str(py) + '|pin@' + str(pnum + 1) + '||' + str(px) + '|' + str(py + 10), file=ofile)
+            pnum += 2
+            nnum += 1
+            px += 10
+
+        # Add the exports (which are the only nontrivial elements)
+        pnum = 1
+        for pin in inpins:
+            print('E' + pin + '||D6G4;X12.0;Y0.0;|pin@' + str(pnum) + '||I', file=ofile)
+            pnum += 3
+        for pin in invecs:
+            print('E' + pin + '||D6G4;X12.0;Y0.0;|pin@' + str(pnum) + '||I', file=ofile)
+            pnum += 3
+        for pin in outpins:
+            print('E' + pin + '||D4G4;X-12.0;Y0.0;|pin@' + str(pnum) + '||O', file=ofile)
+            pnum += 3
+        for pin in outvecs:
+            print('E' + pin + '||D4G4;X-12.0;Y0.0;|pin@' + str(pnum) + '||O', file=ofile)
+            pnum += 3
+        for pin in iopins:
+            print('E' + pin + '||D6G4;RX0.0;Y12.0;|pin@' + str(pnum) + '||B', file=ofile)
+            pnum += 3
+        for pin in iovecs:
+            print('E' + pin + '||D6G4;RRRX0.0;Y-12.0;|pin@' + str(pnum) + '||B', file=ofile)
+            pnum += 3
+
+        # X marks the spot, or at least the end.
+        print('X', file=ofile)
+
+    if not os.path.isfile(desthdr):
+        with open(desthdr, 'w') as ofile:
+            print('# header information:', file=ofile)
+            print('H' + project + '|' + vstring, file=ofile)
+            print('', file=ofile)
+            print('# Views:', file=ofile)
+            print('Vicon|ic', file=ofile)
+            print('', file=ofile)
+            print('# Tools:', file=ofile)
+            print('Ouser|DefaultTechnology()Sschematic', file=ofile)
+            print('Osimulation|VerilogUseAssign()BT', file=ofile)
+            print('C____SEARCH_FOR_CELL_FILES____', file=ofile)
+
+    return 0
+
+def usage():
+    print("make_icon_from_soft.py <project_path> [<verilog_source>] [<output_file>]")
+    print("")
+    print("   where <project_path> is the path to a standard efabless project, and")
+    print("   <verilog_source> is the path to a verilog source file.")
+    print("")
+    print("   The module name must be the same as the project's ip-name.")
+    print("")
+    print("   <verilog_source> is assumed to be in verilog/source/<ip-name>.v by")
+    print("   default if not otherwise specified.")
+    print("")
+    print("   If <output_file> is not specified, output goes in the project's")
+    print("   electric library.")
+    print("")
+
+if __name__ == '__main__':
+    arguments = []
+    options = []
+
+    for item in sys.argv[1:]:
+        if item[0] == '-':
+            options.append(item.strip('-'))
+        else:
+            arguments.append(item)
+
+    debug = True if 'debug' in options else False
+
+    numarg = len(arguments)
+    if numarg > 3 or numarg == 0:
+        usage()
+        sys.exit(0)
+
+    projectpath = arguments[0]
+
+    projdirname = os.path.split(projectpath)[1]
+    jsonfile = projectpath + '/project.json'
+    if not os.path.isfile(jsonfile):
+        # Legacy behavior is to have the JSON file name the same as the directory name.
+        jsonfile = projectpath + '/' + projdirname + '.json'
+        if not os.path.isfile(jsonfile):
+            print('Error:  No project JSON file found for project ' + projdirname)
+            sys.exit(1)
+
+    project = None
+    with open(jsonfile, 'r') as ifile:
+        datatop = json.load(ifile)
+        dsheet = datatop['data-sheet']
+        project = dsheet['ip-name']
+
+    if not project:
+        print('Error:  No project IP name in project JSON file.')
+        sys.exit(1)
+
+    if numarg > 1:
+        verilogfile = arguments[1]
+    else:
+        verilogfile = projectpath + '/verilog/source/' + project + '.v'
+        if not os.path.exists(verilogfile):
+            print('Error:  No verilog file ' + verilogfile + ' found.')
+            print('Please specify full path as 2nd argument.')
+            sys.exit(1)
+
+    if numarg > 2:
+        destfile = arguments[2]
+    else:
+        destfile = projectpath + '/elec/' + project + '.delib/' + project + '.ic'
+        if os.path.exists(destfile):
+            print('Error:  Icon file ' + destfile + ' already exists.')
+            print('Please delete any unwanted original before running this script.')
+            sys.exit(1)
+
+    result = create_symbol(projectpath, verilogfile, project, destfile, debug)
+    sys.exit(result)
diff --git a/common/og_config.py b/common/og_config.py
new file mode 100755
index 0000000..ee9a986
--- /dev/null
+++ b/common/og_config.py
@@ -0,0 +1,33 @@
+# Configuration values for the OpenGalaxy machines
+# Select config-file directed by OPTIONAL (INI file): /etc/sysconfig/ef-config ef_variant= line.
+# File should: have no [section] header, use "# " comments, var="val" (no spaces around =),
+# no dash in var-names, for good compatibility between python and bash.
+#
+# default if fail to read/parse the etc file is STAGING. These values:
+#          ef_variant=DEV       ef_variant=STAGING       ef_variant=PROD
+# yield respectively:
+#    import og_config_DEV import og_config_STAGING import og_config_PROD
+#
+# Survive (try:) missing,improper,unreadable /etc/sysconfig/ef-config.
+# DO NOT survive (no try:) failed imports (non-existent file, bad syntax, etc.).
+
+#
+# look-up ef_variant=... in optional etc file, default to STAGING
+#
+import configparser
+#TODO: replace path with PREFIX
+apps_path="/usr/share/pdk/bin"
+#apps_path="PREFIX/pdk/bin"
+
+config = configparser.ConfigParser(strict=False, allow_no_value=True)
+try:
+    config.read_string("[null]\n"+open("/etc/sysconfig/ef-config").read())
+except:
+    pass
+ef_variant = config.get('null','ef_variant', fallback='STAGING').strip('\'"')
+
+#
+# emulate: import og_config_<ef_variant>
+#
+#cfgModule = __import__("og_config_"+ef_variant)
+#globals().update(vars(cfgModule))
diff --git a/common/og_gui_characterize.py b/common/og_gui_characterize.py
new file mode 100755
index 0000000..1c56555
--- /dev/null
+++ b/common/og_gui_characterize.py
@@ -0,0 +1,1781 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3 -B
+#
+#--------------------------------------------------------
+# Open Galaxy Project Manager GUI.
+#
+# This is a Python tkinter script that handles local
+# project management.  Much of this involves the
+# running of ng-spice for characterization, allowing
+# the user to determine where a circuit is failing
+# characterization;  and when the design passes local
+# characterization, it may be submitted to the
+# marketplace for official characterization.
+#
+#--------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# September 9, 2016
+# Version 1.0
+#--------------------------------------------------------
+
+import io
+import re
+import os
+import sys
+import copy
+import json
+import time
+import signal
+import select
+import datetime
+import contextlib
+import subprocess
+import faulthandler
+
+import tkinter
+from tkinter import ttk
+from tkinter import filedialog
+
+import tksimpledialog
+import tooltip
+from consoletext import ConsoleText
+from helpwindow import HelpWindow
+from failreport import FailReport
+from textreport import TextReport
+from editparam import EditParam
+from settings import Settings
+from simhints import SimHints
+
+import og_config
+
+# User preferences file (if it exists)
+prefsfile = '~/design/.profile/prefs.json'
+
+#------------------------------------------------------
+# Simple dialog for confirming quit or upload
+#------------------------------------------------------
+
+class ConfirmDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed):
+        ttk.Label(master, text=warning, wraplength=500).grid(row = 0, columnspan = 2, sticky = 'wns')
+        return self
+
+    def apply(self):
+        return 'okay'
+
+#------------------------------------------------------
+# Simple dialog with no "OK" button (can only cancel)
+#------------------------------------------------------
+
+class PuntDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed):
+        if warning:
+            ttk.Label(master, text=warning, wraplength=500).grid(row = 0, columnspan = 2, sticky = 'wns')
+        return self
+
+    def buttonbox(self):
+        # Add button box with "Cancel" only.
+        box = ttk.Frame(self.obox)
+        w = ttk.Button(box, text="Cancel", width=10, command=self.cancel)
+        w.pack(side='left', padx=5, pady=5)
+        self.bind("<Escape>", self.cancel)
+        box.pack(fill='x', expand='true')
+
+    def apply(self):
+        return 'okay'
+
+#------------------------------------------------------
+# Main class for this application
+#------------------------------------------------------
+
+class OpenGalaxyCharacterize(ttk.Frame):
+    """Open Galaxy local characterization GUI."""
+
+    def __init__(self, parent, *args, **kwargs):
+        ttk.Frame.__init__(self, parent, *args, **kwargs)
+        self.root = parent
+        self.init_gui()
+        parent.protocol("WM_DELETE_WINDOW", self.on_quit)
+
+    def on_quit(self):
+        """Exits program."""
+        if not self.check_saved():
+            warning = 'Warning:  Simulation results have not been saved.'
+            confirm = ConfirmDialog(self, warning).result
+            if not confirm == 'okay':
+                print('Quit canceled.')
+                return
+        if self.logfile:
+            self.logfile.close()
+        quit()
+
+    def on_mousewheel(self, event):
+        if event.num == 5:
+            self.datasheet_viewer.yview_scroll(1, "units")
+        elif event.num == 4:
+            self.datasheet_viewer.yview_scroll(-1, "units")
+
+    def init_gui(self):
+        """Builds GUI."""
+        global prefsfile
+
+        message = []
+        fontsize = 11
+
+        # Read user preferences file, get default font size from it.
+        prefspath = os.path.expanduser(prefsfile)
+        if os.path.exists(prefspath):
+            with open(prefspath, 'r') as f:
+                self.prefs = json.load(f)
+            if 'fontsize' in self.prefs:
+                fontsize = self.prefs['fontsize']
+        else:
+            self.prefs = {}
+
+        s = ttk.Style()
+
+        available_themes = s.theme_names()
+        s.theme_use(available_themes[0])
+
+        s.configure('bg.TFrame', background='gray40')
+        s.configure('italic.TLabel', font=('Helvetica', fontsize, 'italic'))
+        s.configure('title.TLabel', font=('Helvetica', fontsize, 'bold italic'),
+			foreground = 'brown', anchor = 'center')
+        s.configure('normal.TLabel', font=('Helvetica', fontsize))
+        s.configure('red.TLabel', font=('Helvetica', fontsize), foreground = 'red')
+        s.configure('green.TLabel', font=('Helvetica', fontsize), foreground = 'green3')
+        s.configure('blue.TLabel', font=('Helvetica', fontsize), foreground = 'blue')
+        s.configure('hlight.TLabel', font=('Helvetica', fontsize), background='gray93')
+        s.configure('rhlight.TLabel', font=('Helvetica', fontsize), foreground = 'red',
+			background='gray93')
+        s.configure('ghlight.TLabel', font=('Helvetica', fontsize), foreground = 'green3',
+			background='gray93')
+        s.configure('blue.TLabel', font=('Helvetica', fontsize), foreground = 'blue')
+        s.configure('blue.TMenubutton', font=('Helvetica', fontsize), foreground = 'blue',
+			border = 3, relief = 'raised')
+        s.configure('normal.TButton', font=('Helvetica', fontsize),
+			border = 3, relief = 'raised')
+        s.configure('red.TButton', font=('Helvetica', fontsize), foreground = 'red',
+			border = 3, relief = 'raised')
+        s.configure('green.TButton', font=('Helvetica', fontsize), foreground = 'green3',
+			border = 3, relief = 'raised')
+        s.configure('hlight.TButton', font=('Helvetica', fontsize),
+			border = 3, relief = 'raised', background='gray93')
+        s.configure('rhlight.TButton', font=('Helvetica', fontsize), foreground = 'red',
+			border = 3, relief = 'raised', background='gray93')
+        s.configure('ghlight.TButton', font=('Helvetica', fontsize), foreground = 'green3',
+			border = 3, relief = 'raised', background='gray93')
+        s.configure('blue.TButton', font=('Helvetica', fontsize), foreground = 'blue',
+			border = 3, relief = 'raised')
+        s.configure('redtitle.TButton', font=('Helvetica', fontsize, 'bold italic'),
+			foreground = 'red', border = 3, relief = 'raised')
+        s.configure('bluetitle.TButton', font=('Helvetica', fontsize, 'bold italic'),
+			foreground = 'blue', border = 3, relief = 'raised')
+
+        # Create the help window
+        self.help = HelpWindow(self, fontsize = fontsize)
+
+        with io.StringIO() as buf, contextlib.redirect_stdout(buf):
+            self.help.add_pages_from_file(og_config.apps_path + '/characterize_help.txt')
+            message = buf.getvalue()
+
+        # Set the help display to the first page
+        self.help.page(0)
+
+        # Create the failure report window
+        self.failreport = FailReport(self, fontsize = fontsize)
+
+        # LVS results get a text window of results
+        self.textreport = TextReport(self, fontsize = fontsize)
+
+        # Create the settings window
+        self.settings = Settings(self, fontsize = fontsize, callback = self.callback)
+
+        # Create the simulation hints window
+        self.simhints = SimHints(self, fontsize = fontsize)
+
+        # Create the edit parameter window
+        self.editparam = EditParam(self, fontsize = fontsize)
+
+        # Variables used by option menus and other stuff
+        self.origin = tkinter.StringVar(self)
+        self.cur_project = tkinter.StringVar(self)
+        self.cur_datasheet = "(no selection)"
+        self.datatop = {}
+        self.status = {}
+        self.caceproc = None
+        self.logfile = None
+
+        # Root window title
+        self.root.title('Open Galaxy Characterization')
+        self.root.option_add('*tearOff', 'FALSE')
+        self.pack(side = 'top', fill = 'both', expand = 'true')
+
+        pane = tkinter.PanedWindow(self, orient = 'vertical', sashrelief='groove', sashwidth=6)
+        pane.pack(side = 'top', fill = 'both', expand = 'true')
+        self.toppane = ttk.Frame(pane)
+        self.botpane = ttk.Frame(pane)
+
+        # Get username
+        if 'username' in self.prefs:
+            username = self.prefs['username']
+        else:
+            userid = os.environ['USER']
+            p = subprocess.run(['/ef/apps/bin/withnet',
+			og_config.apps_path + '/og_uid_service.py', userid],
+                        stdout = subprocess.PIPE)
+            if p.stdout:
+                uid_string = p.stdout.splitlines()[0].decode('utf-8')
+                userspec = re.findall(r'[^"\s]\S*|".+?"', uid_string)
+                if len(userspec) > 0:
+                    username = userspec[0].strip('"')
+                    # Note userspec[1] = UID and userspec[2] = role, useful
+                    # for future applications.
+                else:
+                    username = userid
+            else:
+                username = userid
+
+        # Label with the user
+        self.toppane.title_frame = ttk.Frame(self.toppane)
+        self.toppane.title_frame.grid(column = 0, row=0, sticky = 'nswe')
+
+        self.toppane.title_frame.title = ttk.Label(self.toppane.title_frame, text='User:', style = 'red.TLabel')
+        self.toppane.title_frame.user = ttk.Label(self.toppane.title_frame, text=username, style = 'blue.TLabel')
+
+        self.toppane.title_frame.title.grid(column=0, row=0, ipadx = 5)
+        self.toppane.title_frame.user.grid(column=1, row=0, ipadx = 5)
+
+        #---------------------------------------------
+        ttk.Separator(self.toppane, orient='horizontal').grid(column = 0, row = 1, sticky = 'nswe')
+        #---------------------------------------------
+
+        self.toppane.title2_frame = ttk.Frame(self.toppane)
+        self.toppane.title2_frame.grid(column = 0, row = 2, sticky = 'nswe')
+        self.toppane.title2_frame.datasheet_label = ttk.Label(self.toppane.title2_frame, text="Datasheet:",
+		style = 'normal.TLabel')
+        self.toppane.title2_frame.datasheet_label.grid(column=0, row=0, ipadx = 5)
+
+        # New datasheet select button
+        self.toppane.title2_frame.datasheet_select = ttk.Button(self.toppane.title2_frame,
+		text=self.cur_datasheet, style='normal.TButton', command=self.choose_datasheet)
+        self.toppane.title2_frame.datasheet_select.grid(column=1, row=0, ipadx = 5)
+
+        tooltip.ToolTip(self.toppane.title2_frame.datasheet_select,
+			text = "Select new datasheet file")
+
+        # Show path to datasheet
+        self.toppane.title2_frame.path_label = ttk.Label(self.toppane.title2_frame, text=self.cur_datasheet,
+		style = 'normal.TLabel')
+        self.toppane.title2_frame.path_label.grid(column=2, row=0, ipadx = 5, padx = 10)
+
+        # Spacer in middle moves selection button to right
+        self.toppane.title2_frame.sep_label = ttk.Label(self.toppane.title2_frame, text=' ',
+		style = 'normal.TLabel')
+        self.toppane.title2_frame.sep_label.grid(column=3, row=0, ipadx = 5, padx = 10)
+        self.toppane.title2_frame.columnconfigure(3, weight = 1)
+        self.toppane.title2_frame.rowconfigure(0, weight=0)
+
+        # Selection for origin of netlist
+        self.toppane.title2_frame.origin_label = ttk.Label(self.toppane.title2_frame,
+		text='Netlist from:', style = 'normal.TLabel')
+        self.toppane.title2_frame.origin_label.grid(column=4, row=0, ipadx = 5, padx = 10)
+
+        self.origin.set('Schematic Capture')
+        self.toppane.title2_frame.origin_select = ttk.OptionMenu(self.toppane.title2_frame,
+		self.origin, 'Schematic Capture', 'Schematic Capture', 'Layout Extracted',
+		style='blue.TMenubutton', command=self.load_results)
+        self.toppane.title2_frame.origin_select.grid(column=5, row=0, ipadx = 5)
+
+        #---------------------------------------------
+        ttk.Separator(self.toppane, orient='horizontal').grid(column = 0, row = 3, sticky = 'news')
+        #---------------------------------------------
+
+        # Datasheet information goes here when datasheet is loaded.
+        self.mframe = ttk.Frame(self.toppane)
+        self.mframe.grid(column = 0, row = 4, sticky = 'news')
+
+        # Row 4 (mframe) is expandable, the other rows are not.
+        self.toppane.rowconfigure(0, weight = 0)
+        self.toppane.rowconfigure(1, weight = 0)
+        self.toppane.rowconfigure(2, weight = 0)
+        self.toppane.rowconfigure(3, weight = 0)
+        self.toppane.rowconfigure(4, weight = 1)
+        self.toppane.columnconfigure(0, weight = 1)
+
+        #---------------------------------------------
+        # ttk.Separator(self, orient='horizontal').grid(column=0, row=5, sticky='ew')
+        #---------------------------------------------
+
+        # Add a text window below the datasheet to capture output.  Redirect
+        # print statements to it.
+
+        self.botpane.console = ttk.Frame(self.botpane)
+        self.botpane.console.pack(side = 'top', fill = 'both', expand = 'true')
+
+        self.text_box = ConsoleText(self.botpane.console, wrap='word', height = 4)
+        self.text_box.pack(side='left', fill='both', expand='true')
+        console_scrollbar = ttk.Scrollbar(self.botpane.console)
+        console_scrollbar.pack(side='right', fill='y')
+        # attach console to scrollbar
+        self.text_box.config(yscrollcommand = console_scrollbar.set)
+        console_scrollbar.config(command = self.text_box.yview)
+
+        # Add button bar at the bottom of the window
+        self.bbar = ttk.Frame(self.botpane)
+        self.bbar.pack(side = 'top', fill = 'x')
+        # Progress bar expands with the window, buttons don't
+        self.bbar.columnconfigure(6, weight = 1)
+
+        # Define the "quit" button and action
+        self.bbar.quit_button = ttk.Button(self.bbar, text='Close', command=self.on_quit,
+		style = 'normal.TButton')
+        self.bbar.quit_button.grid(column=0, row=0, padx = 5)
+
+        # Define the save button
+        self.bbar.save_button = ttk.Button(self.bbar, text='Save', command=self.save_results,
+		style = 'normal.TButton')
+        self.bbar.save_button.grid(column=1, row=0, padx = 5)
+
+        # Define the save-as button
+        self.bbar.saveas_button = ttk.Button(self.bbar, text='Save As', command=self.save_manual,
+		style = 'normal.TButton')
+
+	# Also a load button
+        self.bbar.load_button = ttk.Button(self.bbar, text='Load', command=self.load_manual,
+		style = 'normal.TButton')
+
+        # Define help button
+        self.bbar.help_button = ttk.Button(self.bbar, text='Help', command=self.help.open,
+		style = 'normal.TButton')
+        self.bbar.help_button.grid(column = 4, row = 0, padx = 5)
+
+        # Define settings button
+        self.bbar.settings_button = ttk.Button(self.bbar, text='Settings',
+		command=self.settings.open, style = 'normal.TButton')
+        self.bbar.settings_button.grid(column = 5, row = 0, padx = 5)
+
+        # Define upload action
+        self.bbar.upload_button = ttk.Button(self.bbar, text='Submit', state = 'enabled',
+		command=self.upload_to_marketplace, style = 'normal.TButton')
+        # "Submit" button remains unplaced;  upload may be done from the web side. . .
+        # self.bbar.upload_button.grid(column = 8, row = 0, padx = 5, sticky = 'ens')
+
+        tooltip.ToolTip(self.bbar.quit_button, text = "Exit characterization tool")
+        tooltip.ToolTip(self.bbar.save_button, text = "Save current characterization state")
+        tooltip.ToolTip(self.bbar.saveas_button, text = "Save current characterization state")
+        tooltip.ToolTip(self.bbar.load_button, text = "Load characterization state from file")
+        tooltip.ToolTip(self.bbar.help_button, text = "Start help tool")
+        tooltip.ToolTip(self.bbar.settings_button, text = "Manage characterization tool settings")
+        tooltip.ToolTip(self.bbar.upload_button, text = "Submit completed design to Marketplace")
+
+        # Inside frame with main electrical parameter display and scrollbar
+        # To make the frame scrollable, it must be a frame inside a canvas.
+        self.datasheet_viewer = tkinter.Canvas(self.mframe)
+        self.datasheet_viewer.grid(row = 0, column = 0, sticky = 'nsew')
+        self.datasheet_viewer.dframe = ttk.Frame(self.datasheet_viewer,
+			style='bg.TFrame')
+        # Place the frame in the canvas
+        self.datasheet_viewer.create_window((0,0),
+			window=self.datasheet_viewer.dframe,
+			anchor="nw", tags="self.frame")
+
+        # Make sure the main window resizes, not the scrollbars.
+        self.mframe.rowconfigure(0, weight = 1)
+        self.mframe.columnconfigure(0, weight = 1)
+        # X scrollbar for datasheet viewer
+        main_xscrollbar = ttk.Scrollbar(self.mframe, orient = 'horizontal')
+        main_xscrollbar.grid(row = 1, column = 0, sticky = 'nsew')
+        # Y scrollbar for datasheet viewer
+        main_yscrollbar = ttk.Scrollbar(self.mframe, orient = 'vertical')
+        main_yscrollbar.grid(row = 0, column = 1, sticky = 'nsew')
+        # Attach console to scrollbars
+        self.datasheet_viewer.config(xscrollcommand = main_xscrollbar.set)
+        main_xscrollbar.config(command = self.datasheet_viewer.xview)
+        self.datasheet_viewer.config(yscrollcommand = main_yscrollbar.set)
+        main_yscrollbar.config(command = self.datasheet_viewer.yview)
+
+        # Make sure that scrollwheel pans window
+        self.datasheet_viewer.bind_all("<Button-4>", self.on_mousewheel)
+        self.datasheet_viewer.bind_all("<Button-5>", self.on_mousewheel)
+
+        # Set up configure callback
+        self.datasheet_viewer.dframe.bind("<Configure>", self.frame_configure)
+
+        # Add the panes once the internal geometry is known
+        pane.add(self.toppane)
+        pane.add(self.botpane)
+        pane.paneconfig(self.toppane, stretch='first')
+
+        # Initialize variables
+        self.sims_to_go = []
+
+        # Capture time of start to compare against the annotated
+        # output file timestamp.
+        self.starttime = time.time()
+
+        # Redirect stdout and stderr to the console as the last thing to do. . .
+        # Otherwise errors in the GUI get sucked into the void.
+        self.stdout = sys.stdout
+        self.stderr = sys.stderr
+        sys.stdout = ConsoleText.StdoutRedirector(self.text_box)
+        sys.stderr = ConsoleText.StderrRedirector(self.text_box)
+
+        if message:
+            print(message)
+
+    def frame_configure(self, event):
+        self.update_idletasks()
+        self.datasheet_viewer.configure(scrollregion=self.datasheet_viewer.bbox("all"))
+
+    def logstart(self):
+        # Start a logfile (or append to it, if it already exists)
+        # Disabled by default, as it can get very large.
+        # Can be enabled from Settings.
+        if self.settings.get_log() == True:
+            dataroot = os.path.splitext(self.cur_datasheet)[0]
+            if not self.logfile:
+                self.logfile = open(dataroot + '.log', 'a')
+
+                # Print some initial information to the logfile.
+                self.logprint('-------------------------')
+                self.logprint('Starting new log file ' + datetime.datetime.now().strftime('%c'),
+				doflush=True)
+
+    def logstop(self):
+        if self.logfile:
+            self.logprint('-------------------------', doflush=True)
+            self.logfile.close()
+            self.logfile = []
+
+    def logprint(self, message, doflush=False):
+        if self.logfile:
+            self.logfile.buffer.write(message.encode('utf-8'))
+            self.logfile.buffer.write('\n'.encode('utf-8'))
+            if doflush:
+                self.logfile.flush()
+
+    def set_datasheet(self, datasheet):
+        if self.logfile:
+            self.logprint('end of log.')
+            self.logprint('-------------------------', doflush=True)
+            self.logfile.close()
+            self.logfile = None
+
+        if not os.path.isfile(datasheet):
+            print('Error:  File ' + datasheet + ' not found.')
+            return
+
+        [dspath, dsname] = os.path.split(datasheet)
+        # Read the datasheet
+        with open(datasheet) as ifile:
+            try:
+                datatop = json.load(ifile)
+            except json.decoder.JSONDecodeError as e:
+                print("Error:  Parse error reading JSON file " + datasheet + ':')
+                print(str(e))
+                return
+            else:
+                # 'request-hash' set to '.' for local simulation
+                datatop['request-hash'] = '.'
+        try:
+            dsheet = datatop['data-sheet']
+        except KeyError:
+            print("Error:  JSON file is not a datasheet!\n")
+        else:
+            self.datatop = datatop
+            self.cur_datasheet = datasheet
+            self.create_datasheet_view()
+            self.toppane.title2_frame.datasheet_select.configure(text=dsname)
+            self.toppane.title2_frame.path_label.configure(text=datasheet)
+
+            # Determine if there is a saved, annotated JSON file that is
+            # more recent than the netlist used for simulation.
+            self.load_results()
+
+        # Attempt to set the datasheet viewer width to the interior width
+        # but do not set it larger than the available desktop.
+        self.update_idletasks()
+        widthnow = self.datasheet_viewer.winfo_width()
+        width = self.datasheet_viewer.dframe.winfo_width()
+        screen_width = self.root.winfo_screenwidth()
+        if width > widthnow:
+            if width < screen_width - 10:
+                self.datasheet_viewer.configure(width=width)
+            else:
+                self.datasheet_viewer.configure(width=screen_width - 10)
+        elif widthnow > screen_width:
+            self.datasheet_viewer.configure(width=screen_width - 10)
+        elif widthnow > width:
+            self.datasheet_viewer.configure(width=width)
+
+        # Likewise for the height, up to 3/5 of the desktop height.
+        height = self.datasheet_viewer.dframe.winfo_height()
+        heightnow = self.datasheet_viewer.winfo_height()
+        screen_height = self.root.winfo_screenheight()
+        if height > heightnow:
+            if height < screen_height * 0.6:
+                self.datasheet_viewer.configure(height=height)
+            else:
+                self.datasheet_viewer.configure(height=screen_height * 0.6)
+        elif heightnow > screen_height:
+            self.datasheet_viewer.configure(height=screen_height - 10)
+        elif heightnow > height:
+            self.datasheet_viewer.configure(height=height)
+
+    def choose_datasheet(self):
+        datasheet = filedialog.askopenfilename(multiple = False,
+			initialdir = os.path.expanduser('~/design'),
+			filetypes = (("JSON File", "*.json"),("All Files","*.*")),
+			title = "Find a datasheet.")
+        if datasheet != '':
+            self.set_datasheet(datasheet)
+
+    def cancel_upload(self):
+        # Post a cancelation message to CACE.  CACE responds by setting the
+        # status to 'canceled'.  The watchprogress procedure is responsible for
+        # returning the button to 'Submit' when the characterization finishes
+        # or is canceled.
+        dspath = os.path.split(self.cur_datasheet)[0]
+        datasheet = os.path.split(self.cur_datasheet)[1]
+        designname = os.path.splitext(datasheet)[0]
+        print('Cancel characterization of ' + designname + ' (' + dspath + ' )')
+        subprocess.run(['/ef/apps/bin/withnet',
+			og_config.apps_path + '/cace_design_upload.py', '-cancel',
+                        dspath])
+        self.removeprogress()
+        self.bbar.upload_button.configure(text='Submit', state = 'enabled',
+			command=self.upload_to_marketplace,
+			style = 'normal.TButton')
+        # Delete the remote status file.
+        dsdir = dspath + '/ngspice/char'
+        statusname = dsdir + '/remote_status.json'
+        if os.path.exists(statusname):
+            os.remove(statusname)
+
+    def progress_bar_setup(self, dspath):
+        # Create the progress bar at the bottom of the window to indicate
+        # the status of a challenge submission.
+
+        # Disable the Submit button
+        self.bbar.upload_button.configure(state='disabled')
+
+        # Start progress bar watchclock
+        dsdir = dspath + '/ngspice/char'
+        statusname = dsdir + '/remote_status.json'
+        if os.path.exists(statusname):
+            statbuf = os.stat(statusname)
+            mtime = statbuf.st_mtime
+        else:
+            if os.path.exists(dsdir):
+                # Write a simple status
+                status = {'message': 'not started', 'total': '0', 'completed': '0'}
+                with open(statusname, 'w') as f:
+                    json.dump(status, f)
+            mtime = 0
+        # Create a TTK progress bar widget in the buttonbar.
+        self.bbar.progress_label = ttk.Label(self.bbar, text="Characterization: ",
+		style = 'normal.TLabel')
+        self.bbar.progress_label.grid(column=4, row=0, ipadx = 5)
+
+        self.bbar.progress_message = ttk.Label(self.bbar, text="(not started)",
+		style = 'blue.TLabel')
+        self.bbar.progress_message.grid(column=5, row=0, ipadx = 5)
+        self.bbar.progress = ttk.Progressbar(self.bbar,
+			orient='horizontal', mode='determinate')
+        self.bbar.progress.grid(column = 6, row = 0, padx = 5, sticky = 'nsew')
+        self.bbar.progress_text = ttk.Label(self.bbar, text="0/0",
+		style = 'blue.TLabel')
+        self.bbar.progress_text.grid(column=7, row=0, ipadx = 5)
+
+        # Start the timer to watch the progress
+        self.watchprogress(statusname, mtime, 1)
+
+    def check_ongoing_upload(self):
+        # Determine if an upload is ongoing when the characterization tool is
+        # started.  If so, immediately go to the 'characterization running'
+        # state with progress bar.
+        dspath = os.path.split(self.cur_datasheet)[0]
+        datasheet = os.path.split(self.cur_datasheet)[1]
+        designname = os.path.splitext(datasheet)[0]
+        dsdir = dspath + '/ngspice/char'
+        statusname = dsdir + '/remote_status.json'
+        if os.path.exists(statusname):
+            with open(statusname, 'r') as f:
+                status = json.load(f)
+                if 'message' in status:
+                    if status['message'] == 'in progress':
+                        print('Design characterization in progress for ' + designname + ' (' + dspath + ' )')
+                        self.progress_bar_setup(dspath)
+                else:
+                    print("No message in status file")
+
+    def upload_to_marketplace(self):
+        dspath = os.path.split(self.cur_datasheet)[0]
+        datasheet = os.path.split(self.cur_datasheet)[1]
+        dsheet = self.datatop['data-sheet']
+        designname = dsheet['ip-name']
+
+        # Make sure a netlist has been generated.
+        if self.sim_param('check') == False:
+            print('No netlist was generated, cannot submit!')
+            return
+
+        # For diagnostic purposes, place all of the characterization tool
+        # settings into datatop['settings'] when uploading to remote CACE.
+        runtime_settings = {}
+        runtime_settings['force-regenerate'] = self.settings.get_force()
+        runtime_settings['edit-all-params'] = self.settings.get_edit()
+        runtime_settings['keep-files'] = self.settings.get_keep()
+        runtime_settings['make-plots'] = self.settings.get_plot()
+        runtime_settings['submit-test-mode'] = self.settings.get_test()
+        runtime_settings['submit-as-schematic'] = self.settings.get_schem()
+        runtime_settings['submit-failing'] = self.settings.get_submitfailed()
+        runtime_settings['log-output'] = self.settings.get_log()
+
+        # Write out runtime settings as a JSON file
+        with open(dspath + '/settings.json', 'w') as file:
+            json.dump(runtime_settings, file, indent = 4)
+
+        warning = ''
+        must_confirm = False
+        if self.settings.get_schem() == True:
+            # If a layout exists but "submit as schematic" was chosen, then
+            # flag a warning and insist on confirmation.
+            if os.path.exists(dspath + '/mag/' + designname + '.mag'):
+                warning += 'Warning: layout exists but only schematic has been selected for submission'
+                must_confirm = True
+            else:
+                print('No layout in ' + dspath + '/mag/' + designname + '.mag')
+                print('Schematic only submission selection is not needed.')
+        else:
+            # Likewise, check if schematic netlist results are showing but a layout
+            # exists, which means that the existing results are not the ones that are
+            # going to be tested.
+            if self.origin.get() == 'Schematic Capture':
+                if os.path.exists(dspath + '/mag/' + designname + '.mag'):
+                    warning += 'Warning: schematic results are shown but remote CACE will be run on layout results.'
+                    must_confirm = True
+
+
+        # Make a check to see if all simulations have been made and passed.  If so,
+        # then just do the upload.  If not, then generate a warning dialog and
+        # require the user to respond to force an upload in spite of an incomplete
+        # simulation.  Give dire warnings if any simulation has failed.
+
+        failures = 0
+        missed = 0
+        for param in dsheet['electrical-params']:
+            if 'max' in param:
+                pmax = param['max']
+                if not 'value' in pmax:
+                    missed += 1
+                elif 'score' in pmax:
+                    if pmax['score'] == 'fail':
+                        failures += 1
+            if 'min' in param:
+                pmin = param['min']
+                if not 'value' in pmin:
+                    missed += 1
+                elif 'score' in pmin:
+                    if pmin['score'] == 'fail':
+                        failures += 1
+
+        if missed > 0:
+            if must_confirm == True:
+                warning += '\n'
+            warning += 'Warning:  Not all critical parameters have been simulated.'
+        if missed > 0 and failures > 0:
+            warning += '\n'
+        if failures > 0:
+            warning += 'Dire Warning:  This design has errors on critical parameters!'
+
+        # Require confirmation
+        if missed > 0 or failures > 0:
+            must_confirm = True
+
+        if must_confirm:
+            if self.settings.get_submitfailed() == True:
+                confirm = ConfirmDialog(self, warning).result
+            else:
+                confirm = PuntDialog(self, warning).result
+            if not confirm == 'okay':
+                print('Upload canceled.')
+                return
+            print('Upload selected')
+
+        # Save hints in file in spi/ directory.
+        hintlist = []
+        for eparam in dsheet['electrical-params']:
+            if not 'editable' in eparam:
+                if 'hints' in eparam:
+                    hintlist.append(eparam['hints'])
+                else:
+                    # Must have a placeholder
+                    hintlist.append({})
+        if hintlist:
+            hfilename = dspath + '/hints.json'
+            with open(hfilename, 'w') as hfile:
+                json.dump(hintlist, hfile, indent = 4)
+
+        print('Uploading design ' + designname + ' (' + dspath + ' )')
+        print('to marketplace and submitting for characterization.')
+        if not self.settings.get_test():
+            self.progress_bar_setup(dspath)
+            self.update_idletasks()
+        subprocess.run(['/ef/apps/bin/withnet',
+			og_config.apps_path + '/cace_design_upload.py',
+                        dspath])
+
+        # Remove the settings file
+        os.remove(dspath + '/settings.json')
+        os.remove(dspath + '/hints.json')
+
+    def removeprogress(self):
+        # Remove the progress bar.  This is left up for a second after
+        # completion or cancelation so that the final message has time
+        # to be seen.
+        try:
+            self.bbar.progress_label.destroy()
+            self.bbar.progress_message.destroy()
+            self.bbar.progress.destroy()
+            self.bbar.progress_text.destroy()
+        except:
+            pass
+
+    def watchprogress(self, filename, filemtime, timeout):
+        new_timeout = timeout + 1 if timeout > 0 else 0
+        # 2 minute timeout for startup (note that all simulation files have to be
+        # made during this period.
+        if new_timeout == 120:
+            self.cancel_upload()
+            return
+
+        # If file does not exist, then keep checking at 2 second intervals.
+        if not os.path.exists(filename):
+            self.after(2000, lambda: self.watchprogress(filename, filemtime, new_timeout))
+            return
+
+        # If filename file is modified, then update progress bar;
+        # otherwise, restart the clock.
+        statbuf = os.stat(filename)
+        if statbuf.st_mtime > filemtime:
+            self.after(250)	# Otherwise can catch file while it's incomplete. . .
+            if self.update_progress(filename) == True:
+                self.after(1000, lambda: self.watchprogress(filename, filemtime, 0))
+            else:
+                # Remove the progress bar when done, after letting the final
+                # message display for a second.
+                self.after(1500, self.removeprogress)
+                # And return the button to "Submit" and in an enabled state.
+                self.bbar.upload_button.configure(text='Submit', state = 'enabled',
+				command=self.upload_to_marketplace,
+				style = 'normal.TButton')
+        else:
+            self.after(1000, lambda: self.watchprogress(filename, filemtime, new_timeout))
+
+    def update_progress(self, filename):
+        # On first update, button changes from "Submit" to "Cancel"
+        # This ensures that the 'remote_status.json' file has been sent
+        # from the CACE with the hash value needed for the CACE to identify
+        # the right simulation and cancel it.
+        if self.bbar.upload_button.configure('text')[-1] == 'Submit':
+            self.bbar.upload_button.configure(text='Cancel', state = 'enabled',
+			command=self.cancel_upload, style = 'red.TButton')
+
+        if not os.path.exists(filename):
+            return False
+
+        # Update the progress bar during an CACE simulation run.
+        # Read the status file
+        try:
+            with open(filename, 'r') as f:
+                status = json.load(f)
+        except (PermissionError, FileNotFoundError):
+            # For a very short time the user does not have ownership of
+            # the file and the read will fail.  This is a rare case, so
+            # just punt until the next cycle.
+            return True
+
+        if 'message' in status:
+            self.bbar.progress_message.configure(text = status['message'])
+
+        try:
+            total = int(status['total'])
+        except:
+            total = 0
+        else:
+            self.bbar.progress.configure(maximum = total)
+        
+        try:
+            completed = int(status['completed'])
+        except:
+            completed = 0
+        else:
+            self.bbar.progress.configure(value = completed)
+
+        self.bbar.progress_text.configure(text = str(completed) + '/' + str(total))
+        if completed > 0 and completed == total:
+            print('Notice:  Design completed.')
+            print('The CACE server has finished characterizing the design.')
+            print('Go to the efabless marketplace to view submission.')
+            return False
+        elif status['message'] == 'canceled':
+            print('Notice:  Design characterization was canceled.')
+            return False
+        else:
+            return True
+
+    def topfilter(self, line):
+        # Check output for ubiquitous "Reference value" lines and remove them.
+        # This happens before logging both to the file and to the console.
+        refrex = re.compile('Reference value')
+        rmatch = refrex.match(line)
+        if not rmatch:
+            return line
+        else:
+            return None
+
+    def spicefilter(self, line):
+        # Check for the alarmist 'tran simulation interrupted' message and remove it.
+        # Check for error or warning and print as stderr or stdout accordingly.
+        intrex = re.compile('tran simulation interrupted')
+        warnrex = re.compile('.*warning', re.IGNORECASE)
+        errrex = re.compile('.*error', re.IGNORECASE)
+
+        imatch = intrex.match(line)
+        if not imatch:
+            ematch = errrex.match(line)
+            wmatch = warnrex.match(line)
+            if ematch or wmatch:
+                print(line, file=sys.stderr)
+            else:
+                print(line, file=sys.stdout)
+
+    def printwarn(self, output):
+        # Check output for warning or error
+        if not output:
+            return 0
+
+        warnrex = re.compile('.*warning', re.IGNORECASE)
+        errrex = re.compile('.*error', re.IGNORECASE)
+
+        errors = 0
+        outlines = output.splitlines()
+        for line in outlines:
+            try:
+                wmatch = warnrex.match(line)
+            except TypeError:
+                line = line.decode('utf-8')
+                wmatch = warnrex.match(line)
+            ematch = errrex.match(line)
+            if ematch:
+                errors += 1
+            if ematch or wmatch:
+                print(line)
+        return errors
+
+    def sim_all(self):
+        if self.caceproc:
+            # Failsafe
+            if self.caceproc.poll() != None:
+                self.caceproc = None
+            else:
+                print('Simulation in progress must finish first.')
+                return
+
+        # Create netlist if necessary, check for valid result
+        if self.sim_param('check') == False:
+            return
+
+        # Simulate all of the electrical parameters in turn
+        self.sims_to_go = []
+        for puniq in self.status:
+            self.sims_to_go.append(puniq)
+
+        # Start first sim
+        if len(self.sims_to_go) > 0:
+            puniq = self.sims_to_go[0]
+            self.sims_to_go = self.sims_to_go[1:]
+            self.sim_param(puniq)
+
+        # Button now stops the simulations
+        self.allsimbutton.configure(style = 'redtitle.TButton', text='Stop Simulations',
+		command=self.stop_sims)
+
+    def stop_sims(self):
+        # Make sure there will be no more simulations
+        self.sims_to_go = []
+        if not self.caceproc:
+            print("No simulation running.")
+            return
+        self.caceproc.terminate()
+        # Use communicate(), not wait() , on piped processes to avoid deadlock.
+        try:
+            self.caceproc.communicate(timeout=10)
+        except subprocess.TimeoutExpired:
+            self.caceproc.kill()
+            self.caceproc.communicate()
+            print("CACE process killed.")
+        else:
+            print("CACE process exited.")
+        # Let watchdog timer see that caceproc is gone and reset the button.
+
+    def edit_param(self, param):
+        # Edit the conditions under which the parameter is tested.
+        if ('editable' in param and param['editable'] == True) or self.settings.get_edit() == True:
+            self.editparam.populate(param)
+            self.editparam.open()
+        else:
+            print('Parameter is not editable')
+
+    def copy_param(self, param):
+        # Make a copy of the parameter (for editing)
+        newparam = param.copy()
+        # Make the copied parameter editable
+        newparam['editable'] = True
+        # Append this to the electrical parameter list after the item being copied
+        if 'display' in param:
+            newparam['display'] = param['display'] + ' (copy)'
+        datatop = self.datatop
+        dsheet = datatop['data-sheet']
+        eparams = dsheet['electrical-params']
+        eidx = eparams.index(param)
+        eparams.insert(eidx + 1, newparam)
+        self.create_datasheet_view()
+
+    def delete_param(self, param):
+        # Remove an electrical parameter from the datasheet.  This is only
+        # allowed if the parameter has been copied from another and so does
+        # not belong to the original set of parameters.
+        datatop = self.datatop
+        dsheet = datatop['data-sheet']
+        eparams = dsheet['electrical-params']
+        eidx = eparams.index(param)
+        eparams.pop(eidx)
+        self.create_datasheet_view()
+
+    def add_hints(self, param, simbutton):
+        # Raise hints window and configure appropriately for the parameter.
+        # Fill in any existing hints.
+        self.simhints.populate(param, simbutton)
+        self.simhints.open()
+
+    def sim_param(self, method):
+        if self.caceproc:
+            # Failsafe
+            if self.caceproc.poll() != None:
+                self.caceproc = None
+            else:
+                print('Simulation in progress, queued for simulation.')
+                if not method in self.sims_to_go:
+                    self.sims_to_go.append(method)
+                return False
+
+        # Get basic values for datasheet and ip-name
+
+        dspath = os.path.split(self.cur_datasheet)[0]
+        dsheet = self.datatop['data-sheet']
+        dname = dsheet['ip-name']
+
+        # Open log file, if specified
+        self.logstart()
+
+        # Check for whether the netlist is specified to come from schematic
+        # or layout.  Add a record to the datasheet depending on whether
+        # the netlist is from layout or extracted.  The settings window has
+        # a checkbox to force submitting as a schematic even if layout exists.
+
+        if self.origin.get() == 'Schematic Capture':
+            dsheet['netlist-source'] = 'schematic'
+        else:
+            dsheet['netlist-source'] = 'layout'
+
+        if self.settings.get_force() == True:
+            dsheet['regenerate'] = 'force'
+
+        basemethod = method.split('.')[0]
+        if basemethod == 'check':	# used by submit to ensure netlist exists
+            return True
+  
+        if basemethod == 'physical':
+            print('Checking ' + method.split('.')[1])
+        else:
+            print('Simulating method = ' + basemethod)
+        self.stat_label = self.status[method]
+        self.stat_label.configure(text='(in progress)', style='blue.TLabel')
+        # Update status now
+        self.update_idletasks()
+        print('Datasheet directory is = ' + dspath + '\n')
+
+        # Instead of using the original datasheet, use the one in memory so that
+        # it accumulates results.  A "save" button will update the original.
+        if not os.path.isdir(dspath + '/ngspice'):
+            os.makedirs(dspath + '/ngspice')
+        dsdir = dspath + '/ngspice/char'
+        if not os.path.isdir(dsdir):
+            os.makedirs(dsdir)
+        with open(dsdir + '/datasheet.json', 'w') as file:
+            json.dump(self.datatop, file, indent = 4)
+        # As soon as we call CACE, we will be watching the status of file
+        # datasheet_anno.  So create it if it does not exist, else attempting
+        # to stat a nonexistant file will cause the 1st simulation to fail.
+        if not os.path.exists(dsdir + '/datasheet_anno.json'):
+            open(dsdir + '/datasheet_anno.json', 'a').close()
+        # Call cace_gensim with full set of options
+        # First argument is the root directory
+        # (Diagnostic)
+        design_path = dspath + '/spi'
+
+        print('Calling cace_gensim.py ' + dspath + 
+			' -local -method=' + method)
+
+        modetext = ['-local']
+        if self.settings.get_keep() == True:
+            print(' -keep ')
+            modetext.append('-keep')
+
+        if self.settings.get_plot() == True:
+            print(' -plot ')
+            modetext.append('-plot')
+
+        print(' -simdir=' + dsdir + ' -datasheetdir=' + dsdir + ' -designdir=' + design_path)
+        print(' -layoutdir=' + dspath + '/mag' + ' -testbenchdir=' + dspath + '/testbench')
+        print(' -datasheet=datasheet.json')
+        
+        self.caceproc = subprocess.Popen([og_config.apps_path + '/cace_gensim.py', dspath,
+			*modetext,
+			'-method=' + method,  # Call local mode w/method
+			'-simdir=' + dsdir,
+			'-datasheetdir=' + dsdir,
+			'-designdir=' + design_path,
+			'-layoutdir=' + dspath + '/mag',
+			'-testbenchdir=' + dspath + '/testbench',
+			'-datasheet=datasheet.json'],
+          		stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
+
+        # Simulation finishes on its own time.  Use watchdog to handle.
+        # Note that python "watchdog" is threaded, and tkinter is not thread-safe.
+        # So watchdog is done with a simple timer loop.
+        statbuf = os.stat(dsdir + '/datasheet.json')
+        checktime = statbuf.st_mtime
+
+        filename = dsdir + '/datasheet_anno.json'
+        statbuf = os.stat(filename)
+        self.watchclock(filename, statbuf.st_mtime, checktime)
+
+    def watchclock(self, filename, filemtime, checktime):
+        # In case simulations cleared while watchclock was pending
+        if self.caceproc == None:
+            return
+        # Poll cace_gensim to see if it finished
+        cace_status = self.caceproc.poll()
+        if cace_status != None:
+            try:
+                output = self.caceproc.communicate(timeout=1)
+            except ValueError:
+                print("CACE gensim forced stop, status " + str(cace_status))
+            else: 
+                outlines = output[0]
+                errlines = output[1]
+                for line in outlines.splitlines():
+                    print(line.decode('utf-8'))
+                for line in errlines.splitlines():
+                    print(line.decode('utf-8'))
+                print("CACE gensim exited with status " + str(cace_status))
+        else:
+            n = 0
+            while True:
+                self.update_idletasks()
+                # Attempt to avoid infinite loop, unsure of the cause.
+                n += 1
+                if n > 100:
+                    n = 0
+                    cace_status = self.caceproc.poll()
+                    if cace_status != None:
+                        break
+                    self.logprint("100 lines of output", doflush=True)
+                    # Something went wrong.  Kill the process.
+                    # self.stop_sims()
+                sresult = select.select([self.caceproc.stdout, self.caceproc.stderr], [], [], 0)[0]
+                if self.caceproc.stdout in sresult:
+                    outstring = self.caceproc.stdout.readline().decode().strip()
+                    self.logprint(outstring, doflush=True)
+                    print(outstring)
+                elif self.caceproc.stderr in sresult:
+                    # ngspice passes back simulation time on stderr.  This ends in \r but no
+                    # newline.  '\r' ends the transmission, so return.
+                    # errstring = self.topfilter(self.caceproc.stderr.readline().decode().strip())
+                    # if errstring:
+                    #     self.logprint(errstring, doflush=True)
+                    #     # Recast everything that isn't an error back into stdout.
+                    #     self.spicefilter(errstring)
+                    ochar = str(self.caceproc.stderr.read(1).decode())
+                    if ochar == '\r':
+                        print('')
+                        break
+                    else:
+                        print(ochar, end='')
+                else:
+                    break
+
+        # If filename file is modified, then call annotate;  otherwise, restart the clock.
+        statbuf = os.stat(filename)
+        if (statbuf.st_mtime > filemtime) or (cace_status != None):
+            if cace_status != None:
+                self.caceproc = None
+            else:
+                # Re-run to catch last output.
+                self.after(500, lambda: self.watchclock(filename, statbuf.st_mtime, checktime))
+                return
+            if cace_status != 0:
+                print('Errors encountered in simulation.')
+                self.logprint('Errors in simulation, CACE status = ' + str(cace_status), doflush=True)
+            self.annotate('anno', checktime)
+            if len(self.sims_to_go) > 0:
+                puniq = self.sims_to_go[0]
+                self.sims_to_go = self.sims_to_go[1:]
+                self.sim_param(puniq)
+            else:
+                # Button goes back to original text and command
+                self.allsimbutton.configure(style = 'bluetitle.TButton',
+				text='Simulate All', command = self.sim_all)
+        elif not self.caceproc:
+            # Process terminated by "stop"
+            # Button goes back to original text and command
+            self.allsimbutton.configure(style = 'bluetitle.TButton',
+			text='Simulate All', command = self.sim_all)
+            # Just redraw everthing so that the "(in progress)" message goes away.
+            self.annotate('anno', checktime)
+        else:
+            self.after(500, lambda: self.watchclock(filename, filemtime, checktime))
+
+    def clear_results(self, dsheet):
+        # Remove results from the window by clearing parameter results
+        paramstodo = []
+        if 'electrical-params' in dsheet:
+            paramstodo.extend(dsheet['electrical-params'])
+        if 'physical-params' in dsheet:
+            paramstodo.extend(dsheet['physical-params'])
+
+        for param in paramstodo:
+            # Fill frame with electrical parameter information
+            if 'max' in param:
+                maxrec = param['max']
+                if 'value' in maxrec:
+                    maxrec.pop('value')
+                if 'score' in maxrec:
+                    maxrec.pop('score')
+            if 'typ' in param:
+                typrec = param['typ']
+                if 'value' in typrec:
+                    typrec.pop('value')
+                if 'score' in typrec:
+                    typrec.pop('score')
+            if 'min' in param:
+                minrec = param['min']
+                if 'value' in minrec:
+                    minrec.pop('value')
+                if 'score' in minrec:
+                    minrec.pop('score')
+            if 'results' in param:
+                param.pop('results')
+
+            if 'plot' in param:
+                plotrec = param['plot']
+                if 'status' in plotrec:
+                    plotrec.pop('status')
+
+        # Regenerate datasheet view
+        self.create_datasheet_view()
+
+    def annotate(self, suffix, checktime):
+        # Pull results back from datasheet_anno.json.  Do NOT load this
+        # file if it predates the unannotated datasheet (that indicates
+        # simulator failure, and no results).
+        dspath = os.path.split(self.cur_datasheet)[0]
+        dsdir = dspath + '/ngspice/char'
+        anno = dsdir + '/datasheet_' + suffix + '.json'
+        unanno = dsdir + '/datasheet.json'
+
+        if os.path.exists(anno):
+            statbuf = os.stat(anno)
+            mtimea = statbuf.st_mtime
+            if checktime >= mtimea:
+                # print('original = ' + str(checktime) + ' annotated = ' + str(mtimea))
+                print('Error in simulation, no update to results.', file=sys.stderr)
+            elif statbuf.st_size == 0:
+                print('Error in simulation, no results.', file=sys.stderr)
+            else:
+                with open(anno, 'r') as file:
+                    self.datatop = json.load(file)
+        else:
+            print('Error in simulation, no update to results.', file=sys.stderr)
+
+        # Regenerate datasheet view
+        self.create_datasheet_view()
+
+        # Close log file, if it was enabled in the settings
+        self.logstop()
+
+    def save_results(self):
+        # Write datasheet_save with all the locally processed results.
+        dspath = os.path.split(self.cur_datasheet)[0]
+        dsdir = dspath + '/ngspice/char'
+
+        if self.origin.get() == 'Layout Extracted':
+            jsonfile = dsdir + '/datasheet_lsave.json'
+        else:
+            jsonfile = dsdir + '/datasheet_save.json'
+
+        with open(jsonfile, 'w') as ofile:
+            json.dump(self.datatop, ofile, indent = 4)
+        self.last_save = os.path.getmtime(jsonfile)
+
+        # Create copy of datasheet without result data.  This is
+        # the file appropriate to insert into the IP catalog
+        # metadata JSON file.
+
+        datacopy = copy.copy(self.datatop)
+        dsheet = datacopy['data-sheet']
+        if 'electrical-params' in dsheet:
+            for eparam in dsheet['electrical-params']:
+                if 'results' in eparam:
+                    eparam.pop('results')
+
+        datacopy.pop('request-hash')
+        jsonfile = dsdir + '/datasheet_compact.json'
+        with open(jsonfile, 'w') as ofile:
+            json.dump(datacopy, ofile, indent = 4)
+
+        print('Characterization results saved.')
+
+    def check_saved(self):
+        # Check if there is a file 'datasheet_save' and if it is more
+        # recent than 'datasheet_anno'.  If so, return True, else False.
+
+        [dspath, dsname] = os.path.split(self.cur_datasheet)
+        dsdir = dspath + '/ngspice/char'
+
+        if self.origin.get() == 'Layout Extracted':
+            savefile = dsdir + '/datasheet_lsave.json'
+        else:
+            savefile = dsdir + '/datasheet_save.json'
+
+        annofile = dsdir + '/datasheet_anno.json'
+        if os.path.exists(annofile):
+            annotime = os.path.getmtime(annofile)
+
+            # If nothing has been updated since the characterization
+            # tool was started, then there is no new information to save.
+            if annotime < self.starttime:
+                return True
+
+            if os.path.exists(savefile):
+                savetime = os.path.getmtime(savefile)
+                # return True if (savetime > annotime) else False
+                if savetime > annotime:
+                    print("Save is more recent than sim, so no need to save.")
+                    return True
+                else:
+                    print("Sim is more recent than save, so need to save.")
+                    return False
+            else:
+                # There is a datasheet_anno file but no datasheet_save,
+	        # so there are necessarily unsaved results.
+                print("no datasheet_save, so any results have not been saved.")
+                return False
+        else:
+            # There is no datasheet_anno file, so datasheet_save
+            # is either current or there have been no simulations.
+            print("no datasheet_anno, so there are no results to save.")
+            return True
+
+    def callback(self):
+        # Check for manual load/save-as status from settings window (callback
+        # when the settings window is closed).
+        if self.settings.get_loadsave() == True:
+            self.bbar.saveas_button.grid(column=2, row=0, padx = 5)
+            self.bbar.load_button.grid(column=3, row=0, padx = 5)
+        else:
+            self.bbar.saveas_button.grid_forget()
+            self.bbar.load_button.grid_forget()
+
+    def save_manual(self, value={}):
+        dspath = self.cur_datasheet
+        # Set initialdir to the project where cur_datasheet is located
+        dsparent = os.path.split(dspath)[0]
+
+        datasheet = filedialog.asksaveasfilename(multiple = False,
+			initialdir = dsparent,
+			confirmoverwrite = True,
+			defaultextension = ".json",
+			filetypes = (("JSON File", "*.json"),("All Files","*.*")),
+			title = "Select filename for saved datasheet.")
+        with open(datasheet, 'w') as ofile:
+            json.dump(self.datatop, ofile, indent = 4)
+
+    def load_manual(self, value={}):
+        dspath = self.cur_datasheet
+        # Set initialdir to the project where cur_datasheet is located
+        dsparent = os.path.split(dspath)[0]
+
+        datasheet = filedialog.askopenfilename(multiple = False,
+			initialdir = dsparent,
+			filetypes = (("JSON File", "*.json"),("All Files","*.*")),
+			title = "Find a datasheet.")
+        if datasheet != '':
+            try:
+                with open(datasheet, 'r') as file:
+                    self.datatop = json.load(file)
+            except:
+                print('Error in file, no update to results.', file=sys.stderr)
+
+            else:
+                # Regenerate datasheet view
+                self.create_datasheet_view()
+
+    def load_results(self, value={}):
+        # Check if datasheet_save exists and is more recent than the
+        # latest design netlist.  If so, load it;  otherwise, not.
+        # NOTE:  Name of .spi file comes from the project 'ip-name'
+        # in the datasheet.
+
+        [dspath, dsname] = os.path.split(self.cur_datasheet)
+        try:
+            dsheet = self.datatop['data-sheet']
+        except KeyError:
+            return
+
+        dsroot = dsheet['ip-name']
+
+        # Remove any existing results from the datasheet records
+        self.clear_results(dsheet)
+
+        # Also must be more recent than datasheet
+        jtime = os.path.getmtime(self.cur_datasheet)
+
+        # dsroot = os.path.splitext(dsname)[0]
+
+        dsdir = dspath + '/spi'
+        if self.origin.get() == 'Layout Extracted':
+            spifile = dsdir + '/pex/' + dsroot + '.spi'
+            savesuffix = 'lsave'
+        else:
+            spifile = dsdir + '/' + dsroot + '.spi'
+            savesuffix = 'save'
+
+        dsdir = dspath + '/ngspice/char'
+        savefile = dsdir + '/datasheet_' + savesuffix + '.json'
+
+        if os.path.exists(savefile):
+            savetime = os.path.getmtime(savefile)
+
+        if os.path.exists(spifile):
+            spitime = os.path.getmtime(spifile)
+
+            if os.path.exists(savefile):
+                if (savetime > spitime and savetime > jtime):
+                    self.annotate(savesuffix, 0)
+                    print('Characterization results loaded.')
+                    # print('(' + savefile + ' timestamp = ' + str(savetime) + '; ' + self.cur_datasheet + ' timestamp = ' + str(jtime))
+                else:
+                    print('Saved datasheet is out-of-date, not loading')
+            else:
+                print('Datasheet file ' + savefile)
+                print('No saved datasheet file, nothing to pre-load')
+        else:
+            print('No netlist file ' + spifile + '!')
+
+        # Remove outdated datasheet.json and datasheet_anno.json to prevent
+        # them from overwriting characterization document entries
+
+        if os.path.exists(savefile):
+            if savetime < jtime:
+                print('Removing outdated save file ' + savefile)
+                os.remove(savefile)
+
+        savefile = dsdir + '/datasheet_anno.json'
+        if os.path.exists(savefile):
+            savetime = os.path.getmtime(savefile)
+            if savetime < jtime:
+                print('Removing outdated results file ' + savefile)
+                os.remove(savefile)
+
+        savefile = dsdir + '/datasheet.json'
+        if os.path.exists(savefile):
+            savetime = os.path.getmtime(savefile)
+            if savetime < jtime:
+                print('Removing outdated results file ' + savefile)
+                os.remove(savefile)
+
+    def create_datasheet_view(self):
+        dframe = self.datasheet_viewer.dframe
+ 
+        # Destroy the existing datasheet frame contents (if any)
+        for widget in dframe.winfo_children():
+            widget.destroy()
+        self.status = {}	# Clear dictionary
+
+        dsheet = self.datatop['data-sheet']
+        if 'global-conditions' in dsheet:
+            globcond = dsheet['global-conditions']
+        else:
+            globcond = []
+
+        # Add basic information at the top
+
+        n = 0
+        dframe.cframe = ttk.Frame(dframe)
+        dframe.cframe.grid(column = 0, row = n, sticky='ewns', columnspan = 10)
+
+        dframe.cframe.plabel = ttk.Label(dframe.cframe, text = 'Project IP name:',
+			style = 'italic.TLabel')
+        dframe.cframe.plabel.grid(column = 0, row = n, sticky='ewns', ipadx = 5)
+        dframe.cframe.pname = ttk.Label(dframe.cframe, text = dsheet['ip-name'],
+			style = 'normal.TLabel')
+        dframe.cframe.pname.grid(column = 1, row = n, sticky='ewns', ipadx = 5)
+        dframe.cframe.fname = ttk.Label(dframe.cframe, text = dsheet['foundry'],
+			style = 'normal.TLabel')
+        dframe.cframe.fname.grid(column = 2, row = n, sticky='ewns', ipadx = 5)
+        dframe.cframe.fname = ttk.Label(dframe.cframe, text = dsheet['node'],
+			style = 'normal.TLabel')
+        dframe.cframe.fname.grid(column = 3, row = n, sticky='ewns', ipadx = 5)
+        if 'decription' in dsheet:
+            dframe.cframe.pdesc = ttk.Label(dframe.cframe, text = dsheet['description'],
+			style = 'normal.TLabel')
+            dframe.cframe.pdesc.grid(column = 4, row = n, sticky='ewns', ipadx = 5)
+
+        if 'UID' in self.datatop:
+            n += 1
+            dframe.cframe.ulabel = ttk.Label(dframe.cframe, text = 'UID:',
+			style = 'italic.TLabel')
+            dframe.cframe.ulabel.grid(column = 0, row = n, sticky='ewns', ipadx = 5)
+            dframe.cframe.uname = ttk.Label(dframe.cframe, text = self.datatop['UID'],
+			style = 'normal.TLabel')
+            dframe.cframe.uname.grid(column = 1, row = n, columnspan = 5, sticky='ewns', ipadx = 5)
+
+        n = 1
+        ttk.Separator(dframe, orient='horizontal').grid(column=0, row=n, sticky='ewns', columnspan=10)
+
+        # Title block
+        n += 1
+        dframe.desc_title = ttk.Label(dframe, text = 'Parameter', style = 'title.TLabel')
+        dframe.desc_title.grid(column = 0, row = n, sticky='ewns')
+        dframe.method_title = ttk.Label(dframe, text = 'Method', style = 'title.TLabel')
+        dframe.method_title.grid(column = 1, row = n, sticky='ewns')
+        dframe.min_title = ttk.Label(dframe, text = 'Min', style = 'title.TLabel')
+        dframe.min_title.grid(column = 2, row = n, sticky='ewns', columnspan = 2)
+        dframe.typ_title = ttk.Label(dframe, text = 'Typ', style = 'title.TLabel')
+        dframe.typ_title.grid(column = 4, row = n, sticky='ewns', columnspan = 2)
+        dframe.max_title = ttk.Label(dframe, text = 'Max', style = 'title.TLabel')
+        dframe.max_title.grid(column = 6, row = n, sticky='ewns', columnspan = 2)
+        dframe.stat_title = ttk.Label(dframe, text = 'Status', style = 'title.TLabel')
+        dframe.stat_title.grid(column = 8, row = n, sticky='ewns')
+
+        if not self.sims_to_go:
+            self.allsimbutton = ttk.Button(dframe, text='Simulate All',
+			style = 'bluetitle.TButton', command = self.sim_all)
+        else:
+            self.allsimbutton = ttk.Button(dframe, text='Stop Simulations',
+			style = 'redtitle.TButton', command = self.stop_sims)
+        self.allsimbutton.grid(column = 9, row=n, sticky='ewns')
+
+        tooltip.ToolTip(self.allsimbutton, text = "Simulate all electrical parameters")
+
+        # Make all columns equally expandable
+        for i in range(10):
+            dframe.columnconfigure(i, weight = 1)
+
+        # Parse the file for electrical parameters
+        n += 1
+        binrex = re.compile(r'([0-9]*)\'([bodh])', re.IGNORECASE)
+        paramstodo = []
+        if 'electrical-params' in dsheet:
+            paramstodo.extend(dsheet['electrical-params'])
+        if 'physical-params' in dsheet:
+            paramstodo.extend(dsheet['physical-params'])
+
+        if self.origin.get() == 'Schematic Capture':
+            isschem = True
+        else:
+            isschem = False
+
+        for param in paramstodo:
+            # Fill frame with electrical parameter information
+            if 'method' in param:
+                p = param['method']
+                puniq = p + '.0'
+                if puniq in self.status:
+                    # This method was used before, so give it a unique identifier
+                    j = 1
+                    while True:
+                        puniq = p + '.' + str(j)
+                        if puniq not in self.status:
+                            break
+                        else:
+                            j += 1
+                else:
+                    j = 0
+                paramtype = 'electrical'
+            else:
+                paramtype = 'physical'
+                p = param['condition']
+                puniq = paramtype + '.' + p
+                j = 0
+
+            if 'editable' in param and param['editable'] == True:
+                normlabel   = 'hlight.TLabel'
+                redlabel    = 'rhlight.TLabel'
+                greenlabel  = 'ghlight.TLabel'
+                normbutton  = 'hlight.TButton'
+                redbutton   = 'rhlight.TButton'
+                greenbutton = 'ghlight.TButton'
+            else:
+                normlabel   = 'normal.TLabel'
+                redlabel    = 'red.TLabel'
+                greenlabel  = 'green.TLabel'
+                normbutton  = 'normal.TButton'
+                redbutton   = 'red.TButton'
+                greenbutton = 'green.TButton'
+
+            if 'display' in param:
+                dtext = param['display']
+            else:
+                dtext = p
+
+            # Special handling:  Change LVS_errors to "device check" when using
+            # schematic netlist.
+            if paramtype == 'physical':
+                if isschem:
+                    if p == 'LVS_errors':
+                        dtext = 'Invalid device check'
+
+            dframe.description = ttk.Label(dframe, text = dtext, style = normlabel)
+
+            dframe.description.grid(column = 0, row=n, sticky='ewns')
+            dframe.method = ttk.Label(dframe, text = p, style = normlabel)
+            dframe.method.grid(column = 1, row=n, sticky='ewns')
+            if 'plot' in param:
+                status_style = normlabel
+                dframe.plots = ttk.Frame(dframe)
+                dframe.plots.grid(column = 2, row=n, columnspan = 6, sticky='ewns')
+                plotrec = param['plot']
+                if 'status' in plotrec:
+                    status_value = plotrec['status']
+                else:
+                    status_value = '(not checked)'
+                dframe_plot = ttk.Label(dframe.plots, text=plotrec['filename'],
+				style = normlabel)
+                dframe_plot.grid(column = j, row = n, sticky='ewns')
+            else:
+                # For schematic capture, mark physical parameters that can't and won't be
+                # checked as "not applicable".
+                status_value = '(not checked)'
+                if paramtype == 'physical':
+                    if isschem:
+                       if p == 'area' or p == 'width' or p == 'height' or p == 'DRC_errors':
+                           status_value = '(N/A)'
+
+                if 'min' in param:
+                    status_style = normlabel
+                    pmin = param['min']
+                    if 'target' in pmin:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            targettext = pmin['target'] + ' ' + param['unit']
+                        else:
+                            targettext = pmin['target']
+                        # Hack for use of min to change method of scoring
+                        if not 'penalty' in pmin or pmin['penalty'] != '0':
+                            dframe.min = ttk.Label(dframe, text=targettext, style = normlabel)
+                        else:
+                            dframe.min = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                    else:
+                        dframe.min = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                    if 'score' in pmin:
+                        if pmin['score'] != 'fail':
+                            status_style = greenlabel
+                            if status_value != 'fail':
+                                status_value = 'pass'
+                        else:
+                            status_style = redlabel
+                            status_value = 'fail'
+                    if 'value' in pmin:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            valuetext = pmin['value'] + ' ' + param['unit']
+                        else:
+                            valuetext = pmin['value']
+                        dframe.value = ttk.Label(dframe, text=valuetext, style=status_style)
+                        dframe.value.grid(column = 3, row=n, sticky='ewns')
+                else:
+                    dframe.min = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                dframe.min.grid(column = 2, row=n, sticky='ewns')
+                if 'typ' in param:
+                    status_style = normlabel
+                    ptyp = param['typ']
+                    if 'target' in ptyp:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            targettext = ptyp['target'] + ' ' + param['unit']
+                        else:
+                            targettext = ptyp['target']
+                        dframe.typ = ttk.Label(dframe, text=targettext, style = normlabel)
+                    else:
+                        dframe.typ = ttk.Label(dframe, text='(no target)', style = normlabel)
+                    if 'score' in ptyp:
+                        # Note:  You can't fail a "typ" score, but there is only one "Status",
+                        # so if it is a "fail", it must remain a "fail".
+                        if ptyp['score'] != 'fail':
+                            status_style = greenlabel
+                            if status_value != 'fail':
+                                status_value = 'pass'
+                        else:
+                            status_style = redlabel
+                            status_value = 'fail'
+                    if 'value' in ptyp:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            valuetext = ptyp['value'] + ' ' + param['unit']
+                        else:
+                            valuetext = ptyp['value']
+                        dframe.value = ttk.Label(dframe, text=valuetext, style=status_style)
+                        dframe.value.grid(column = 5, row=n, sticky='ewns')
+                else:
+                    dframe.typ = ttk.Label(dframe, text='(no target)', style = normlabel)
+                dframe.typ.grid(column = 4, row=n, sticky='ewns')
+                if 'max' in param:
+                    status_style = normlabel
+                    pmax = param['max']
+                    if 'target' in pmax:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            targettext = pmax['target'] + ' ' + param['unit']
+                        else:
+                            targettext = pmax['target']
+                        # Hack for use of max to change method of scoring
+                        if not 'penalty' in pmax or pmax['penalty'] != '0':
+                            dframe.max = ttk.Label(dframe, text=targettext, style = normlabel)
+                        else:
+                            dframe.max = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                    else:
+                        dframe.max = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                    if 'score' in pmax:
+                        if pmax['score'] != 'fail':
+                            status_style = greenlabel
+                            if status_value != 'fail':
+                                status_value = 'pass'
+                        else:
+                            status_style = redlabel
+                            status_value = 'fail'
+                    if 'value' in pmax:
+                        if 'unit' in param and not binrex.match(param['unit']):
+                            valuetext = pmax['value'] + ' ' + param['unit']
+                        else:
+                            valuetext = pmax['value']
+                        dframe.value = ttk.Label(dframe, text=valuetext, style=status_style)
+                        dframe.value.grid(column = 7, row=n, sticky='ewns')
+                else:
+                    dframe.max = ttk.Label(dframe, text='(no limit)', style = normlabel)
+                dframe.max.grid(column = 6, row=n, sticky='ewns')
+
+            if paramtype == 'electrical':
+                if 'hints' in param:
+                    simtext = '\u2022Simulate'
+                else:
+                    simtext = 'Simulate'
+            else:
+                simtext = 'Check'
+
+            simbutton = ttk.Menubutton(dframe, text=simtext, style = normbutton)
+
+            # Generate pull-down menu on Simulate button.  Most items apply
+            # only to electrical parameters (at least for now)
+            simmenu = tkinter.Menu(simbutton)
+            simmenu.add_command(label='Run',
+			command = lambda puniq=puniq: self.sim_param(puniq))
+            simmenu.add_command(label='Stop', command = self.stop_sims)
+            if paramtype == 'electrical':
+                simmenu.add_command(label='Hints',
+			command = lambda param=param, simbutton=simbutton: self.add_hints(param, simbutton))
+                simmenu.add_command(label='Edit',
+			command = lambda param=param: self.edit_param(param))
+                simmenu.add_command(label='Copy',
+			command = lambda param=param: self.copy_param(param))
+                if 'editable' in param and param['editable'] == True:
+                    simmenu.add_command(label='Delete',
+				command = lambda param=param: self.delete_param(param))
+
+            # Attach the menu to the button
+            simbutton.config(menu=simmenu)
+
+            # simbutton = ttk.Button(dframe, text=simtext, style = normbutton)
+            #		command = lambda puniq=puniq: self.sim_param(puniq))
+
+            simbutton.grid(column = 9, row=n, sticky='ewns')
+
+            if paramtype == 'electrical':
+                tooltip.ToolTip(simbutton, text = "Simulate one electrical parameter")
+            else:
+                tooltip.ToolTip(simbutton, text = "Check one physical parameter")
+
+            # If 'pass', then just display message.  If 'fail', then create a button that
+            # opens and configures the failure report window.
+            if status_value == '(not checked)':
+                bstyle=normbutton
+                stat_label = ttk.Label(dframe, text=status_value, style=bstyle)
+            else:
+                if status_value == 'fail':
+                    bstyle=redbutton
+                else:
+                    bstyle=greenbutton
+                if paramtype == 'electrical':
+                    stat_label = ttk.Button(dframe, text=status_value, style=bstyle,
+				command = lambda param=param, globcond=globcond:
+				self.failreport.display(param, globcond,
+				self.cur_datasheet))
+                elif p == 'LVS_errors':
+                    dspath = os.path.split(self.cur_datasheet)[0]
+                    datasheet = os.path.split(self.cur_datasheet)[1]
+                    dsheet = self.datatop['data-sheet']
+                    designname = dsheet['ip-name']
+                    if self.origin.get() == 'Schematic Capture':
+                        lvs_file = dspath + '/mag/precheck.log'
+                    else:
+                        lvs_file = dspath + '/mag/comp.out'
+                    if not os.path.exists(lvs_file):
+                        if os.path.exists(dspath + '/mag/precheck.log'):
+                            lvs_file = dspath + '/mag/precheck.log'
+                        elif os.path.exists(dspath + '/mag/comp.out'):
+                            lvs_file = dspath + '/mag/comp.out'
+
+                    stat_label = ttk.Button(dframe, text=status_value, style=bstyle,
+				command = lambda lvs_file=lvs_file: self.textreport.display(lvs_file))
+                else:
+                    stat_label = ttk.Label(dframe, text=status_value, style=bstyle)
+                tooltip.ToolTip(stat_label,
+			text = "Show detail view of simulation conditions and results")
+            stat_label.grid(column = 8, row=n, sticky='ewns')
+            self.status[puniq] = stat_label
+            n += 1
+
+        for child in dframe.winfo_children():
+            child.grid_configure(ipadx = 5, ipady = 1, padx = 2, pady = 2)
+
+        # Check if a design submission and characterization may be in progress.
+        # If so, add the progress bar at the bottom.
+        self.check_ongoing_upload()
+
+if __name__ == '__main__':
+    faulthandler.register(signal.SIGUSR2)
+    options = []
+    arguments = []
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    root = tkinter.Tk()
+    app = OpenGalaxyCharacterize(root)
+    if arguments:
+        print('Calling set_datasheet with argument ' + arguments[0])
+        app.set_datasheet(arguments[0])
+
+    root.mainloop()
diff --git a/common/og_gui_manager.py b/common/og_gui_manager.py
new file mode 100755
index 0000000..921b3f1
--- /dev/null
+++ b/common/og_gui_manager.py
@@ -0,0 +1,4138 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3 -B
+#
+#--------------------------------------------------------
+# Open Galaxy Project Manager GUI.
+#
+# This is a Python tkinter script that handles local
+# project management.  It is meant as a replacement for
+# appsel_zenity.sh
+#
+#--------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# September 9, 2016
+# Modifications 2017, 2018
+# Version 1.0
+#--------------------------------------------------------
+
+import sys
+# Require python 3.5.x (and not python 3.6.x). Without this trap here, in several
+# instances of VMs where /usr/bin/python3 symlinked to 3.6.x by mistake, it manifests
+# as (misleading) errors like: ImportError: No module named 'yaml'
+#
+# '%x' % sys.hexversion  ->  '30502f0'
+
+import tkinter
+from tkinter import ttk, StringVar, Listbox, END
+from tkinter import filedialog
+
+# globals
+theProg = sys.argv[0]
+root = tkinter.Tk()      # WARNING: must be exactly one instance of Tk; don't call again elsewhere
+
+# 4 configurations based on booleans: splash,defer
+# n,n:  no splash, show only form when completed: LEGACY MODE, user confused by visual lag.
+# n,y:  no splash but defer projLoad: show an empty form ASAP
+# y,n:  yes splash, and wait for projLoad before showing completed form
+# y,y:  yes splash, but also defer projLoad: show empty form ASAP
+
+# deferLoad = False        # LEGACY: no splash, and wait for completed form
+# doSplash = False
+
+deferLoad = True         # True: display GUI before (slow) loading of projects, so no splash:
+doSplash = not deferLoad # splash IFF GUI-construction includes slow loading of projects
+
+# deferLoad = False        # load projects before showing form, so need splash:
+# doSplash = not deferLoad # splash IFF GUI-construction includes slow loading of projects
+
+# deferLoad = True         # here keep splash also, despite also deferred-loading
+# doSplash = True
+
+#------------------------------------------------------
+# Splash screen: display ASAP: BEFORE bulk of imports.
+#------------------------------------------------------
+
+class SplashScreen(tkinter.Toplevel):
+    """Open Galaxy Project Management Splash Screen"""
+
+    def __init__(self, parent, *args, **kwargs):
+        super().__init__(parent, *args, **kwargs)
+        parent.withdraw()
+        #EFABLESS PLATFORM
+        #image = tkinter.PhotoImage(file="/ef/efabless/opengalaxy/og_splashscreen50.gif")
+        label = ttk.Label(self, image=image)
+        label.pack()
+
+        # required to make window show before the program gets to the mainloop
+        self.update_idletasks()
+
+import faulthandler
+import signal
+
+# SplashScreen here. fyi: there's a 2nd/later __main__ section for main app
+splash = None     # a global
+if __name__ == '__main__':
+    faulthandler.register(signal.SIGUSR2)
+    if doSplash:
+        splash = SplashScreen(root)
+
+import io
+import os
+import re
+import json
+import yaml
+import shutil
+import tarfile
+import datetime
+import subprocess
+import contextlib
+import tempfile
+import glob
+
+import tksimpledialog
+import tooltip
+from rename_project import rename_project_all
+#from fix_libdirs import fix_libdirs
+from consoletext import ConsoleText
+from helpwindow import HelpWindow
+from treeviewchoice import TreeViewChoice
+from symbolbuilder import SymbolBuilder
+from make_icon_from_soft import create_symbol
+from profile import Profile
+
+import og_config
+
+# Global name for design directory
+designdir = 'design'
+# Global name for import directory
+importdir = 'import'
+# Global name for cloudv directory
+cloudvdir = 'cloudv'
+# Global name for archived imports project sub-directory
+archiveimportdir = 'imported'
+# Global name for current design file
+#EFABLESS PLATFORM
+currdesign = '~/.open_pdks/currdesign'
+prefsfile = '~/.open_pdks/prefs.json'
+
+
+#---------------------------------------------------------------
+# Watch a directory for modified time change.  Repeat every two
+# seconds.  Call routine callback() if a change occurs
+#---------------------------------------------------------------
+
+class WatchClock(object):
+    def __init__(self, parent, path, callback, interval=2000, interval0=None):
+        self.parent = parent
+        self.callback = callback
+        self.path = path
+        self.interval = interval
+        if interval0 != None:
+            self.interval0 = interval0
+            self.restart(first=True)
+        else:
+            self.interval0 = interval
+            self.restart()
+
+    def query(self):
+        for entry in self.path:
+            statbuf = os.stat(entry)
+            if statbuf.st_mtime > self.reftime:
+                self.callback()
+                self.restart()
+                return
+        self.timer = self.parent.after(self.interval, self.query)
+
+    def stop(self):
+        self.parent.after_cancel(self.timer)
+
+    # if first: optionally use different (typically shorter) interval, AND DON'T
+    # pre-record watched-dir mtime-s (which forces the callback on first timer fire)
+    def restart(self, first=False):
+        self.reftime = 0
+        if not first:
+            for entry in self.path:
+                statbuf = os.stat(entry)
+                if statbuf.st_mtime > self.reftime:
+                    self.reftime = statbuf.st_mtime
+        self.timer = self.parent.after(self.interval0 if first and self.interval0 != None else self.interval, self.query)
+
+#------------------------------------------------------
+# Dialog for generating a new layout
+#------------------------------------------------------
+
+class NewLayoutDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed=''):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+
+        self.l1prefs = tkinter.IntVar(master)
+        self.l1prefs.set(1)
+        ttk.Checkbutton(master, text='Populate new layout from netlist',
+			variable = self.l1prefs).grid(row = 2, columnspan = 2, sticky = 'enws')
+
+        return self
+
+    def apply(self):
+        return self.l1prefs.get
+
+#------------------------------------------------------
+# Simple dialog for entering project names
+#------------------------------------------------------
+
+class ProjectNameDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed=''):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        ttk.Label(master, text='Enter new project name:').grid(row = 1, column = 0, sticky = 'wns')
+        self.nentry = ttk.Entry(master)
+        self.nentry.grid(row = 1, column = 1, sticky = 'ewns')
+        self.nentry.insert(0, seed)
+        return self.nentry # initial focus
+
+    def apply(self):
+        return self.nentry.get()
+
+class PadFrameCellNameDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed=''):
+        description='PadFrame'       # TODO: make this an extra optional parameter of a generic CellNameDialog?
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        if description:
+            description = description + " "
+        else:
+            description = ""
+        ttk.Label(master, text=("Enter %scell name:" %(description))).grid(row = 1, column = 0, sticky = 'wns')
+        self.nentry = ttk.Entry(master)
+        self.nentry.grid(row = 1, column = 1, sticky = 'ewns')
+        self.nentry.insert(0, seed)
+        return self.nentry # initial focus
+
+    def apply(self):
+        return self.nentry.get()
+
+#------------------------------------------------------
+# Dialog for copying projects.  Includes checkbox
+# entries for preferences.
+#------------------------------------------------------
+
+class CopyProjectDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed=''):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        ttk.Label(master, text="Enter new project name:").grid(row = 1, column = 0, sticky = 'wns')
+        self.nentry = ttk.Entry(master)
+        self.nentry.grid(row = 1, column = 1, sticky = 'ewns')
+        self.nentry.insert(0, seed)
+        self.elprefs = tkinter.IntVar(master)
+        self.elprefs.set(0)
+        ttk.Checkbutton(master, text='Copy electric preferences (not recommended)',
+			variable = self.elprefs).grid(row = 2, columnspan = 2, sticky = 'enws')
+        self.spprefs = tkinter.IntVar(master)
+        self.spprefs.set(0)
+        ttk.Checkbutton(master, text='Copy ngspice folder (not recommended)',
+			variable = self.spprefs).grid(row = 3, columnspan = 2, sticky = 'enws')
+        return self.nentry # initial focus
+
+    def apply(self):
+        # Return a list containing the entry text and the checkbox states.
+        elprefs = True if self.elprefs.get() == 1 else False
+        spprefs = True if self.spprefs.get() == 1 else False
+        return [self.nentry.get(), elprefs, spprefs]
+
+#-------------------------------------------------------
+# Not-Quite-So-Simple dialog for entering a new project.
+# Select a project name and a PDK from a drop-down list.
+#-------------------------------------------------------
+
+class NewProjectDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed='', importnode=None, development=False):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        ttk.Label(master, text="Enter new project name:").grid(row = 1, column = 0)
+        self.nentry = ttk.Entry(master)
+        self.nentry.grid(row = 1, column = 1, sticky = 'ewns')
+        self.nentry.insert(0, seed or '')      # may be None
+        self.pvar = tkinter.StringVar(master)
+        if not importnode:
+            # Add PDKs as found by searching /ef/tech for 'libs.tech' directories
+            ttk.Label(master, text="Select foundry/node:").grid(row = 2, column = 0)
+        else:
+            ttk.Label(master, text="Foundry/node:").grid(row = 2, column = 0)
+        self.infolabel = ttk.Label(master, text="", style = 'brown.TLabel', wraplength=250)
+        self.infolabel.grid(row = 3, column = 0, columnspan = 2, sticky = 'news')
+        self.pdkmap = {}
+        self.pdkdesc = {}
+        self.pdkstat = {}
+        pdk_def = None
+
+        node_def = importnode
+        if not node_def:
+            node_def = "EFXH035B"
+
+        # use glob instead of os.walk. Don't need to recurse large PDK hier.
+        # TODO: stop hardwired default EFXH035B: get from an overall flow /ef/tech/.ef-config/plist.json
+        # (or get it from the currently selected project)
+        #EFABLESS PLATFORM
+        #TODO: Replace with PREFIX
+        for pdkdir_lr in glob.glob('/usr/share/pdk/*/libs.tech/'):
+            pdkdir = os.path.split( os.path.split( pdkdir_lr )[0])[0]    # discard final .../libs.tech/
+            (foundry, node, desc, status) = OpenGalaxyManager.pdkdir2fnd( pdkdir )
+            if not foundry or not node:
+                continue
+            key = foundry + '/' + node
+            self.pdkmap[key] = pdkdir
+            self.pdkdesc[key] = desc
+            self.pdkstat[key] = status
+            if node == node_def and not pdk_def:
+                pdk_def = key
+            
+        # Quick hack:  sorting puts EFXH035A before EFXH035LEGACY.  However, some
+        # ranking is needed.
+        pdklist = sorted( self.pdkmap.keys())
+        if not pdklist:
+            raise ValueError( "assertion failed, no available PDKs found")
+        pdk_def = (pdk_def or pdklist[0])
+
+        self.pvar.set(pdk_def)
+
+        # Restrict list to single entry if importnode was non-NULL and
+        # is in the PDK list (OptionMenu is replaced by a simple label)
+        # Otherwise, restrict the list to entries having an "status"
+        # entry equal to "active".  This allows some legacy PDKs to be
+	# disabled for creating new projects (but available for projects
+        # that already have them).
+        
+        if importnode:
+            self.pdkselect = ttk.Label(master, text = pdk_def, style='blue.TLabel')
+        else:
+            pdkactive = list(item for item in pdklist if self.pdkstat[item] == 'active')
+            if development:
+                pdkactive.extend(list(item for item in pdklist if self.pdkstat[item] == 'development'))
+
+            self.pdkselect = ttk.OptionMenu(master, self.pvar, pdk_def, *pdkactive,
+    			style='blue.TMenubutton', command=self.show_info)
+        self.pdkselect.grid(row = 2, column = 1)
+        self.show_info(0)
+
+        return self.nentry # initial focus
+
+    def show_info(self, args):
+        key = str(self.pvar.get())
+        desc = self.pdkdesc[key]
+        if desc == '':
+            self.infolabel.config(text='(no description available)')
+        else:
+            self.infolabel.config(text=desc)
+
+    def apply(self):
+        return self.nentry.get(), self.pdkmap[ str(self.pvar.get()) ]  # Note converts StringVar to string
+
+#----------------------------------------------------------------
+# Not-Quite-So-Simple dialog for selecting an existing project.
+# Select a project name from a drop-down list.  This could be
+# replaced by simply using the selected (current) project.
+#----------------------------------------------------------------
+
+class ExistingProjectDialog(tksimpledialog.Dialog):
+    def body(self, master, plist, seed, warning='Enter name of existing project to import into:'):
+        ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+
+        # Alphebetize list
+        plist.sort()
+        # Add projects
+        self.pvar = tkinter.StringVar(master)
+        self.pvar.set(plist[0])
+
+        ttk.Label(master, text='Select project:').grid(row = 1, column = 0)
+
+        self.projectselect = ttk.OptionMenu(master, self.pvar, plist[0], *plist, style='blue.TMenubutton')
+        self.projectselect.grid(row = 1, column = 1, sticky = 'ewns')
+        # pack version (below) hangs. Don't know why, changed to grid (like ProjectNameDialog)
+        # self.projectselect.pack(side = 'top', fill = 'both', expand = 'true')
+        return self.projectselect # initial focus
+
+    def apply(self):
+        return self.pvar.get()  # Note converts StringVar to string
+
+#----------------------------------------------------------------
+# Not-Quite-So-Simple dialog for selecting an existing ElecLib of existing project.
+# Select an elecLib name from a drop-down list.
+#----------------------------------------------------------------
+
+class ExistingElecLibDialog(tksimpledialog.Dialog):
+    def body(self, master, plist, seed):
+        warning = "Enter name of existing Electric library to import into:"
+        ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+
+        # Alphebetize list
+        plist.sort()
+        # Add electric libraries
+        self.pvar = tkinter.StringVar(master)
+        self.pvar.set(plist[0])
+
+        ttk.Label(master, text="Select library:").grid(row = 1, column = 0)
+
+        self.libselect = ttk.OptionMenu(master, self.pvar, plist[0], *plist, style='blue.TMenubutton')
+        self.libselect.grid(row = 1, column = 1)
+        return self.libselect # initial focus
+
+    def apply(self):
+        return self.pvar.get()  # Note converts StringVar to string
+
+#----------------------------------------------------------------
+# Dialog for layout, in case of multiple layout names, none of
+# which matches the project name (ip-name).  Method: Select a
+# layout name from a drop-down list.  If there is no project.json
+# file, add a checkbox for creating one and seeding the ip-name
+# with the name of the selected layout.  Include entry for
+# new layout, and for new layouts add a checkbox to import the
+# layout from schematic or verilog, if a valid candidate exists.
+#----------------------------------------------------------------
+
+class EditLayoutDialog(tksimpledialog.Dialog):
+    def body(self, master, plist, seed='', ppath='', pname='', warning='', hasnet=False):
+        ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        self.ppath = ppath
+        self.pname = pname
+
+        # Checkbox variable
+        self.confirm = tkinter.IntVar(master)
+        self.confirm.set(0)
+
+        # To-Do:  Add checkbox for netlist import
+
+        # Alphebetize list
+        plist.sort()
+        # Add additional item for new layout
+        plist.append('(New layout)')
+
+        # Add layouts to list
+        self.pvar = tkinter.StringVar(master)
+        self.pvar.set(plist[0])
+
+        ttk.Label(master, text='Selected layout to edit:').grid(row = 1, column = 0)
+
+        if pname in plist:
+            pseed = plist.index(pname)
+        else:
+            pseed = 0
+
+        self.layoutselect = ttk.OptionMenu(master, self.pvar, plist[pseed], *plist,
+				style='blue.TMenubutton', command=self.handle_choice)
+        self.layoutselect.grid(row = 1, column = 1, sticky = 'ewns')
+
+        # Create an entry form and checkbox for entering a new layout name, but
+        # keep them unpacked unless the "(New layout)" selection is chosen.
+
+        self.layoutbox = ttk.Frame(master)
+        self.layoutlabel = ttk.Label(self.layoutbox, text='New layout name:')
+        self.layoutlabel.grid(row = 0, column = 0, sticky = 'ewns')
+        self.layoutentry = ttk.Entry(self.layoutbox)
+        self.layoutentry.grid(row = 0, column = 1, sticky = 'ewns')
+        self.layoutentry.insert(0, pname)
+
+        # Only allow 'makeproject' checkbox if there is no project.json file
+        jname = ppath + '/project.json'
+        if not os.path.exists(jname):
+            dname = os.path.split(ppath)[1]
+            jname = ppath + '/' + dname + '.json'
+            if not os.path.exists(jname):
+                self.makeproject = ttk.Checkbutton(self.layoutbox,
+			text='Make default project name',
+			variable = self.confirm)
+                self.makeproject.grid(row = 2, column = 0, columnspan = 2, sticky = 'ewns')
+        return self.layoutselect # initial focus
+
+    def handle_choice(self, event):
+        if self.pvar.get() == '(New layout)':
+            # Add entry and checkbox for creating ad-hoc project.json file
+            self.layoutbox.grid(row = 1, column = 0, columnspan = 2, sticky = 'ewns')
+        else:
+            # Remove entry and checkbox
+            self.layoutbox.grid_forget()
+        return
+
+    def apply(self):
+        if self.pvar.get() == '(New layout)':
+            if self.confirm.get() == 1:
+                pname = self.pname
+                master.create_ad_hoc_json(self.layoutentry.get(), pname)
+            return self.layoutentry.get()
+        else:
+            return self.pvar.get()  # Note converts StringVar to string
+
+#----------------------------------------------------------------
+# Dialog for padframe: select existing ElecLib of existing project, type in a cellName.
+#   Select an elecLib name from a drop-down list.
+#   Text field for entry of a cellName.
+#----------------------------------------------------------------
+
+class ExistingElecLibCellDialog(tksimpledialog.Dialog):
+    def body(self, master, descPre, seed='', descPost='', plist=None, seedLibNm=None, seedCellNm=''):
+        warning = 'Pick existing Electric library; enter cell name'
+        warning = (descPre or '') + ((descPre and ': ') or '') + warning + ((descPost and ' ') or '') + (descPost or '')
+        ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+
+        # Alphebetize list
+        plist.sort()
+        # Add electric libraries
+        self.pvar = tkinter.StringVar(master)
+        pNdx = 0
+        if seedLibNm and seedLibNm in plist:
+            pNdx = plist.index(seedLibNm)
+        self.pvar.set(plist[pNdx])
+
+        ttk.Label(master, text='Electric library:').grid(row = 1, column = 0, sticky = 'ens')
+        self.libselect = ttk.OptionMenu(master, self.pvar, plist[pNdx], *plist, style='blue.TMenubutton')
+        self.libselect.grid(row = 1, column = 1, sticky = 'wns')
+
+        ttk.Label(master, text=('cell name:')).grid(row = 2, column = 0, sticky = 'ens')
+        self.nentry = ttk.Entry(master)
+        self.nentry.grid(row = 2, column = 1, sticky = 'ewns')
+        self.nentry.insert(0, seedCellNm)
+
+        return self.libselect # initial focus
+
+    def apply(self):
+        # return list of 2 strings: selected ElecLibName, typed-in cellName.
+        return [self.pvar.get(), self.nentry.get()]  # Note converts StringVar to string
+
+#------------------------------------------------------
+# Simple dialog for confirming anything.
+#------------------------------------------------------
+
+class ConfirmDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        return self
+
+    def apply(self):
+        return 'okay'
+
+#------------------------------------------------------
+# More proactive dialog for confirming an invasive
+# procedure like "delete project".  Requires user to
+# click a checkbox to ensure this is not a mistake.
+# confirmPrompt can be overridden, default='I am sure I want to do this.'
+#------------------------------------------------------
+
+class ProtectedConfirmDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed='', confirmPrompt=None):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        self.confirm = tkinter.IntVar(master)
+        self.confirm.set(0)
+        if not confirmPrompt:
+            confirmPrompt='I am sure I want to do this.'
+        ttk.Checkbutton(master, text=confirmPrompt,
+			variable = self.confirm).grid(row = 1, columnspan = 2, sticky = 'enws')
+        return self
+
+    def apply(self):
+        return 'okay' if self.confirm.get() == 1 else ''
+
+#------------------------------------------------------
+# Simple dialog to say "blah is not implemented yet."
+#------------------------------------------------------
+
+class NotImplementedDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed):
+        if not warning:
+            warning = "Sorry, that feature is not implemented yet"
+        if warning:
+            warning = "Sorry, " + warning + ", is not implemented yet"
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        return self
+
+    def apply(self):
+        return 'okay'
+
+#------------------------------------------------------
+# (This is actually a generic confirm dialogue, no install/overwrite intelligence)
+# But so far dedicated to confirming the installation of one or more files,
+# with notification of which (if any) will overwrite existing files.
+#
+# The warning parameter is fully constructed by caller, as multiple lines as either:
+#   For the import of module 'blah',
+#   CONFIRM installation of (*: OVERWRITE existing):
+#    * path1
+#      path2
+#      ....
+# or:
+#   For the import of module 'blah',
+#   CONFIRM installation of:
+#      path1
+#      path2
+#      ....
+# TODO: bastardizes warning parameter as multiple lines. Implement some other way?
+#------------------------------------------------------
+
+class ConfirmInstallDialog(tksimpledialog.Dialog):
+    def body(self, master, warning, seed):
+        if warning:
+            ttk.Label(master, text=warning).grid(row = 0, columnspan = 2, sticky = 'wns')
+        return self
+
+    def apply(self):
+        return 'okay'
+
+#------------------------------------------------------
+# Open Galaxy Manager class
+#------------------------------------------------------
+
+class OpenGalaxyManager(ttk.Frame):
+    """Open Galaxy Project Management GUI."""
+
+    def __init__(self, parent, *args, **kwargs):
+        super().__init__(parent, *args, **kwargs)
+        self.root = parent
+        parent.withdraw()
+        # self.update()
+        self.update_idletasks()     # erase small initial frame asap
+        self.init_gui()
+        parent.protocol("WM_DELETE_WINDOW", self.on_quit)
+        if splash:
+            splash.destroy()
+        parent.deiconify()
+
+    def on_quit(self):
+        """Exits program."""
+        quit()
+
+    def init_gui(self):
+        """Builds GUI."""
+        global designdir
+        global importdir
+        global archiveimportdir
+        global currdesign
+        global theProg
+        global deferLoad
+
+        message = []
+        allPaneOpen = False
+        prjPaneMinh = 10
+        iplPaneMinh = 4
+        impPaneMinh = 4
+
+        # if deferLoad:         # temp. for testing... open all panes
+        #     allPaneOpen = True
+
+        # Read user preferences
+        self.prefs = {}
+        self.read_prefs()
+
+        # Get default font size from user preferences
+        fontsize = self.prefs['fontsize']
+
+        s = ttk.Style()
+        available_themes = s.theme_names()
+        # print("themes: " + str(available_themes))
+        s.theme_use(available_themes[0])
+
+        s.configure('gray.TFrame', background='gray40')
+        s.configure('blue_white.TFrame', bordercolor = 'blue', borderwidth = 3)
+        s.configure('italic.TLabel', font=('Helvetica', fontsize, 'italic'))
+        s.configure('title.TLabel', font=('Helvetica', fontsize, 'bold italic'),
+                        foreground = 'brown', anchor = 'center')
+        s.configure('title2.TLabel', font=('Helvetica', fontsize, 'bold italic'),
+                        foreground = 'blue')
+        s.configure('normal.TLabel', font=('Helvetica', fontsize))
+        s.configure('red.TLabel', font=('Helvetica', fontsize), foreground = 'red')
+        s.configure('brown.TLabel', font=('Helvetica', fontsize), foreground = 'brown3', background = 'gray95')
+        s.configure('green.TLabel', font=('Helvetica', fontsize), foreground = 'green3')
+        s.configure('blue.TLabel', font=('Helvetica', fontsize), foreground = 'blue')
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        s.configure('red.TButton', font=('Helvetica', fontsize), foreground = 'red', border = 3,
+                        relief = 'raised')
+        s.configure('green.TButton', font=('Helvetica', fontsize), foreground = 'green3', border = 3,
+                        relief = 'raised')
+        s.configure('blue.TMenubutton', font=('Helvetica', fontsize), foreground = 'blue', border = 3,
+			relief = 'raised')
+
+        # Create the help window
+        self.help = HelpWindow(self, fontsize=fontsize)
+        
+        with io.StringIO() as buf, contextlib.redirect_stdout(buf):
+            self.help.add_pages_from_file(og_config.apps_path + '/manager_help.txt')
+            message = buf.getvalue()
+        
+
+        # Set the help display to the first page
+        self.help.page(0)
+
+        # Create the profile settings window
+        self.profile = Profile(self, fontsize=fontsize)
+
+        # Variables used by option menus
+        self.seltype = tkinter.StringVar(self)
+        self.cur_project = tkinter.StringVar(self)
+        self.cur_import = "(nothing selected)"
+        self.project_name = ""
+
+        # Root window title
+        self.root.title('Open Galaxy Project Manager')
+        self.root.option_add('*tearOff', 'FALSE')
+        self.pack(side = 'top', fill = 'both', expand = 'true')
+
+        pane = tkinter.PanedWindow(self, orient = 'vertical', sashrelief='groove', sashwidth=6)
+        pane.pack(side = 'top', fill = 'both', expand = 'true')
+        self.toppane = ttk.Frame(pane)
+        self.botpane = ttk.Frame(pane)
+
+        # All interior windows size to toppane
+        self.toppane.columnconfigure(0, weight = 1)
+        # Projects window resizes preferably to others
+        self.toppane.rowconfigure(3, weight = 1)
+
+        # Get username, and from it determine the project directory.
+        # Save this path, because it gets used often.
+        username = self.prefs['username']
+        self.projectdir = os.path.expanduser('~/' + designdir)
+        self.cloudvdir = os.path.expanduser('~/' + cloudvdir)
+
+        # Check that the project directory exists, and create it if not
+        if not os.path.isdir(self.projectdir):
+            os.makedirs(self.projectdir)
+
+        # Label with the user
+        self.toppane.user_frame = ttk.Frame(self.toppane)
+        self.toppane.user_frame.grid(row = 0, sticky = 'news')
+
+        # Put logo image in corner.  Ignore if something goes wrong, as this
+        # is only decorative.  Note: ef_logo must be kept as a record in self,
+        # or else it gets garbage collected.
+        try:
+            #EFABLESS PLATFORM
+            self.ef_logo = tkinter.PhotoImage(file='/ef/efabless/opengalaxy/efabless_logo_small.gif')
+            self.toppane.user_frame.logo = ttk.Label(self.toppane.user_frame, image=self.ef_logo)
+            self.toppane.user_frame.logo.pack(side = 'left', padx = 5)
+        except:
+            pass
+
+        self.toppane.user_frame.title = ttk.Label(self.toppane.user_frame, text='User:', style='red.TLabel')
+        self.toppane.user_frame.user = ttk.Label(self.toppane.user_frame, text=username, style='blue.TLabel')
+
+        self.toppane.user_frame.title.pack(side = 'left', padx = 5)
+        self.toppane.user_frame.user.pack(side = 'left', padx = 5)
+
+        #---------------------------------------------
+        ttk.Separator(self.toppane, orient='horizontal').grid(row = 1, sticky = 'news')
+        #---------------------------------------------
+
+        # List of projects:
+        self.toppane.design_frame = ttk.Frame(self.toppane)
+        self.toppane.design_frame.grid(row = 2, sticky = 'news')
+
+        self.toppane.design_frame.design_header = ttk.Label(self.toppane.design_frame, text='Projects',
+			style='title.TLabel')
+        self.toppane.design_frame.design_header.pack(side = 'left', padx = 5)
+
+        self.toppane.design_frame.design_header2 = ttk.Label(self.toppane.design_frame,
+			text='(' + self.projectdir + '/)', style='normal.TLabel')
+        self.toppane.design_frame.design_header2.pack(side = 'left', padx = 5)
+
+        # Get current project from ~/.efmeta/currdesign and set the selection.
+        try:
+            with open(os.path.expanduser(currdesign), 'r') as f:
+                pnameCur = f.read().rstrip()
+        except:
+            pnameCur = None
+
+        # Create listbox of projects
+        projectlist = self.get_project_list() if not deferLoad else []
+        height = min(10, max(prjPaneMinh, 2 + len(projectlist)))
+        self.projectselect = TreeViewChoice(self.toppane, fontsize=fontsize, deferLoad=deferLoad, selectVal=pnameCur, natSort=True)
+        self.projectselect.populate("Available Projects:", projectlist,
+			[["Create", True, self.createproject],
+			 ["Copy", False, self.copyproject],
+			 ["Rename IP", False, self.renameproject],
+			 ["<CloudV", True, self.cloudvimport],
+			 ["Clean", False, self.cleanproject],
+			 ["Delete", False, self.deleteproject]],
+			height=height, columns=[0, 1])
+        self.projectselect.grid(row = 3, sticky = 'news')
+        self.projectselect.bindselect(self.setcurrent)
+
+        tooltip.ToolTip(self.projectselect.get_button(0), text="Create a new project")
+        tooltip.ToolTip(self.projectselect.get_button(1), text="Make a copy of an entire project")
+        tooltip.ToolTip(self.projectselect.get_button(2), text="Rename a project folder")
+        tooltip.ToolTip(self.projectselect.get_button(3), text="Import CloudV project as new project")
+        tooltip.ToolTip(self.projectselect.get_button(4), text="Clean simulation data from project")
+        tooltip.ToolTip(self.projectselect.get_button(5), text="Delete an entire project")
+
+        pdklist = self.get_pdk_list(projectlist)
+        self.projectselect.populate2("PDK", projectlist, pdklist)
+
+        if pnameCur:
+            try:
+                curitem = next(item for item in projectlist if pnameCur == os.path.split(item)[1])
+            except StopIteration:
+                pass
+            else:
+                if curitem:
+                    self.projectselect.setselect(pnameCur)
+
+        # Check that the import directory exists, and create it if not
+        if not os.path.isdir(self.projectdir + '/' + importdir):
+            os.makedirs(self.projectdir + '/' + importdir)
+
+        # Create a watchdog on the project and import directories
+        watchlist = [self.projectdir, self.projectdir + '/' + importdir]
+        if os.path.isdir(self.projectdir + '/upload'):
+            watchlist.append(self.projectdir + '/upload')
+        
+        # Check the creation time of the project manager app itself.  Because the project
+        # manager tends to be left running indefinitely, it is important to know when it
+        # has been updated.  This is checked once every hour since it is really expected
+        # only to happen occasionally.
+
+        thisapp = [theProg]
+        self.watchself = WatchClock(self, thisapp, self.update_alert, 3600000)
+
+        #---------------------------------------------
+
+        # Add second button bar for major project applications
+        self.toppane.apptitle = ttk.Label(self.toppane, text='Tools:', style='title2.TLabel')
+        self.toppane.apptitle.grid(row = 4, sticky = 'news')
+        self.toppane.appbar = ttk.Frame(self.toppane)
+        self.toppane.appbar.grid(row = 5, sticky = 'news')
+
+        # Define the application buttons and actions
+        self.toppane.appbar.schem_button = ttk.Button(self.toppane.appbar, text='Edit Schematic',
+		command=self.edit_schematic, style = 'normal.TButton')
+        self.toppane.appbar.schem_button.pack(side = 'left', padx = 5)
+        self.toppane.appbar.layout_button = ttk.Button(self.toppane.appbar, text='Edit Layout',
+		command=self.edit_layout, style = 'normal.TButton')
+        self.toppane.appbar.layout_button.pack(side = 'left', padx = 5)
+        self.toppane.appbar.lvs_button = ttk.Button(self.toppane.appbar, text='Run LVS',
+		command=self.run_lvs, style = 'normal.TButton')
+        self.toppane.appbar.lvs_button.pack(side = 'left', padx = 5)
+        self.toppane.appbar.char_button = ttk.Button(self.toppane.appbar, text='Characterize',
+		command=self.characterize, style = 'normal.TButton')
+        self.toppane.appbar.char_button.pack(side = 'left', padx = 5)
+        self.toppane.appbar.synth_button = ttk.Button(self.toppane.appbar, text='Synthesis Flow',
+		command=self.synthesize, style = 'normal.TButton')
+        self.toppane.appbar.synth_button.pack(side = 'left', padx = 5)
+
+        self.toppane.appbar.padframeCalc_button = ttk.Button(self.toppane.appbar, text='Pad Frame',
+        	command=self.padframe_calc, style = 'normal.TButton')
+        self.toppane.appbar.padframeCalc_button.pack(side = 'left', padx = 5)
+        '''
+        if self.prefs['schemeditor'] == 'xcircuit':
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'XCircuit' schematic editor")
+        elif self.prefs['schemeditor'] == 'xschem':
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'XSchem' schematic editor")
+        else:
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'Electric' schematic editor")
+
+        if self.prefs['layouteditor'] == 'klayout':
+            tooltip.ToolTip(self.toppane.appbar.layout_button, text="Start 'KLayout' layout editor")
+        else:
+            tooltip.ToolTip(self.toppane.appbar.layout_button, text="Start 'Magic' layout editor")
+        '''  
+        self.refreshToolTips()
+        
+        tooltip.ToolTip(self.toppane.appbar.lvs_button, text="Start LVS tool")
+        tooltip.ToolTip(self.toppane.appbar.char_button, text="Start Characterization tool")
+        tooltip.ToolTip(self.toppane.appbar.synth_button, text="Start Digital Synthesis tool")
+        tooltip.ToolTip(self.toppane.appbar.padframeCalc_button, text="Start Pad Frame Generator")
+
+        #---------------------------------------------
+        ttk.Separator(self.toppane, orient='horizontal').grid(row = 6, sticky = 'news')
+        #---------------------------------------------
+        # List of IP libraries:
+        self.toppane.library_frame = ttk.Frame(self.toppane)
+        self.toppane.library_frame.grid(row = 7, sticky = 'news')
+
+        self.toppane.library_frame.library_header = ttk.Label(self.toppane.library_frame, text='IP Library:',
+			style='title.TLabel')
+        self.toppane.library_frame.library_header.pack(side = 'left', padx = 5)
+
+        self.toppane.library_frame.library_header2 = ttk.Label(self.toppane.library_frame,
+			text='(' + self.projectdir + '/ip/)', style='normal.TLabel')
+        self.toppane.library_frame.library_header2.pack(side = 'left', padx = 5)
+
+        self.toppane.library_frame.library_header3 = ttk.Button(self.toppane.library_frame,
+		text=(allPaneOpen and '-' or '+'), command=self.library_toggle, style = 'normal.TButton', width = 2)
+        self.toppane.library_frame.library_header3.pack(side = 'right', padx = 5)
+
+        # Create listbox of IP libraries
+        iplist = self.get_library_list() if not deferLoad else []
+        height = min(8, max(iplPaneMinh, 2 + len(iplist)))
+        self.ipselect = TreeViewChoice(self.toppane, fontsize=fontsize, deferLoad=deferLoad, natSort=True)
+        self.ipselect.populate("IP Library:", iplist,
+			[], height=height, columns=[0, 1], versioning=True)
+        valuelist = self.ipselect.getvaluelist()
+        datelist = self.get_date_list(valuelist)
+        itemlist = self.ipselect.getlist()
+        self.ipselect.populate2("date", itemlist, datelist)
+        if allPaneOpen:
+            self.library_open()
+
+        #---------------------------------------------
+        ttk.Separator(self.toppane, orient='horizontal').grid(row = 9, sticky = 'news')
+        #---------------------------------------------
+        # List of imports:
+        self.toppane.import_frame = ttk.Frame(self.toppane)
+        self.toppane.import_frame.grid(row = 10, sticky = 'news')
+
+        self.toppane.import_frame.import_header = ttk.Label(self.toppane.import_frame, text='Imports:',
+			style='title.TLabel')
+        self.toppane.import_frame.import_header.pack(side = 'left', padx = 5)
+
+        self.toppane.import_frame.import_header2 = ttk.Label(self.toppane.import_frame,
+			text='(' + self.projectdir + '/import/)', style='normal.TLabel')
+        self.toppane.import_frame.import_header2.pack(side = 'left', padx = 5)
+
+        self.toppane.import_frame.import_header3 = ttk.Button(self.toppane.import_frame,
+		text=(allPaneOpen and '-' or '+'), command=self.import_toggle, style = 'normal.TButton', width = 2)
+        self.toppane.import_frame.import_header3.pack(side = 'right', padx = 5)
+
+        # Create listbox of imports
+        importlist = self.get_import_list() if not deferLoad else []
+        self.number_of_imports = len(importlist) if not deferLoad else None
+        height = min(8, max(impPaneMinh, 2 + len(importlist)))
+        self.importselect = TreeViewChoice(self.toppane, fontsize=fontsize, markDir=True, deferLoad=deferLoad)
+        self.importselect.populate("Pending Imports:", importlist,
+			[["Import As", False, self.importdesign],
+			["Import Into", False, self.importintodesign],
+			["Delete", False, self.deleteimport]], height=height, columns=[0, 1])
+        valuelist = self.importselect.getvaluelist()
+        datelist = self.get_date_list(valuelist)
+        itemlist = self.importselect.getlist()
+        self.importselect.populate2("date", itemlist, datelist)
+
+        tooltip.ToolTip(self.importselect.get_button(0), text="Import as a new project")
+        tooltip.ToolTip(self.importselect.get_button(1), text="Import into an existing project")
+        tooltip.ToolTip(self.importselect.get_button(2), text="Remove the import file(s)")
+        if allPaneOpen:
+            self.import_open()
+
+        #---------------------------------------------
+        # ttk.Separator(self, orient='horizontal').grid(column = 0, row = 8, columnspan=4, sticky='ew')
+        #---------------------------------------------
+
+        # Add a text window below the import to capture output.  Redirect
+        # print statements to it.
+        self.botpane.console = ttk.Frame(self.botpane)
+        self.botpane.console.pack(side = 'top', fill = 'both', expand = 'true')
+
+        self.text_box = ConsoleText(self.botpane.console, wrap='word', height = 4)
+        self.text_box.pack(side='left', fill='both', expand = 'true')
+        console_scrollbar = ttk.Scrollbar(self.botpane.console)
+        console_scrollbar.pack(side='right', fill='y')
+        # attach console to scrollbar
+        self.text_box.config(yscrollcommand = console_scrollbar.set)
+        console_scrollbar.config(command = self.text_box.yview)
+
+        # Give all the expansion weight to the message window.
+        # self.rowconfigure(9, weight = 1)
+        # self.columnconfigure(0, weight = 1)
+
+        # at bottom (legacy mode): window height grows by one row.
+        # at top the buttons share a row with user name, reduce window height, save screen real estate.
+        bottomButtons = False
+
+        # Add button bar: at the bottom of window (legacy mode), or share top row with user-name
+        if bottomButtons:
+            bbar =  ttk.Frame(self.botpane)
+            bbar.pack(side='top', fill = 'x')
+        else:
+            bbar =  self.toppane.user_frame
+
+        # Define help button
+        bbar.help_button = ttk.Button(bbar, text='Help',
+		                      command=self.help.open, style = 'normal.TButton')
+
+        # Define profile settings button
+        bbar.profile_button = ttk.Button(bbar, text='Settings',
+		                         command=self.profile.open, style = 'normal.TButton')
+
+        # Define the "quit" button and action
+        bbar.quit_button = ttk.Button(bbar, text='Quit', command=self.on_quit,
+                                      style = 'normal.TButton')
+        # Tool tips for button bar
+        tooltip.ToolTip(bbar.quit_button, text="Exit the project manager")
+        tooltip.ToolTip(bbar.help_button, text="Show help window")
+
+        if bottomButtons:
+            bbar.help_button.pack(side = 'left', padx = 5)
+            bbar.profile_button.pack(side = 'left', padx = 5)
+            bbar.quit_button.pack(side = 'right', padx = 5)
+        else:
+            # quit at TR like window-title's close; help towards the outside, settings towards inside
+            bbar.quit_button.pack(side = 'right', padx = 5)
+            bbar.help_button.pack(side = 'right', padx = 5)
+            bbar.profile_button.pack(side = 'right', padx = 5)
+
+        # Add the panes once the internal geometry is known
+        pane.add(self.toppane)
+        pane.add(self.botpane)
+        pane.paneconfig(self.toppane, stretch='first')
+        # self.update_idletasks()
+
+        #---------------------------------------------------------------
+        # Project list
+        # projects = os.listdir(os.path.expanduser('~/' + designdir))
+        # self.cur_project.set(projects[0])
+        # self.design_select = ttk.OptionMenu(self, self.cur_project, projects[0], *projects,
+        #		style='blue.TMenubutton')
+
+        # New import list
+        # self.import_select = ttk.Button(self, text=self.cur_import, command=self.choose_import)
+
+        #---------------------------------------------------------
+        # Define project design actions
+        # self.design_actions = ttk.Frame(self)
+        # self.design_actions.characterize = ttk.Button(self.design_actions,
+        #  	text='Upload and Characterize', command=self.characterize)
+        # self.design_actions.characterize.grid(column = 0, row = 0)
+
+        # Define import actions
+        # self.import_actions = ttk.Frame(self)
+        # self.import_actions.upload = ttk.Button(self.import_actions,
+        #	text='Upload Challenge', command=self.make_challenge)
+        # self.import_actions.upload.grid(column = 0, row = 0)
+
+        self.watchclock = WatchClock(self, watchlist, self.update_project_views, 2000,
+                                     0 if deferLoad else None) # do immediate forced refresh (1st in mainloop)
+        # self.watchclock = WatchClock(self, watchlist, self.update_project_views, 2000)
+
+        # Redirect stdout and stderr to the console as the last thing to do. . .
+        # Otherwise errors in the GUI get sucked into the void.
+        self.stdout = sys.stdout
+        self.stderr = sys.stderr
+        sys.stdout = ConsoleText.StdoutRedirector(self.text_box)
+        sys.stderr = ConsoleText.StderrRedirector(self.text_box)
+
+        if message:
+            print(message)
+
+        if self.prefs == {}:
+            print("No user preferences file, using default settings.")
+
+    # helper for Profile to do live mods of some of the user-prefs (without restart projectManager):
+    def setUsername(self, newname):
+        self.toppane.user_frame.user.config(text=newname)
+        
+    def refreshToolTips(self):
+        if self.prefs['schemeditor'] == 'xcircuit':
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'XCircuit' schematic editor")
+        elif self.prefs['schemeditor'] == 'xschem':
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'XSchem' schematic editor")
+        else:
+            tooltip.ToolTip(self.toppane.appbar.schem_button, text="Start 'Electric' schematic editor")
+
+        if self.prefs['layouteditor'] == 'klayout':
+            tooltip.ToolTip(self.toppane.appbar.layout_button, text="Start 'KLayout' layout editor")
+        else:
+            tooltip.ToolTip(self.toppane.appbar.layout_button, text="Start 'Magic' layout editor")
+            
+    def config_path(self, path):
+        #returns the directory that path contains between .config and .ef-config
+        if (os.path.exists(path + '/.config')):
+            return '/.config'
+        elif (os.path.exists(path + '/.ef-config')):
+            return '/.ef-config'
+        raise FileNotFoundError('Neither '+path+'/.config nor '+path+'/.ef-config exists.')
+
+    #------------------------------------------------------------------------
+    # Check if a name is blacklisted for being a project folder
+    #------------------------------------------------------------------------
+
+    def blacklisted(self, dirname):
+        # Blacklist:  Do not show files of these names:
+        blacklist = [importdir, 'ip', 'upload', 'export', 'lost+found']
+        if dirname in blacklist:
+            return True
+        else:
+            return False
+
+    def write_prefs(self):
+        global prefsfile
+
+        if self.prefs:
+            expprefsfile = os.path.expanduser(prefsfile)
+            prefspath = os.path.split(expprefsfile)[0]
+            if not os.path.exists(prefspath):
+                os.makedirs(prefspath)
+            with open(os.path.expanduser(prefsfile), 'w') as f:
+                json.dump(self.prefs, f, indent = 4)
+
+    def read_prefs(self):
+        global prefsfile
+
+        # Set all known defaults even if they are not in the JSON file so
+        # that it is not necessary to check for the existence of the keyword
+        # in the dictionary every time it is accessed.
+        if 'fontsize' not in self.prefs:
+            self.prefs['fontsize'] = 11
+        userid = os.environ['USER']
+        uid = ''
+        username = userid
+        self.prefs['username'] = username
+        
+        '''
+        if 'username' not in self.prefs:
+            
+            # 
+            #EFABLESS PLATFORM
+            p = subprocess.run(['/ef/apps/bin/withnet' ,
+			og_config.apps_path + '/og_uid_service.py', userid],
+			stdout = subprocess.PIPE)
+            if p.stdout:
+                uid_string = p.stdout.splitlines()[0].decode('utf-8')
+                userspec = re.findall(r'[^"\s]\S*|".+?"', uid_string)
+                if len(userspec) > 0:
+                    username = userspec[0].strip('"')
+                    # uid = userspec[1]
+                    # Note userspec[1] = UID and userspec[2] = role, useful
+                    # for future applications.
+                else:
+                    username = userid
+            else:
+                username = userid
+            self.prefs['username'] = username
+            # self.prefs['uid'] = uid
+        '''
+        if 'schemeditor' not in self.prefs:
+            self.prefs['schemeditor'] = 'electric'
+
+        if 'layouteditor' not in self.prefs:
+            self.prefs['layouteditor'] = 'magic'
+
+        if 'magic-graphics' not in self.prefs:
+            self.prefs['magic-graphics'] = 'X11'
+
+        if 'development' not in self.prefs:
+            self.prefs['development'] = False
+
+        if 'devstdcells' not in self.prefs:
+            self.prefs['devstdcells'] = False
+
+        # Any additional user preferences go above this line.
+
+        # Get user preferences from ~/design/.profile/prefs.json and use it to
+        # overwrite default entries in self.prefs
+        try:
+            with open(os.path.expanduser(prefsfile), 'r') as f:
+                prefsdict = json.load(f)
+                for key in prefsdict:
+                    self.prefs[key] = prefsdict[key]
+        except:
+            # No preferences file, so create an initial one.
+            if not os.path.exists(prefsfile):
+                self.write_prefs()
+        
+        # if 'User:' Label exists, this updates it live (Profile calls read_prefs after write)
+        try:
+            self.setUsername(self.prefs['username'])
+        except:
+            pass
+
+    #------------------------------------------------------------------------
+    # Get a list of the projects in the user's design directory.  Exclude
+    # items that are not directories, or which are blacklisted.
+    #------------------------------------------------------------------------
+
+    def get_project_list(self):
+        global importdir
+
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[ \t\n].*")
+
+        # Get contents of directory.  Look only at directories
+        projectlist = list(item for item in os.listdir(self.projectdir) if
+			os.path.isdir(self.projectdir + '/' + item))
+
+        # 'import' and others in the blacklist are not projects!
+        # Files beginning with '.' and files with whitespace are
+        # also not listed.
+        for item in projectlist[:]:
+            if self.blacklisted(item):
+                projectlist.remove(item)
+            elif badrex1.match(item):
+                projectlist.remove(item)
+            elif badrex2.match(item):
+                projectlist.remove(item)
+
+        # Add pathname to all items in projectlist
+        projectlist = [self.projectdir + '/' + item for item in projectlist]
+        return projectlist
+
+    #------------------------------------------------------------------------
+    # Get a list of the projects in the user's cloudv directory.  Exclude
+    # items that are not directories, or which are blacklisted.
+    #------------------------------------------------------------------------
+
+    def get_cloudv_project_list(self):
+        global importdir
+
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[ \t\n].*")
+
+        if not os.path.exists(self.cloudvdir):
+            print('No user cloudv dir exists;  no projects to import.')
+            return None
+
+        # Get contents of cloudv directory.  Look only at directories
+        projectlist = list(item for item in os.listdir(self.cloudvdir) if
+			os.path.isdir(self.cloudvdir + '/' + item))
+
+        # 'import' and others in the blacklist are not projects!
+        # Files beginning with '.' and files with whitespace are
+        # also not listed.
+        for item in projectlist[:]:
+            if self.blacklisted(item):
+                projectlist.remove(item)
+            elif badrex1.match(item):
+                projectlist.remove(item)
+            elif badrex2.match(item):
+                projectlist.remove(item)
+
+        # Add pathname to all items in projectlist
+        projectlist = [self.cloudvdir + '/' + item for item in projectlist]
+        return projectlist
+
+    #------------------------------------------------------------------------
+    # utility: [re]intialize a project's elec/ dir: the .java preferences and LIBDIRS.
+    # So user can just delete .java, and restart electric (from projectManager), to reinit preferences.
+    # So user can just delete LIBDIRS, and restart electric (from projectManager), to reinit LIBDIRS.
+    # So project copies/imports can filter ngspice/run (and ../.allwaves), we'll recreate it here.
+    #
+    # The global /ef/efabless/deskel/* is used and the PDK name substituted.
+    #
+    # This SINGLE function is used to setup elec/ contents for new projects, in addition to being
+    # called in-line prior to "Edit Schematics" (on-the-fly).
+    #------------------------------------------------------------------------
+    @classmethod
+    def reinitElec(cls, design):
+        pdkdir = os.path.join( design, ".ef-config/techdir")
+        elec = os.path.join( design, "elec")
+
+        # on the fly, ensure has elec/ dir, ensure has ngspice/run/allwaves dir
+        try:
+            os.makedirs(design + '/elec', exist_ok=True)
+        except IOError as e:
+            print('Error in os.makedirs(elec): ' + str(e))
+        try:
+            os.makedirs(design + '/ngspice/run/.allwaves', exist_ok=True)
+        except IOError as e:
+            print('Error in os.makedirs(.../.allwaves): ' + str(e))
+        #EFABLESS PLATFORM
+        deskel = '/ef/efabless/deskel'
+        
+        # on the fly:
+        # .../elec/.java : reinstall if missing. From PDK-specific if any.
+        if not os.path.exists( os.path.join( elec, '.java')):
+            # Copy Electric preferences
+            try:
+                shutil.copytree(deskel + '/dotjava', design + '/elec/.java', symlinks = True)
+            except IOError as e:
+                print('Error copying files: ' + str(e))
+
+        # .../elec/LIBDIRS : reinstall if missing, from PDK-specific LIBDIRS
+        # in libs.tech/elec/LIBDIRS
+
+        libdirsloc = pdkdir + '/libs.tech/elec/LIBDIRS'
+
+        if not os.path.exists( os.path.join( elec, 'LIBDIRS')):
+            if os.path.exists( libdirsloc ):
+                # Copy Electric LIBDIRS
+                try:
+                    shutil.copy(libdirsloc, design + '/elec/LIBDIRS')
+                except IOError as e:
+                    print('Error copying files: ' + str(e))
+            else:
+                print('Info: PDK not configured for Electric: no libs.tech/elec/LIBDIRS')
+
+        return None
+
+    #------------------------------------------------------------------------
+    # utility: filter a list removing: empty strings, strings with any whitespace
+    #------------------------------------------------------------------------
+    whitespaceREX = re.compile('\s')
+    @classmethod
+    def filterNullOrWS(cls, inlist):
+        return [ i for i in inlist if i and not cls.whitespaceREX.search(i) ]
+
+    #------------------------------------------------------------------------
+    # utility: do a glob.glob of relative pattern, but specify the rootDir,
+    # so returns the matching paths found below that rootDir.
+    #------------------------------------------------------------------------
+    @classmethod
+    def globFromDir(cls, pattern, dir=None):
+        if dir:
+            dir = dir.rstrip('/') + '/'
+            pattern = dir + pattern
+        result = glob.glob(pattern)
+        if dir and result:
+            nbr = len(dir)
+            result = [ i[nbr:] for i in result ]
+        return result
+
+    #------------------------------------------------------------------------
+    # utility: from a pdkPath, return list of 3 strings: <foundry>, <node>, <description>.
+    # i.e. pdkPath has form '[.../]<foundry>[.<ext>]/<node>'. For now the description
+    # is always ''. And an optional foundry extension is pruned/dropped.
+    # thus '.../XFAB.2/EFXP018A4' -> 'XFAB', 'EFXP018A4', ''
+    #
+    # optionally store in each PDK: .ef-config/nodeinfo.json which can define keys:
+    # 'foundry', 'node', 'description' to override the foundry (computed from the path)
+    # and (fixed, empty) description currently returned by this.
+    #
+    # Intent: keep a short-description field at least, intended to be one-line max 40 chars,
+    # suitable for a on-hover-tooltip display. (Distinct from a big multiline description).
+    #
+    # On error (malformed pdkPath: can't determine foundry or node), the foundry or node
+    # or both may be '' or as specified in the optional default values (if you're
+    # generating something for display and want an unknown to appear as 'none' etc.).
+    #------------------------------------------------------------------------
+    @classmethod
+    def pdkdir2fnd(cls, pdkdir, def_foundry='', def_node='', def_description=''):
+        foundry = ''
+        node = ''
+        description = ''
+        status = 'active'
+        if pdkdir:
+            split = os.path.split(os.path.realpath(pdkdir))
+            # Full path should be [<something>/]<foundry>[.ext]/<node>
+            node = split[1]
+            foundry = os.path.split(split[0])[1]
+            foundry = os.path.splitext(foundry)[0]
+            # Check for nodeinfo.json
+            infofile = pdkdir + '/.config/nodeinfo.json'
+            if os.path.exists(infofile):
+                with open(infofile, 'r') as ifile:
+                    nodeinfo = json.load(ifile)
+                if 'foundry' in nodeinfo:
+                    foundry = nodeinfo['foundry']
+                if 'node' in nodeinfo:
+                    node = nodeinfo['node']
+                if 'description' in nodeinfo:
+                    description = nodeinfo['description']
+                if 'status' in nodeinfo:
+                    status = nodeinfo['status']
+                return foundry, node, description, status
+            
+            infofile = pdkdir + '/.ef-config/nodeinfo.json'
+            if os.path.exists(infofile):
+                with open(infofile, 'r') as ifile:
+                    nodeinfo = json.load(ifile)
+                if 'foundry' in nodeinfo:
+                    foundry = nodeinfo['foundry']
+                if 'node' in nodeinfo:
+                    node = nodeinfo['node']
+                if 'description' in nodeinfo:
+                    description = nodeinfo['description']
+                if 'status' in nodeinfo:
+                    status = nodeinfo['status']
+            
+
+        return foundry, node, description, status
+
+    #------------------------------------------------------------------------
+    # Get a list of the electric-libraries (DELIB only) in a given project.
+    # List of full-paths each ending in '.delib'
+    #------------------------------------------------------------------------
+
+    def get_elecLib_list(self, pname):
+        elibs = self.globFromDir(pname + '/elec/*.delib/', self.projectdir)
+        elibs = [ re.sub("/$", "", i) for i in elibs ]
+        return self.filterNullOrWS(elibs)
+
+    #------------------------------------------------------------------------
+    # Create a list of datestamps for each import file
+    #------------------------------------------------------------------------
+    def get_date_list(self, valuelist):
+        datelist = []
+        for value in valuelist:
+            try:
+                importfile = value[0]
+                try:
+                    statbuf = os.stat(importfile)
+                except:
+                    # Note entries that can't be accessed.
+                    datelist.append("(unknown)")
+                else:
+                    datestamp = datetime.datetime.fromtimestamp(statbuf.st_mtime)
+                    datestr = datestamp.strftime("%c")
+                    datelist.append(datestr)
+            except:
+                datelist.append("(N/A)")
+
+        return datelist
+
+    #------------------------------------------------------------------------
+    # Get the PDK attached to a project for display as: '<foundry> : <node>'
+    # unless path=True: then return true PDK dir-path.
+    #
+    # TODO: the ef-config prog output is not used below. Intent was use
+    # ef-config to be the one official query for *any* project's PDK value, and
+    # therein-only hide a built-in default for legacy projects without techdir symlink.
+    # In below ef-config will always give an EF_TECHDIR, so that code-branch always
+    # says '(default)', the ef-config subproc is wasted, and '(no PDK)' is never
+    # reached.
+    #------------------------------------------------------------------------
+    def get_pdk_dir(self, project, path=False):
+        pdkdir = os.path.realpath(project + self.config_path(project)+'/techdir')
+        if path:
+            return pdkdir
+        foundry, node, desc, status = self.pdkdir2fnd( pdkdir )
+        return foundry + ' : ' + node
+        '''
+        if os.path.isdir(project + '/.ef-config'):
+            if os.path.exists(project + '/.ef-config/techdir'):
+                pdkdir = os.path.realpath(project + '/.ef-config/techdir')
+                
+        elif os.path.isdir(project + '/.config'):
+            if os.path.exists(project + '/.config/techdir'):
+                pdkdir = os.path.realpath(project + '/.config/techdir')
+                if path:
+                    return pdkdir
+                foundry, node, desc, status = self.pdkdir2fnd( pdkdir )
+                return foundry + ' : ' + node
+        '''
+        '''
+        if not pdkdir:
+            # Run "ef-config" script for backward compatibility
+            export = {'EF_DESIGNDIR': project}
+            #EFABLESS PLATFORM
+            p = subprocess.run(['/ef/efabless/bin/ef-config', '-sh', '-t'],
+			stdout = subprocess.PIPE, env = export)
+            config_out = p.stdout.splitlines()
+            for line in config_out:
+                setline = line.decode('utf-8').split('=')
+                if setline[0] == 'EF_TECHDIR':
+                    pdkdir = ( setline[1] if path else '(default)' )
+            if not pdkdir:
+                pdkdir = ( None if path else '(no PDK)' )    # shouldn't get here
+        '''
+        
+        
+
+        return pdkdir
+
+    #------------------------------------------------------------------------
+    # Get the list of PDKs that are attached to each project
+    #------------------------------------------------------------------------
+    def get_pdk_list(self, projectlist):
+        pdklist = []
+        for project in projectlist:
+            pdkdir = self.get_pdk_dir(project)
+            pdklist.append(pdkdir)
+
+        return pdklist
+
+    #------------------------------------------------------------------------
+    # Find a .json's associated tar.gz (or .tgz) if any.
+    # Return path to the tar.gz if any, else None.
+    #------------------------------------------------------------------------
+
+    def json2targz(self, jsonPath):
+        root = os.path.splitext(jsonPath)[0]
+        for ext in ('.tgz', '.tar.gz'):
+            if os.path.isfile(root + ext):
+                return root + ext
+        return None
+
+    #------------------------------------------------------------------------
+    # Remove a .json and associated tar.gz (or .tgz) if any.
+    # If not a .json, remove just that file (no test for a tar).
+    #------------------------------------------------------------------------
+
+    def removeJsonPlus(self, jsonPath):
+        ext = os.path.splitext(jsonPath)[1]
+        if ext == ".json":
+            tar = self.json2targz(jsonPath)
+            if tar: os.remove(tar)
+        return os.remove(jsonPath)
+
+    #------------------------------------------------------------------------
+    # MOVE a .json and associated tar.gz (or .tgz) if any, to targetDir.
+    # If not a .json, move just that file (no test for a tar).
+    #------------------------------------------------------------------------
+
+    def moveJsonPlus(self, jsonPath, targetDir):
+        ext = os.path.splitext(jsonPath)[1]
+        if ext == ".json":
+            tar = self.json2targz(jsonPath)
+            if tar:
+                shutil.move(tar,      targetDir)
+        # believe the move throws an error. So return value (the targetDir name) isn't really useful.
+        return  shutil.move(jsonPath, targetDir)
+
+    #------------------------------------------------------------------------
+    # Get a list of the libraries in the user's ip folder
+    #------------------------------------------------------------------------
+
+    def get_library_list(self):
+        # Get contents of directory
+        try:
+            iplist = glob.glob(self.projectdir + '/ip/*/*')
+        except:
+            iplist = []
+        else:
+            pass
+      
+        return iplist
+
+    #------------------------------------------------------------------------
+    # Get a list of the files in the user's design import folder
+    # (use current 'import' but also original 'upload')
+    #------------------------------------------------------------------------
+
+    def get_import_list(self):
+        # Get contents of directory
+        importlist = os.listdir(self.projectdir + '/' + importdir)
+
+        # If entries have both a .json and .tar.gz file, remove the .tar.gz (also .tgz).
+        # Also ignore any .swp files dropped by the vim editor.
+        # Also ignore any subdirectories of import
+        for item in importlist[:]:
+            if item[-1] in '#~':
+                importlist.remove(item)
+                continue
+            ipath = self.projectdir + '/' + importdir + '/' + item
+
+            # recognize dirs (as u2u projects) if not symlink and has a 'project.json',
+            # hide dirs named *.bak. If originating user does u2u twice before target user
+            # can consume/import it, the previous one (only) is retained as *.bak.
+            if os.path.isdir(ipath):
+                if os.path.islink(ipath) or not self.validProjectName(item) \
+                   or self.importProjNameBadrex1.match(item) \
+                   or not os.path.isfile(ipath + '/project.json'):
+                    importlist.remove(item)
+                    continue
+            else:
+                ext = os.path.splitext(item)
+                if ext[1] == '.json':
+                    if ext[0] + '.tar.gz' in importlist:
+                        importlist.remove(ext[0] + '.tar.gz')
+                    elif ext[0] + '.tgz' in importlist:
+                        importlist.remove(ext[0] + '.tgz')
+                elif ext[1] == '.swp':
+                    importlist.remove(item)
+                elif os.path.isdir(self.projectdir + '/' + importdir + '/' + item):
+                    importlist.remove(item)
+
+        # Add pathname to all items in projectlist
+        importlist = [self.projectdir + '/' + importdir + '/' + item for item in importlist]
+
+        # Add support for original "upload" directory (backward compatibility)
+        if os.path.exists(self.projectdir + '/upload'):
+            uploadlist = os.listdir(self.projectdir + '/upload')
+
+            # If entries have both a .json and .tar.gz file, remove the .tar.gz (also .tgz).
+            # Also ignore any .swp files dropped by the vim editor.
+            for item in uploadlist[:]:
+                ext = os.path.splitext(item)
+                if ext[1] == '.json':
+                    if ext[0] + '.tar.gz' in uploadlist:
+                        uploadlist.remove(ext[0] + '.tar.gz')
+                    elif ext[0] + '.tgz' in uploadlist:
+                        uploadlist.remove(ext[0] + '.tgz')
+                elif ext[1] == '.swp':
+                    uploadlist.remove(item)
+
+            # Add pathname to all items in projectlist
+            uploadlist = [self.projectdir + '/upload/' + item for item in uploadlist]
+            importlist.extend(uploadlist)
+
+        # Remember the size of the list so we know when it changed
+        self.number_of_imports = len(importlist)
+        return importlist 
+
+    #------------------------------------------------------------------------
+    # Import for json documents and related tarballs (.gz or .tgz):  
+    #------------------------------------------------------------------------
+
+    def importjson(self, projname, importfile):
+        # (1) Check if there is a tarball with the same root name as the JSON
+        importroot = os.path.splitext(importfile)[0]
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[/ \t\n\\\><\*\?].*")
+        if os.path.isfile(importroot + '.tgz'):
+           tarname = importroot + '.tgz'
+        elif os.path.isfile(importroot + '.tar.gz'):
+           tarname = importroot + '.tar.gz'
+        else:
+           tarname = []
+        # (2) Check for name conflict
+        origname = projname
+        newproject = self.projectdir + '/' + projname
+        newname = projname
+        while os.path.isdir(newproject) or self.blacklisted(newname):
+            if self.blacklisted(newname):
+                warning = "Name " + newname + " is not allowed for a project name."
+            elif badrex1.match(newname):
+                warning = 'project name may not start with "."'
+            elif badrex2.match(newname):
+                warning = 'project name contains illegal characters or whitespace.'
+            else:
+                warning = "Project " + newname + " already exists!"
+            newname = ProjectNameDialog(self, warning, seed=newname).result
+            if not newname:
+                return 0	# Canceled, no action.
+            newproject = self.projectdir + '/' + newname
+        print("New project name is " + newname + ".")
+        # (3) Create new directory
+        os.makedirs(newproject)
+        # (4) Dump the tarball (if any) in the new directory
+        if tarname:
+            with tarfile.open(tarname, mode='r:gz') as archive:
+                for member in archive:
+                    archive.extract(member, newproject)
+        # (5) Copy the JSON document into the new directory.  Keep the
+        # original name of the project, so as to overwrite any existing
+        # document, then change the name to match that of the project
+        # folder.
+        # New behavior 12/2018:  JSON file is always called 'project.json'.
+        # Also support legacy JSON name if it exists (don't generate files with
+        # both names)
+
+        jsonfile = newproject + '/project.json'
+        if not os.path.isfile(jsonfile):
+            if os.path.isfile(newproject + '/' + projname + '.json'):
+                jsonfile = newproject + '/' + projname + '.json'
+
+        try:
+            shutil.copy(importfile, jsonfile)
+        except IOError as e:
+            print('Error copying files: ' + str(e))
+            return None
+        else:
+            # If filename is 'project.json' then it does not need to be changed.
+            # This is for legacy name support only.
+            if jsonfile != newproject + '/project.json':
+                shutil.move(jsonfile, newproject + '/' + newname + '.json')
+
+        # (6) Remove the original files from the import folder
+        os.remove(importfile)
+        if tarname:
+            os.remove(tarname)
+
+        # (7) Standard project setup:  if spi/, elec/, and ngspice/ do not
+        # exist, create them.  If elec/.java does not exist, create it and
+        # seed from deskel.  If ngspice/run and ngspice/run/.allwaves do not
+        # exist, create them.
+
+        if not os.path.exists(newproject + '/spi'):
+            os.makedirs(newproject + '/spi')
+        if not os.path.exists(newproject + '/spi/pex'):
+            os.makedirs(newproject + '/spi/pex')
+        if not os.path.exists(newproject + '/spi/lvs'):
+            os.makedirs(newproject + '/spi/lvs')
+        if not os.path.exists(newproject + '/ngspice'):
+            os.makedirs(newproject + '/ngspice')
+        if not os.path.exists(newproject + '/ngspice/run'):
+            os.makedirs(newproject + '/ngspice/run')
+        if not os.path.exists(newproject + '/ngspice/run/.allwaves'):
+            os.makedirs(newproject + '/ngspice/run/.allwaves')
+        if not os.path.exists(newproject + '/elec'):
+            os.makedirs(newproject + '/elec')
+        if not os.path.exists(newproject + '/xcirc'):
+            os.makedirs(newproject + '/xcirc')
+        if not os.path.exists(newproject + '/mag'):
+            os.makedirs(newproject + '/mag')
+
+        self.reinitElec(newproject)   # [re]install elec/.java, elec/LIBDIRS if needed, from pdk-specific if-any
+
+        return 1	# Success
+
+    #------------------------------------------------------------------------
+    # Import for netlists (.spi):
+    # (1) Request project name
+    # (2) Create new project if name does not exist, or
+    #     place netlist in existing project if it does.
+    #------------------------------------------------------------------------
+
+    #--------------------------------------------------------------------
+    # Install netlist in electric:
+    # "importfile" is the filename in ~/design/import
+    # "pname" is the name of the target project (folder)
+    # "newfile" is the netlist file name (which may or may not be the same
+    #     as 'importfile').
+    #--------------------------------------------------------------------
+
+    def install_in_electric(self, importfile, pname, newfile, isnew=True):
+        #--------------------------------------------------------------------
+        # Install the netlist.
+        # If netlist is CDL, then call cdl2spi first
+        #--------------------------------------------------------------------
+
+        newproject = self.projectdir + '/' + pname
+        if not os.path.isdir(newproject + '/spi/'):
+            os.makedirs(newproject + '/spi/')
+        if os.path.splitext(newfile)[1] == '.cdl':
+            if not os.path.isdir(newproject + '/cdl/'):
+                os.makedirs(newproject + '/cdl/')
+            shutil.copy(importfile, newproject + '/cdl/' + newfile)
+            try:
+                p = subprocess.run(['/ef/apps/bin/cdl2spi', importfile],
+			stdout = subprocess.PIPE, stderr = subprocess.PIPE,
+			check = True)
+            except subprocess.CalledProcessError as e:
+                print('Error running cdl2spi: ' + e.output.decode('utf-8'))
+                if isnew == True:
+                    shutil.rmtree(newproject)
+                return None
+            else:
+                spi_string = p.stdout.splitlines()[0].decode('utf-8')
+                if p.stderr:
+                    err_string = p.stderr.splitlines()[0].decode('utf-8')
+                    # Print error messages to console
+                    print(err_string)
+            if not spi_string:
+                print('Error: cdl2spi has no output')
+                if isnew == True:
+                    shutil.rmtree(newproject)
+                return None
+            outname = os.path.splitext(newproject + '/spi/' + newfile)[0] + '.spi'
+            with open(outname, 'w') as f:
+                f.write(spi_string)
+        else:
+            outname = newproject + '/spi/' + newfile
+            try:
+                shutil.copy(importfile, outname)
+            except IOError as e:
+                print('Error copying files: ' + str(e))
+                if isnew == True:
+                    shutil.rmtree(newproject)
+                return None
+
+        #--------------------------------------------------------------------
+        # Symbol generator---this code to be moved into its own def.
+        #--------------------------------------------------------------------
+        # To-do, need a more thorough SPICE parser, maybe use netgen to parse.
+        # Need to find topmost subcircuit, by parsing the hieararchy.
+        subcktrex = re.compile('\.subckt[ \t]+([^ \t]+)[ \t]+', re.IGNORECASE)
+        subnames = []
+        with open(importfile, 'r') as f:
+            for line in f:
+                lmatch = subcktrex.match(line)
+                if lmatch:
+                    subnames.append(lmatch.group(1))
+
+        if subnames:
+            subname = subnames[0]
+
+        # Run cdl2icon perl script
+        try:
+            p = subprocess.run(['/ef/apps/bin/cdl2icon', '-file', importfile, '-cellname',
+			subname, '-libname', pname, '-projname', pname, '--prntgussddirs'],
+			stdout = subprocess.PIPE, stderr = subprocess.PIPE, check = True)
+        except subprocess.CalledProcessError as e:
+            print('Error running cdl2spi: ' + e.output.decode('utf-8'))
+            return None
+        else:
+            pin_string = p.stdout.splitlines()[0].decode('utf-8')
+            if not pin_string:
+                print('Error: cdl2icon has no output')
+                if isnew == True:
+                    shutil.rmtree(newproject)
+                return None
+            if p.stderr:
+                err_string = p.stderr.splitlines()[0].decode('utf-8')
+                print(err_string)
+
+        # Invoke dialog to arrange pins here
+        pin_info_list = SymbolBuilder(self, pin_string.split(), fontsize=self.prefs['fontsize']).result
+        if not pin_info_list:
+            # Dialog was canceled
+            print("Symbol builder was canceled.")
+            if isnew == True:
+                shutil.rmtree(newproject)
+            return 0
+
+        for pin in pin_info_list:
+            pin_info = pin.split(':')
+            pin_name = pin_info[0]
+            pin_type = pin_info[1]
+
+        # Call cdl2icon with the final pin directions
+        outname = newproject + '/elec/' + pname + '.delib/' + os.path.splitext(newfile)[0] + '.ic'
+        try:
+            p = subprocess.run(['/ef/apps/bin/cdl2icon', '-file', importfile, '-cellname',
+			subname, '-libname', pname, '-projname', pname, '-output',
+			outname, '-pindircmbndstring', ','.join(pin_info_list)],
+			stdout = subprocess.PIPE, stderr = subprocess.PIPE, check = True)
+        except subprocess.CalledProcessError as e:
+            print('Error running cdl2icon: ' + e.output.decode('utf-8'))
+            if isnew == True:
+                shutil.rmtree(newproject)
+            return None
+        else:
+            icon_string = p.stdout.splitlines()[0].decode('utf-8')   # not used, AFAIK
+            if p.stderr:
+                err_string = p.stderr.splitlines()[0].decode('utf-8')
+                print(err_string)
+
+        return 1	# Success
+
+    #------------------------------------------------------------------------
+    # Import netlist file into existing project
+    #------------------------------------------------------------------------
+
+    def importspiceinto(self, newfile, importfile):
+        # Require existing project location
+        ppath = ExistingProjectDialog(self, self.get_project_list()).result
+        if not ppath:
+            return 0		# Canceled in dialog, no action.
+        pname = os.path.split(ppath)[1]
+        print("Importing into existing project " + pname)
+        result = self.install_in_electric(importfile, pname, newfile, isnew=False)
+        if result == None:
+            print('Error during import.')
+            return None
+        elif result == 0:
+            return 0    # Canceled
+        else:
+            # Remove original file from imports area
+            os.remove(importfile)
+            return 1    # Success
+
+    #------------------------------------------------------------------------
+    # Import netlist file as a new project
+    #------------------------------------------------------------------------
+
+    def importspice(self, newfile, importfile):
+        # Use create project code first to generate a valid project space.
+        newname = self.createproject(None)
+        if not newname:
+            return 0		# Canceled in dialog, no action.
+        print("Importing as new project " + newname + ".")
+        result = self.install_in_electric(importfile, newname, newfile, isnew=True)
+        if result == None:
+            print('Error during install')
+            return None
+        elif result == 0: 
+            # Canceled, so do not remove the import
+            return 0
+        else: 
+            # Remove original file from imports area
+            os.remove(importfile)
+            return 1    # Success
+
+    #------------------------------------------------------------------------
+    # Determine if JSON's tar can be imported as-if it were just a *.v.
+    # This is thin wrapper around tarVglImportable. Find the JSON's associated
+    # tar.gz if any, and call tarVglImportable.
+    # Returns list of two:
+    #   None if rules not satisified; else path of the single GL .v member.
+    #   None if rules not satisified; else root-name of the single .json member.
+    #------------------------------------------------------------------------
+
+    def jsonTarVglImportable(self, path):
+        ext = os.path.splitext(path)[1]
+        if ext != '.json': return None, None, None
+
+        tar = self.json2targz(path)
+        if not tar: return None, None, None
+
+        return self.tarVglImportable(tar)
+
+    #------------------------------------------------------------------------
+    # Get a single named member (memPath) out of a JSON's tar file.
+    # This is thin wrapper around tarMember2tempfile. Find the JSON's associated
+    # tar.gz if any, and call tarMember2tempfile.
+    #------------------------------------------------------------------------
+
+    def jsonTarMember2tempfile(self, path, memPath):
+        ext = os.path.splitext(path)[1]
+        if ext != '.json': return None
+
+        tar = self.json2targz(path)
+        if not tar: return None
+
+        return self.tarMember2tempfile(tar, memPath)
+
+    #------------------------------------------------------------------------
+    # Determine if tar-file can be imported as-if it were just a *.v.
+    # Require exactly one yosys-output .netlist.v, and exactly one .json.
+    # Nothing else matters: Ignore all other *.v, *.tv, *.jelib, *.vcd...
+    #
+    # If user renames *.netlist.v in cloudv before export to not end in
+    # netlist.v, we won't recognize it.
+    #
+    # Returns list of two:
+    #   None if rules not satisified; else path of the single GL netlist.v member.
+    #   None if rules not satisified; else root-name of the single .json member.
+    #------------------------------------------------------------------------
+
+    def tarVglImportable(self, path):
+        # count tar members by extensions. Track the .netlist.v. and .json. Screw the rest.
+        nbrExt = {'.v':0, '.netlist.v':0, '.tv':0, '.jelib':0, '.json':0, '/other/':0, '/vgl/':0}
+        nbrGLv = 0
+        jname = None
+        vfile = None
+        node = None
+        t = tarfile.open(path)
+        for i in t:
+            # ignore (without counting) dir entries. From cloudv (so far) the tar does not
+            # have dir-entries, but most tar do (esp. most manually made test cases).
+            if i.isdir():
+                continue
+            # TODO: should we require all below counted files to be plain files (no symlinks etc.)?
+            # get extension, but recognize a multi-ext for .netlist.v case
+            basenm = os.path.basename(i.name)
+            ext = os.path.splitext(basenm)[1]
+            root = os.path.splitext(basenm)[0]
+            ext2 = os.path.splitext(root)[1]
+            if ext2 == '.netlist' and ext == '.v':
+                ext = ext2 + ext
+            if ext and ext not in nbrExt:
+                ext = '/other/'
+            elif ext == '.netlist.v' and self.tarMemberIsGLverilog(t, i.name):
+                vfile = i.name
+                ext = '/vgl/'
+            elif ext == '.json':
+                node = self.tarMemberHasFoundryNode(t, i.name)
+                jname = root
+            nbrExt[ext] += 1
+
+        # check rules. Require exactly one yosys-output .netlist.v, and exactly one .json.
+        # Quantities of other types are all don't cares.
+        if (nbrExt['/vgl/'] == 1 and nbrExt['.json'] == 1):
+            # vfile is the name of the verilog netlist in the tarball, while jname
+            # is the root name of the JSON file found in the tarball (if any) 
+            return vfile, jname, node
+
+        # failed, not gate-level-verilog importable:
+        return None, None, node
+
+
+    #------------------------------------------------------------------------
+    # OBSOLETE VERSION: Determine if tar-file can be imported as-if it were just a *.v.
+    # Rules for members: one *.v, {0,1} *.jelib, {0,1} *.json, 0 other types.
+    # Return None if rules not satisified; else return path of the single .v.
+    #------------------------------------------------------------------------
+    #
+    # def tarVglImportable(self, path):
+    #     # count tar members by extensions. Track the .v.
+    #     nbrExt = {'.v':0, '.jelib':0, '.json':0, 'other':0}
+    #     vfile = ""
+    #     t = tarfile.open(path)
+    #     for i in t:
+    #         ext = os.path.splitext(i.name)[1]
+    #         if ext not in nbrExt:
+    #             ext = 'other'
+    #         nbrExt[ext] += 1
+    #         if ext == ".v": vfile = i.name
+    #
+    #     # check rules.
+    #     if (nbrExt['.v'] != 1 or nbrExt['other'] != 0 or
+    #         nbrExt['.jelib'] > 1 or nbrExt['.json'] > 1):
+    #         return None
+    #     return vfile
+
+    #------------------------------------------------------------------------
+    # Get a single named member (memPath) out of a tar file (tarPath), into a
+    # temp-file, so subprocesses can process it.
+    # Return path to the temp-file, or None if member not found in the tar.
+    #------------------------------------------------------------------------
+
+    def tarMember2tempfile(self, tarPath, memPath):
+        t = tarfile.open(tarPath)
+        member = t.getmember(memPath)
+        if not member: return None
+
+        # Change member.name so it extracts into our new temp-file.
+        # extract() can specify the root-dir befow which the member path
+        # resides. If temp is an absolute-path, that root-dir must be /.
+        tmpf1 = tempfile.NamedTemporaryFile(delete=False)
+        if tmpf1.name[0] != "/":
+            raise ValueError("assertion failed, temp-file path not absolute: %s" % tmpf1.name)
+        member.name = tmpf1.name
+        t.extract(member,"/")
+
+        return tmpf1.name
+
+    #------------------------------------------------------------------------
+    # Create an electric .delib directory and seed it with a header file
+    #------------------------------------------------------------------------
+
+    def create_electric_header_file(self, project, libname):
+        if not os.path.isdir(project + '/elec/' + libname + '.delib'):
+            os.makedirs(project + '/elec/' + libname + '.delib')
+
+        p = subprocess.run(['electric', '-v'], stdout=subprocess.PIPE)
+        eversion = p.stdout.splitlines()[0].decode('utf-8')
+        # Create header file
+        with open(project + '/elec/' + libname + '.delib/header', 'w') as f:
+            f.write('# header information:\n')
+            f.write('H' + libname + '|' + eversion + '\n\n')
+            f.write('# Tools:\n')
+            f.write('Ouser|DefaultTechnology()Sschematic\n')
+            f.write('Osimulation|VerilogUseAssign()BT\n')
+            f.write('C____SEARCH_FOR_CELL_FILES____\n')
+
+    #------------------------------------------------------------------------
+    # Create an ad-hoc "project.json" dictionary and fill essential records
+    #------------------------------------------------------------------------
+
+    def create_ad_hoc_json(self, ipname, pname):
+        # Create ad-hoc JSON file and fill it with the minimum
+        # necessary entries to define a project.
+        jData = {}
+        jDS = {}
+        jDS['ip-name'] = ipname
+        pdkdir = self.get_pdk_dir(pname, path=True)
+        try:
+            jDS['foundry'], jDS['node'], pdk_desc, pdk_stat = self.pdkdir2fnd( pdkdir )
+        except:
+            # Cannot parse PDK name, so foundry and node will remain undefined
+            pass
+        jDS['format'] = '3'
+        pparams = []
+        param = {}
+        param['unit'] = "\u00b5m\u00b2"
+        param['condition'] = "device_area"
+        param['display'] = "Device area"
+        pmax = {}
+        pmax['penalty'] = '0'
+        pmax['target'] = '100000'
+        param['max'] = pmax
+        pparams.append(param)
+    
+        param = {}
+        param['unit'] = "\u00b5m\u00b2"
+        param['condition'] = "area"
+        param['display'] = "Layout area"
+        pmax = {}
+        pmax['penalty'] = '0'
+        pmax['target'] = '100000'
+        param['max'] = pmax
+        pparams.append(param)
+
+        param = {}
+        param['unit'] = "\u00b5m"
+        param['condition'] = "width"
+        param['display'] = "Layout width"
+        pmax = {}
+        pmax['penalty'] = '0'
+        pmax['target'] = '300'
+        param['max'] = pmax
+        pparams.append(param)
+
+        param = {}
+        param['condition'] = "DRC_errors"
+        param['display'] = "DRC errors"
+        pmax = {}
+        pmax['penalty'] = 'fail'
+        pmax['target'] = '0'
+        param['max'] = pmax
+        pparams.append(param)
+
+        param = {}
+        param['condition'] = "LVS_errors"
+        param['display'] = "LVS errors"
+        pmax = {}
+        pmax['penalty'] = 'fail'
+        pmax['target'] = '0'
+        param['max'] = pmax
+        pparams.append(param)
+
+        jDS['physical-params'] = pparams
+        jData['data-sheet'] = jDS
+
+        return jData
+
+    #------------------------------------------------------------------------
+    # For a single named member (memPath) out of an open tarfile (tarf),
+    # determine if it is a JSON file, and attempt to extract value of entry
+    # 'node' in dictionary entry 'data-sheet'.  Otherwise return None.
+    #------------------------------------------------------------------------
+
+    def tarMemberHasFoundryNode(self, tarf, memPath):
+        fileJSON = tarf.extractfile(memPath)
+        if not fileJSON: return None
+
+        try:
+            # NOTE: tarfile data is in bytes, json.load(fileJSON) does not work.
+            datatop = json.loads(fileJSON.read().decode('utf-8'))
+        except:
+            print("Failed to load extract file " + memPath + " as JSON data")
+            return None
+        else:
+            node = None
+            if 'data-sheet' in datatop:
+                dsheet = datatop['data-sheet']
+                if 'node' in dsheet:
+                    node = dsheet['node']
+
+        fileJSON.close()     # close open-tarfile before any return
+        return node
+
+    #------------------------------------------------------------------------
+    # For a single named member (memPath) out of an open tarfile (tarf),
+    # determine if first line embeds (case-insensitive match): Generated by Yosys
+    # Return True or False. If no such member or it has no 1st line, returns False.
+    #------------------------------------------------------------------------
+
+    def tarMemberIsGLverilog(self, tarf, memPath):
+        fileHdl = tarf.extractfile(memPath)
+        if not fileHdl: return False
+
+        line = fileHdl.readline()
+        fileHdl.close()     # close open-tarfile before any return
+        if not line: return False
+        return ('generated by yosys' in line.decode('utf-8').lower())
+
+    #------------------------------------------------------------------------
+    # Import vgl-netlist file INTO existing project.
+    # The importfile can be a .v; or a .json-with-tar that embeds a .v.
+    # What is newfile? not used here.
+    #
+    # PROMPT to select an existing project is here.
+    # (Is also a PROMPT to select existing electric lib, but that's within importvgl).
+    #------------------------------------------------------------------------
+
+    def importvglinto(self, newfile, importfile):
+        # Require existing project location
+        ppath = ExistingProjectDialog(self, self.get_project_list()).result
+        if not ppath:   return 0		# Canceled in dialog, no action.
+        pname = os.path.split(ppath)[1]
+        print( "Importing into existing project: %s" % (pname))
+
+        return self.importvgl(newfile, importfile, pname)
+
+    #------------------------------------------------------------------------
+    # Import cloudv project as new project.
+    #------------------------------------------------------------------------
+
+    def install_from_cloudv(self, opath, ppath, pdkname, stdcellname, ydicts):
+        oname = os.path.split(opath)[1]
+        pname = os.path.split(ppath)[1]
+
+        print('Cloudv project name is ' + str(oname))
+        print('New Open Galaxy project name is ' + str(pname))
+
+        os.makedirs(ppath + '/verilog', exist_ok=True)
+
+        vfile = None
+        isfullchip = False
+        ipname = oname
+
+        # First check for single synthesized projects, or all synthesized
+	# digital sub-blocks within a full-chip project.
+
+        os.makedirs(ppath + '/verilog/source', exist_ok=True)
+        bfiles = glob.glob(opath + '/build/*.netlist.v')
+        for bfile in bfiles:
+            tname = os.path.split(bfile)[1]
+            vname = os.path.splitext(os.path.splitext(tname)[0])[0]
+            tfile = ppath + '/verilog/' + vname + '/' + vname + '.vgl'
+            print('Making qflow sub-project ' + vname)
+            os.makedirs(ppath + '/verilog/' + vname, exist_ok=True)
+            shutil.copy(bfile, tfile)
+            if vname == oname:
+                vfile = tfile
+
+            # Each build project gets its own qflow directory.  Create the
+            # source/ subdirectory and make a link back to the .vgl file.
+            # qflow prep should do the rest.
+
+            os.makedirs(ppath + '/qflow', exist_ok=True)
+            os.makedirs(ppath + '/qflow/' + vname)
+            os.makedirs(ppath + '/qflow/' + vname + '/source')
+
+            # Make sure the symbolic link is relative, so that it is portable
+            # through a shared project.
+            curdir = os.getcwd()
+            os.chdir(ppath + '/qflow/' + vname + '/source')
+            os.symlink('../../../verilog/' + vname + '/' + vname + '.vgl', vname + '.v')
+            os.chdir(curdir)
+
+            # Create a simple qflow_vars.sh file so that the project manager
+            # qflow launcher will see it as a qflow sub-project.  If the meta.yaml
+            # file has a "stdcell" entry for the subproject, then add the line
+            # "techname=" with the name of the standard cell library as pulled
+            # from meta.yaml.
+
+            stdcell = None
+            buildname = 'build/' + vname + '.netlist.v'
+            for ydict in ydicts:
+                if buildname in ydict:
+                    yentry = ydict[buildname]
+                    if 'stdcell' in yentry:
+                        stdcell = yentry['stdcell']
+
+            with open(ppath + '/qflow/' + vname + '/qflow_vars.sh', 'w') as ofile:
+                print('#!/bin/tcsh -f', file=ofile)
+                if stdcell:
+                    print('set techname=' + stdcell, file=ofile)
+
+        # Now check for a full-chip verilog SoC (from CloudV)
+
+        modrex = re.compile('[ \t]*module[ \t]+[^ \t(]*_?soc[ \t]*\(')
+        genmodrex = re.compile('[ \t]*module[ \t]+([^ \t(]+)[ \t]*\(')
+
+        bfiles = glob.glob(opath + '/*.model/*.v')
+        for bfile in bfiles:
+            tname = os.path.split(bfile)[1]
+            vpath = os.path.split(bfile)[0]
+            ipname = os.path.splitext(tname)[0]
+            tfile = ppath + '/verilog/' + ipname + '.v'
+            isfullchip = True
+            break
+
+        if isfullchip:
+            print('Cloudv project IP name is ' + str(ipname))
+
+            # All files in */ paths should be copied to project verilog/source/,
+            # except for the module containing the SoC itself.  Note that the actual
+            # verilog source goes here, not the synthesized netlist, although that is
+            # mainly for efficiency of the simulation, which would normally be done in
+            # cloudV and not in Open Galaxy.  For Open Galaxy, what is needed is the
+            # existence of a verilog file containing a module name, which is used to
+            # track down the various files (LEF, DEF, etc.) that are needed for full-
+            # chip layout.
+            #
+            # (Sept. 2019) Added copying of files in /SW/ -> /sw/ and /Verify/ ->
+	    # /verify/ for running full-chip simulations on the Open Galaxy side.
+
+            os.makedirs(ppath + '/verilog', exist_ok=True)
+
+            cfiles = glob.glob(vpath + '/source/*')
+            for cfile in cfiles:
+                cname = os.path.split(cfile)[1]
+                if cname != tname:
+                    tpath = ppath + '/verilog/source/' + cname
+                    os.makedirs(ppath + '/verilog/source', exist_ok=True)
+                    shutil.copy(cfile, tpath)
+
+            cfiles = glob.glob(vpath + '/verify/*')
+            for cfile in cfiles:
+                cname = os.path.split(cfile)[1]
+                tpath = ppath + '/verilog/verify/' + cname
+                os.makedirs(ppath + '/verilog/verify', exist_ok=True)
+                shutil.copy(cfile, tpath)
+
+            cfiles = glob.glob(vpath + '/sw/*')
+            for cfile in cfiles:
+                cname = os.path.split(cfile)[1]
+                tpath = ppath + '/verilog/sw/' + cname
+                os.makedirs(ppath + '/verilog/sw', exist_ok=True)
+                shutil.copy(cfile, tpath)
+
+            # Read the top-level SoC verilog and recast it for OpenGalaxy.
+            with open(bfile, 'r') as ifile:
+                chiplines = ifile.read().splitlines()
+
+            # Find the modules used, track them down, and add the source location
+            # in the Open Galaxy environment as an "include" line in the top level
+            # verilog.
+
+            parentdir = os.path.split(bfile)[0]
+            modfile = parentdir + '/docs/modules.txt'
+
+            modules = []
+            if os.path.isfile(modfile):
+                with open(modfile, 'r') as ifile:
+                    modules = ifile.read().splitlines()
+            else:
+                print("Warning:  No modules.txt file for the chip top level module in "
+				+ parentdir + "/docs/.\n")
+
+            # Get the names of verilog libraries in this PDK.
+            pdkdir = os.path.realpath(ppath + '/.ef-config/techdir')
+            pdkvlog = pdkdir + '/libs.ref/verilog'
+            pdkvlogfiles = glob.glob(pdkvlog + '/*/*.v')
+
+            # Read the verilog libraries and create a dictionary mapping each
+            # module name to a location of the verilog file where it is located.
+            moddict = {}
+            for vlogfile in pdkvlogfiles:
+                with open(vlogfile, 'r') as ifile:
+                    for line in ifile.read().splitlines():
+                        mmatch = genmodrex.match(line)
+                        if mmatch:
+                            modname = mmatch.group(1)
+                            moddict[modname] = vlogfile
+
+            # Get the names of verilog libraries in the user IP space.
+            # (TO DO:  Need to know the IP version being used!)
+            designdir = os.path.split(ppath)[0]
+            ipdir = designdir + '/ip/'
+            uservlogfiles = glob.glob(ipdir + '/*/*/verilog/*.v')
+            for vlogfile in uservlogfiles:
+                # Strip ipdir from the front
+                vlogpath = vlogfile.replace(ipdir, '', 1)
+                with open(vlogfile, 'r') as ifile:
+                    for line in ifile.read().splitlines():
+                        mmatch = genmodrex.match(line)
+                        if mmatch:
+                            modname = mmatch.group(1)
+                            moddict[modname] = vlogpath
+
+            # Find all netlist builds from the project (those that were copied above)
+            buildfiles = glob.glob(ppath + '/verilog/source/*.v')
+            for vlogfile in buildfiles:
+                # Strip ipdir from the front
+                vlogpath = vlogfile.replace(ppath + '/verilog/source/', '', 1)
+                with open(vlogfile, 'r') as ifile:
+                    for line in ifile.read().splitlines():
+                        mmatch = genmodrex.match(line)
+                        if mmatch:
+                            modname = mmatch.group(1)
+                            moddict[modname] = vlogpath
+
+            # (NOTE:  removing 'ifndef LVS' as netgen should be able to handle
+            #  the contents of included files, and they are preferred since any
+            #  arrays are declared in each module I/O)
+            # chiplines.insert(0, '`endif')
+            chiplines.insert(0, '//--- End of list of included module dependencies ---')
+            includedfiles = []
+            for module in modules:
+                # Determine where this module comes from.  Look in the PDK, then in
+                # the user ip/ directory, then in the local hierarchy.  Note that
+                # the local hierarchy expects layouts from synthesized netlists that
+                # have not yet been created, so determine the expected location.
+
+                if module in moddict:
+                    if moddict[module] not in includedfiles:
+                        chiplines.insert(0, '`include "' + moddict[module] + '"')
+                        includedfiles.append(moddict[module])
+
+            # chiplines.insert(0, '`ifndef LVS')
+            chiplines.insert(0, '//--- List of included module dependencies ---')
+            chiplines.insert(0, '// iverilog simulation requires the use of -I source -I ~/design/ip')
+            chiplines.insert(0, '// NOTE:  Includes may be rooted at ~/design/ip/ or at ./source')
+            chiplines.insert(0, '// SoC top level verilog copied and modified by project manager')
+
+            # Copy file, but replace the module name "soc" with the ip-name
+            with open(tfile, 'w') as ofile:
+                for chipline in chiplines:
+                    print(modrex.sub('module ' + ipname + ' (', chipline), file=ofile)
+
+        # Need to define behavior:  What if there is more than one netlist?
+        # Which one is to be imported?  For now, ad-hoc behavior is to select
+        # the last netlist file in the list if no file matches the ip-name.
+
+        # Note that for full-chip projects, the full chip verilog file is always
+        # the last one set.
+
+        if not vfile:
+            try:
+                vfile = tfile
+            except:
+                pass
+
+        # NOTE:  vfile was being used to create a symbol, but not any more;
+        # see below.  All the above code referencing vfile can probably be
+        # removed.
+
+        try:
+            sfiles = glob.glob(vpath + '/source/*')
+            sfiles.extend(glob.glob(vpath + '/*/source/*'))
+        except:
+            sfiles = glob.glob(opath + '/*.v')
+            sfiles.extend(glob.glob(opath + '/*.sv'))
+            sfiles.extend(glob.glob(opath + '/local/*'))
+
+        for fname in sfiles:
+            sname = os.path.split(fname)[1]
+            tfile = ppath + '/verilog/source/' + sname
+            # Reject '.model' and '.soc" files (these are meaningful only to CloudV)
+            fileext = os.path.splitext(fname)[1]
+            if fileext == '.model' or fileext == '.soc':
+                continue
+            if os.path.isfile(fname):
+                # Check if /verilog/source/ has been created
+                if not os.path.isdir(ppath + '/verilog/source'):
+                    os.makedirs(ppath + '/verilog/source')
+                shutil.copy(fname, tfile)
+
+        # Add standard cell library name to project.json
+        pjsonfile = ppath + '/project.json'
+        if os.path.exists(pjsonfile):
+            with open(pjsonfile, 'r') as ifile:
+                datatop = json.load(ifile)
+        else:
+            datatop = self.create_ad_hoc_json(ipname, ppath)
+
+        # Generate a symbol in electric for the verilog top module
+        iconfile = ppath + '/elec/' + ipname + '.delib/' + ipname + '.ic'
+        if not os.path.exists(iconfile):
+            # NOTE:  Symbols are created by qflow migration for project
+            # builds.  Only the chip top-level needs to run create_symbol
+            # here.
+
+            if isfullchip:
+                print("Creating symbol for module " + ipname + " automatically from verilog source.")
+                create_symbol(ppath, vfile, ipname, iconfile, False)
+            # Add header file
+            self.create_electric_header_file(ppath, ipname)
+          
+        dsheet = datatop['data-sheet']
+        if not stdcellname or stdcellname == "":
+            dsheet['standard-cell'] = 'default'
+        else:
+            dsheet['standard-cell'] = stdcellname
+
+        with open(pjsonfile, 'w') as ofile:
+            json.dump(datatop, ofile, indent = 4)
+
+        return 0
+
+    #------------------------------------------------------------------------
+    # Import vgl-netlist AS new project.
+    # The importfile can be a .v; or a .json-with-tar that embeds a .v.
+    # What is newfile? not used here.
+    #
+    # PROMPT to select an create new project is within importvgl.
+    #------------------------------------------------------------------------
+
+    def importvglas(self, newfile, importfile, seedname):
+        print('importvglas:  seedname is ' + str(seedname))
+        return self.importvgl(newfile, importfile, newname=None, seedname=seedname)
+
+    #------------------------------------------------------------------------
+    # Utility shared/used by both: Import vgl-netlist file AS or INTO a project.
+    # Called directly for AS. Called via importvglinto for INTO.
+    #   importfile : source of .v to import, actual .v or json-with-tar that embeds a .v
+    #   newfile : not used
+    #   newname : target project-name (INTO), or None (AS: i.e. prompt to create one).
+    # Either newname is given: we PROMPT to pick an existing elecLib;
+    # Else PROMPT for new projectName and CREATE it (and use elecLib of same name).
+    #------------------------------------------------------------------------
+
+    def importvgl(self, newfile, importfile, newname=None, seedname=None):
+        elecLib = None
+        isnew = not newname
+
+        # Up front:  Determine if this import has a .json file associated
+        # with it.  If so, then parse the JSON data to find if there is a
+        # foundry and node set for the project.  If so, then the foundry
+        # node is not selectable at time of import.  Likewise, if "isnew"
+        # is false, then we need to check if there is a directory called
+        # "newname" and if it is set to the same foundry node.  If not,
+        # then the import must be rejected.
+
+        tarVfile, jName, importnode = self.jsonTarVglImportable(importfile)
+
+        if isnew:
+            print('importvgl:  seedname is ' + str(seedname))
+            # Use create project code first to generate a valid project space.
+            newname = self.createproject(None, seedname, importnode)
+            if not newname: return 0		# Canceled in dialog, no action.
+            print("Importing as new project " + newname + ".")
+            elecLib = newname
+
+        ppath = self.projectdir + '/' + newname
+        if not elecLib:
+            choices = self.get_elecLib_list(newname)
+            if not choices:
+                print( "Aborted: No existing electric libraries found to import into.")
+                return 0
+                
+            elecLib = ExistingElecLibDialog(self, choices).result
+            if not elecLib:
+                # Never a just-created project to delete here: We only PROMPT to pick elecLib in non-new case.
+                return 0		# Canceled in dialog, no action.
+            
+            # Isolate just electric lib name without extension. ../a/b.delib -> b
+            elecLib = os.path.splitext(os.path.split(elecLib)[-1])[0]
+            print("Importing to project: %s, elecLib: %s" % (newname, elecLib))
+
+        # Determine isolated *.v as importactual. May be importfile or tar-member (as temp-file).
+        importactual = importfile
+        if tarVfile:
+            importactual = self.jsonTarMember2tempfile(importfile, tarVfile)
+            print("importing json-with-tar's member: %s" % (tarVfile))
+
+        if not os.path.isfile(importactual):
+            # TODO: should this be a raise instead?
+            print('Error determining *.v to import')
+            return None
+
+        result = self.vgl_install(importactual, newname, elecLib, newfile, isnew=isnew)
+        if result == None:
+            print('Error during install')
+            return None
+        elif result == 0: 
+            # Canceled, so do not remove the import
+            return 0
+        else: 
+            # If jName is non-NULL then there is a JSON file in the tarball.  This is
+            # to be used as the project JSON file.  Contents of file coming from
+            # CloudV are correct as of 12/8/2017.
+            pname = os.path.expanduser('~/design/' + newname)
+            legacyjname = pname + '/' + newname + '.json'
+            # New behavior 12/2018:  Project JSON file always named 'project.json'
+            jname = pname + '/project.json'
+
+            # Do not overwrite an existing JSON file.  Overwriting is a problem for
+            # "import into", as the files go into an existing project, which would
+            # normally have its own JSON file.
+
+            if not os.path.exists(jname) and not os.path.exists(legacyjname):
+                try:
+                    tarJfile = os.path.split(tarVfile)[0] + '/' + jName + '.json'
+                    importjson = self.jsonTarMember2tempfile(importfile, tarJfile)
+                except:
+                    jData = self.create_ad_hoc_json(newname, pname)
+    
+                    with open(jname, 'w') as ofile:
+                        json.dump(jData, ofile, indent = 4)
+
+                else:
+                    # Copy the temporary file pulled from the tarball and
+                    # remove the temporary file.
+                    shutil.copy(importjson, jname)
+                    os.remove(importjson)
+
+            # For time-being, if a tar.gz & json: archive them in the target project, also as extracted.
+            # Remove original file from imports area (either .v; or .json plus tar)
+            # plus temp-file if extracted from the tar.
+            if importactual != importfile:
+                os.remove(importactual)
+                pname = self.projectdir + '/' + newname
+                importd = pname + '/' + archiveimportdir      # global: archiveimportdir
+                os.makedirs(importd, exist_ok=True)
+                # Dirnames to embed a VISIBLE date (UTC) of when populated.
+                # TODO: improve dir naming or better way to store & understand later when it was processed (a log?),
+                # without relying on file-system mtime.
+                archived = tempfile.mkdtemp( dir=importd, prefix='{:%Y-%m-%d.%H:%M:%S}-'.format(datetime.datetime.utcnow()))
+                tarname = self.json2targz(importfile)
+                if tarname:
+                    with tarfile.open(tarname, mode='r:gz') as archive:
+                        for member in archive:
+                            archive.extract(member, archived)
+                self.moveJsonPlus(importfile, archived)
+            else:
+                self.removeJsonPlus(importfile)
+            return 1    # Success
+
+    #------------------------------------------------------------------------
+    # Prepare multiline "warning" indicating which files to install already exist.
+    # TODO: ugly, don't use a simple confirmation dialogue: present a proper table.
+    #------------------------------------------------------------------------
+    def installsConfirmMarkOverwrite(self, module, files):
+        warning = [ "For import of module: %s," % module ]
+        anyExists = False
+        for i in files:
+            exists = os.path.isfile(os.path.expanduser(i))
+            if exists: anyExists = True
+            warning += [ (" * " if exists else "   ") + i ]
+        if anyExists:
+            titleSuffix = "\nCONFIRM installation of (*: OVERWRITE existing):"
+        else:
+            titleSuffix = "\nCONFIRM installation of:"
+        warning[0] += titleSuffix
+        return ConfirmInstallDialog(self,   "\n".join(warning)).result
+
+    def vgl_install(self, importfile, pname, elecLib, newfile, isnew=True):
+        #--------------------------------------------------------------------
+        # Convert the in .v to: spi, cdl, elec-icon, elec-text-view forms.
+        # TODO: Prompt to confirm final install of 5 files in dir-structure.
+        #
+        # newfile: argument is not used. What is it for?
+        # Target project AND electricLib MAY BE same (pname) or different.
+        # Rest of the filenames are determined by the module name in the source .v.
+        #--------------------------------------------------------------------
+
+        newproject = self.projectdir + '/' + pname
+        try:
+            p = subprocess.run(['/ef/apps/bin/vglImport', importfile, pname, elecLib],
+                               stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+                               check=True, universal_newlines=True)
+        except subprocess.CalledProcessError as e:
+            if hasattr(e, 'stdout') and e.stdout: print(e.stdout)
+            if hasattr(e, 'stderr') and e.stderr: print(e.stderr)
+            print('Error running vglImport: ' + str(e))
+            if isnew == True: shutil.rmtree(newproject)
+            return None
+        else:
+            dataLines = p.stdout.splitlines()
+            if p.stderr:
+                # Print error messages to console
+                for i in p.stderr.splitlines(): print(i)
+            if not dataLines or len(dataLines) != 11:
+                print('Error: vglImport has no output, or wrong #outputs (%d vs 11)' % len(dataLines))
+                if isnew == True: shutil.rmtree(newproject)
+                return None
+            else:
+                module = dataLines[0]
+                confirm = self.installsConfirmMarkOverwrite(module, dataLines[2::2])
+                if not confirm:
+                    print("Cancelled")
+                    if isnew == True: shutil.rmtree(newproject)
+                    return 0
+                # print("Proceed")
+                clean = dataLines[1:]
+                nbr = len(dataLines)
+                ndx = 1
+                # trap I/O errors and clean-up if any
+                try:
+                    while ndx+1 < nbr:
+                        trg = os.path.expanduser(dataLines[ndx+1])
+                        os.makedirs(os.path.dirname(trg), exist_ok=True)
+                        shutil.move(dataLines[ndx], trg)
+                        ndx += 2
+                except IOError as e:
+                    print('Error copying files: ' + str(e))
+                    for i in clean:
+                        with contextlib.suppress(FileNotFoundError): os.remove(i)
+                    if isnew == True: shutil.rmtree(newproject)
+                    return 0
+                print( "For import of module %s installed: %s" % (module, " ".join(dataLines[2::2])))
+                return 1    # Success
+
+
+    #------------------------------------------------------------------------
+    # Callback function from "Import Into" button on imports list box.
+    #------------------------------------------------------------------------
+
+    def importintodesign(self, value):
+        if not value['values']:
+            print('No import selected.')
+            return
+
+        # Stop the watchdog timer while this is going on
+        self.watchclock.stop()
+        newname = value['text']
+        importfile = value['values'][0]
+        print('Import project name: ' + newname + '')
+        print('Import file name: ' + importfile + '')
+
+        # Behavior depends on what kind of file is being imported.
+        # Tarballs are entire projects.  Other files are individual
+        # files and may be imported into new or existing projects
+
+        if os.path.isdir(importfile):
+            print('File is a project, must import as new project.')
+            result = self.import2project(importfile, addWarn='Redirected: A projectDir must Import-As new project.')
+        else:
+            ext = os.path.splitext(importfile)[1]
+            vFile, jName, importnode = self.jsonTarVglImportable(importfile)
+            if ((ext == '.json' and vFile) or ext == '.v'):
+                result = self.importvglinto(newname, importfile)
+            elif ext == '.json':
+                # Same behavior as "Import As", at least for now
+                print('File is a project, must import as new project.')
+                result = self.importjson(newname, importfile)
+            else:
+                result = self.importspiceinto(newname, importfile)
+
+        if result:
+            self.update_project_views(force=True)
+        self.watchclock.restart()
+
+    #------------------------------------------------------------------------
+    # Callback function from "Import As" button on imports list box.
+    #------------------------------------------------------------------------
+
+    def importdesign(self, value):
+        if not value['values']:
+            print('No import selected.')
+            return
+
+        # Stop the watchdog timer while this is going on
+        self.watchclock.stop()
+        newname = value['text']
+        importfile = value['values'][0]
+        print('Import project name: ' + newname)
+        print('Import file name: ' + importfile)
+
+        # Behavior depends on what kind of file is being imported.
+        # Tarballs are entire projects.  Other files are individual
+        # files and may be imported into new or existing projects
+
+        if os.path.isdir(importfile):
+            result = self.import2project(importfile)
+        else:
+            pathext = os.path.splitext(importfile)
+            vfile, seedname, importnode = self.jsonTarVglImportable(importfile)
+            if ((pathext[1] == '.json' and seedname) or pathext[1] == '.v'):
+                result = self.importvglas(newname, importfile, seedname)
+            elif pathext[1] == '.json':
+                result = self.importjson(newname, importfile)
+            else:
+                result = self.importspice(newname, importfile)
+
+        if result:
+            self.update_project_views(force=True)
+        self.watchclock.restart()
+
+    def deleteimport(self, value):
+        if not value['values']:
+            print('No import selected.')
+            return
+
+        print("Delete import " + value['text'] + '  ' + value['values'][0] + " !")
+        # Require confirmation
+        warning = 'Confirm delete import ' + value['text'] + '?'
+        confirm = ProtectedConfirmDialog(self, warning).result
+        if not confirm == 'okay':
+            return
+        print('Delete confirmed!')
+        item = value['values'][0]
+
+        if not os.path.islink(item) and os.path.isdir(item):
+            shutil.rmtree(item)
+            return
+
+        os.remove(item)
+        ext = os.path.splitext(item)
+        # Where import is a pair of .json and .tar.gz files, remove both.
+        if ext[1] == '.json':
+            if os.path.exists(ext[0] + '.tar.gz'):
+                os.remove(ext[0] + '.tar.gz')
+            elif os.path.exists(ext[0] + '.tgz'):
+                os.remove(ext[0] + '.tgz')
+
+    def update_project_views(self, force=False):
+        # More than updating project views, this updates projects, imports, and
+        # IP libraries.
+
+        projectlist = self.get_project_list()
+        self.projectselect.repopulate(projectlist)
+        pdklist = self.get_pdk_list(projectlist)
+        self.projectselect.populate2("PDK", projectlist, pdklist)
+
+        old_imports = self.number_of_imports
+        importlist = self.get_import_list()
+        self.importselect.repopulate(importlist)
+        valuelist = self.importselect.getvaluelist()
+        datelist = self.get_date_list(valuelist)
+        itemlist = self.importselect.getlist()
+        self.importselect.populate2("date", itemlist, datelist)
+
+        # To do:  Check if itemlist in imports changed, and open if a new import
+        # has arrived.
+     
+        if force or (old_imports != None) and (old_imports < self.number_of_imports):
+            self.import_open()
+
+        iplist = self.get_library_list()
+        self.ipselect.repopulate(iplist, versioning=True)
+        valuelist = self.ipselect.getvaluelist()
+        datelist = self.get_date_list(valuelist)
+        itemlist = self.ipselect.getlist()
+        self.ipselect.populate2("date", itemlist, datelist)
+        
+    def update_alert(self):
+        # Project manager has been updated.  Generate an alert window and
+        # provide option to restart the project manager.
+
+        warning = 'Project manager app has been updated.  Restart now?'
+        confirm = ConfirmDialog(self, warning).result
+        if not confirm == 'okay':
+            print('Warning: Must quit and restart to get any fixes or updates.')
+            return
+        os.execl('/ef/efabless/opengalaxy/og_gui_manager.py', 'appsel_zenity.sh')
+        # Does not return; replaces existing process.
+
+    #----------------------------------------------------------------------
+    # Delete a project from the design folder.
+    #----------------------------------------------------------------------
+
+    def deleteproject(self, value):
+        if not value['values']:
+            print('No project selected.')
+            return
+        print('Delete project ' + value['values'][0])
+        # Require confirmation
+        warning = 'Confirm delete entire project ' + value['text'] + '?'
+        confirm = ProtectedConfirmDialog(self, warning).result
+        if not confirm == 'okay':
+            return
+        shutil.rmtree(value['values'][0])
+
+    #----------------------------------------------------------------------
+    # Clean out the simulation folder.  Traditionally this was named
+    # 'ngspice', so this is checked for backward-compatibility.  The
+    # proper name of the simulation directory is 'simulation'.
+    #----------------------------------------------------------------------
+
+    def cleanproject(self, value):
+        if not value['values']:
+            print('No project selected.')
+            return
+        ppath = value['values'][0]
+        print('Clean simulation raw data from directory ' + ppath)
+        # Require confirmation
+        warning = 'Confirm clean project ' + value['text'] + ' contents?'
+        confirm = ConfirmDialog(self, warning).result
+        if not confirm == 'okay':
+            return
+        if os.path.isdir(ppath + '/simulation'):
+            simpath = 'simulation'
+        elif os.path.isdir(ppath + '/ngspice'):
+            simpath = 'ngspice'
+        else:
+            print('Project has no simulation folder.')
+            return
+
+        filelist = os.listdir(ppath + '/' + simpath)
+        for sfile in filelist:
+            if os.path.splitext(sfile)[1] == '.raw':
+                os.remove(ppath + '/ngspice/' + sfile)                
+        print('Project simulation folder cleaned.')
+
+        # Also clean the log file
+        filelist = os.listdir(ppath)
+        for sfile in filelist:
+            if os.path.splitext(sfile)[1] == '.log':
+                os.remove(ppath + '/' + sfile)                
+
+    #---------------------------------------------------------------------------------------
+    # Determine which schematic editors are compatible with the PDK, and return a list of them.
+    #---------------------------------------------------------------------------------------
+
+    def list_valid_schematic_editors(self, pdktechdir):
+        # Check PDK technology directory for xcircuit, xschem, and electric
+        applist = []
+        if os.path.exists(pdktechdir + '/elec'):
+            applist.append('electric')
+        if os.path.exists(pdktechdir + '/xschem'):
+            applist.append('xschem')
+        if os.path.exists(pdktechdir + '/xcircuit'):
+            applist.append('xcircuit')
+
+        return applist
+
+    #------------------------------------------------------------------------------------------
+    # Determine which layout editors are compatible with the PDK, and return a list of them.
+    #------------------------------------------------------------------------------------------
+
+    def list_valid_layout_editors(self, pdktechdir):
+        # Check PDK technology directory for magic and klayout
+        applist = []
+        if os.path.exists(pdktechdir + '/magic'):
+            applist.append('magic')
+        if os.path.exists(pdktechdir + '/klayout'):
+            applist.append('klayout')
+        return applist
+
+    #----------------------------------------------------------------------
+    # Create a new project folder and initialize it (see below for steps)
+    #----------------------------------------------------------------------
+
+    def createproject(self, value, seedname=None, importnode=None):
+        # Note:  value is current selection, if any, and is ignored
+        # Require new project location and confirmation
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[/ \t\n\\\><\*\?].*")
+        warning = 'Create new project:'
+        print(warning)
+        development = self.prefs['development']
+        while True:
+            try:
+                if seedname:
+                    newname, newpdk = NewProjectDialog(self, warning, seed=seedname, importnode=importnode, development=development).result
+                else:
+                    newname, newpdk = NewProjectDialog(self, warning, seed='', importnode=importnode, development=development).result
+            except TypeError:
+                # TypeError occurs when "Cancel" is pressed, just handle exception.
+                return None
+            if not newname:
+                return None	# Canceled, no action.
+
+            newproject = self.projectdir + '/' + newname
+            if self.blacklisted(newname):
+                warning = newname + ' is not allowed for a project name.'
+            elif badrex1.match(newname):
+                warning = 'project name may not start with "."'
+            elif badrex2.match(newname):
+                warning = 'project name contains illegal characters or whitespace.'
+            elif os.path.exists(newproject):
+                warning = newname + ' is already a project name.'
+            else:
+                break
+        
+        try:
+            
+            subprocess.Popen([og_config.apps_path + '/create_project.py', newproject, newpdk])
+            
+        except IOError as e:
+            print('Error copying files: ' + str(e))
+            return None
+            
+        except:
+            print('Error making project.')
+            return None
+            
+        return newname
+        '''
+        # Find what tools are compatible with the given PDK
+        schemapps = self.list_valid_schematic_editors(newpdk + '/libs.tech')
+        layoutapps = self.list_valid_layout_editors(newpdk + '/libs.tech')
+
+        print('New project name will be ' + newname + '.')
+        print('Associated project PDK is ' + newpdk + '.')
+        try:
+            os.makedirs(newproject)
+
+            # Make standard folders
+            if 'magic' in layoutapps:
+                os.makedirs(newproject + '/mag')
+
+            os.makedirs(newproject + '/spi')
+            os.makedirs(newproject + '/spi/pex')
+            os.makedirs(newproject + '/spi/lvs')
+            if 'electric' in layoutapps or 'electric' in schemapps:
+                os.makedirs(newproject + '/elec')
+            if 'xcircuit' in schemapps:
+                os.makedirs(newproject + '/xcirc')
+            if 'klayout' in schemapps:
+                os.makedirs(newproject + '/klayout')
+            os.makedirs(newproject + '/ngspice')
+            os.makedirs(newproject + '/ngspice/run')
+            if 'electric' in schemapps:
+                os.makedirs(newproject + '/ngspice/run/.allwaves')
+            os.makedirs(newproject + '/testbench')
+            os.makedirs(newproject + '/verilog')
+            os.makedirs(newproject + '/verilog/source')
+            os.makedirs(newproject + '/.ef-config')
+            if 'xschem' in schemapps:
+                os.makedirs(newproject + '/xschem')
+
+            pdkname = os.path.split(newpdk)[1]
+
+            # Symbolic links
+            os.symlink(newpdk, newproject + '/.ef-config/techdir')
+
+            # Copy preferences
+            # deskel = '/ef/efabless/deskel'
+            #
+            # Copy examples (disabled;  this is too confusing to the end user.  Also, they
+            # should not be in user space at all, as they are not user editable.
+            #
+            # for item in os.listdir(deskel + '/exlibs'):
+            #     shutil.copytree(deskel + '/exlibs/' + item, newproject + '/elec/' + item)
+            # for item in os.listdir(deskel + '/exmag'):
+            #     if os.path.splitext(item)[1] == '.mag':
+            #         shutil.copy(deskel + '/exmag/' + item, newproject + '/mag/' + item)
+
+            # Put tool-specific startup files into the appropriate user directories.
+            if 'electric' in layoutapps or 'electric' in schemapps:
+                self.reinitElec(newproject)   # [re]install elec/.java, elec/LIBDIRS if needed, from pdk-specific if-any
+                # Set up electric
+                self.create_electric_header_file(newproject, newname)
+
+            if 'magic' in layoutapps:
+                shutil.copy(newpdk + '/libs.tech/magic/current/' + pdkname + '.magicrc', newproject + '/mag/.magicrc')
+
+            if 'xcircuit' in schemapps:
+                xcircrc = newpdk + '/libs.tech/xcircuit/' + pdkname + '.' + 'xcircuitrc'
+                xcircrc2 = newpdk + '/libs.tech/xcircuit/xcircuitrc'
+                if os.path.exists(xcircrc):
+                    shutil.copy(xcircrc, newproject + '/xcirc/.xcircuitrc')
+                elif os.path.exists(xcircrc2):
+                    shutil.copy(xcircrc2, newproject + '/xcirc/.xcircuitrc')
+
+            if 'xschem' in schemapps:
+                xschemrc = newpdk + '/libs.tech/xschem/xschemrc'
+                if os.path.exists(xschemrc):
+                    shutil.copy(xschemrc, newproject + '/xschem/xschemrc')
+
+        except IOError as e:
+            print('Error copying files: ' + str(e))
+            return None
+
+        return newname
+        '''
+    #----------------------------------------------------------------------
+    # Import a CloudV project from ~/cloudv/<project_name>
+    #----------------------------------------------------------------------
+
+    def cloudvimport(self, value):
+
+        # Require existing project location
+        clist = self.get_cloudv_project_list()
+        if not clist:
+            return 0		# No projects to import
+        ppath = ExistingProjectDialog(self, clist, warning="Enter name of cloudV project to import:").result
+        if not ppath:
+            return 0		# Canceled in dialog, no action.
+        pname = os.path.split(ppath)[1]
+        print("Importing CloudV project " + pname)
+
+        importnode = None
+        stdcell = None
+        netlistfile = None
+
+        # Pull process and standard cell library from the YAML file created by
+        # CloudV.  NOTE:  YAML file has multiple documents, so must use
+        # yaml.load_all(), not yaml.load().  If there are refinements of this
+        # process for individual build files, they will override (see further down).
+
+        # To do:  Check entries for SoC builds.  If there are multiple SoC builds,
+        # then create an additional drop-down selection to choose one, since only
+        # one SoC build can exist as a single Open Galaxy project.  Get the name
+        # of the top-level module for the SoC.  (NOTE:  It may not be intended
+        # that there can be multiple SoC builds in the project, so for now retaining
+        # the existing parsing assuming default names.)
+
+        if os.path.exists(ppath + '/.ef-config/meta.yaml'):
+            print("Reading YAML file:")
+            ydicts = []
+            with open(ppath + '/.ef-config/meta.yaml', 'r') as ifile:
+                yalldata = yaml.load_all(ifile, Loader=yaml.Loader)
+                for ydict in yalldata:
+                    ydicts.append(ydict)
+
+            for ydict in ydicts:
+                for yentry in ydict.values():
+                    if 'process' in yentry:
+                        importnode = yentry['process']
+
+        # If there is a file ().soc and a directory ().model, then pull the file
+        # ().model/().model.v, which is a chip top-level netlist.
+
+        ydicts = []
+        has_soc = False
+        save_vdir = None
+        vdirs = glob.glob(ppath + '/*')
+        for vdir in vdirs:
+            vnameparts = vdir.split('.')
+            if len(vnameparts) > 1 and vnameparts[-1] == 'soc' and os.path.isdir(vdir):
+                has_soc = True
+            if len(vnameparts) > 1 and vnameparts[-1] == 'model':
+                save_vdir = vdir
+
+        if has_soc:
+            if save_vdir:
+                vdir = save_vdir
+                print("INFO:  CloudV project " + vdir + " is a full chip SoC project.")
+
+                vroot = os.path.split(vdir)[1]
+                netlistfile = vdir + '/' + vroot + '.v'
+                if os.path.exists(netlistfile):
+                    print("INFO:  CloudV chip top level verilog is " + netlistfile + ".")
+            else:
+                print("ERROR:  Expected SoC .model directory not found.")
+
+        # Otherwise, if the project has a build/ directory and a netlist.v file,
+        # then set the foundry node accordingly.
+
+        elif os.path.exists(ppath + '/build'):
+            vfiles = glob.glob(ppath + '/build/*.v')
+            for vfile in vfiles:
+                vroot = os.path.splitext(vfile)[0]
+                if os.path.splitext(vroot)[1] == '.netlist':
+                    netlistfile = ppath + '/build/' + vfile
+
+                    # Pull process and standard cell library from the YAML file
+                    # created by CloudV
+                    # Use yaml.load_all(), not yaml.load() (see above)
+
+                    if os.path.exists(ppath + '/.ef-config/meta.yaml'):
+                        print("Reading YAML file:")
+                        ydicts = []
+                        with open(ppath + '/.ef-config/meta.yaml', 'r') as ifile:
+                            yalldata = yaml.load_all(ifile, Loader=yaml.Loader)
+                            for ydict in yalldata:
+                                ydicts.append(ydict)
+
+                        for ydict in ydicts:
+                            for yentry in ydict.values():
+                                if 'process' in yentry:
+                                    importnode = yentry['process']
+                                if 'stdcell' in yentry:
+                                    stdcell = yentry['stdcell']
+                    break
+
+        if importnode:
+            print("INFO:  Project targets foundry process " + importnode + ".")
+        else:
+            print("WARNING:  Project does not target any foundry process.")
+
+        newname = self.createproject(value, seedname=pname, importnode=importnode)
+        if not newname: return 0		# Canceled in dialog, no action.
+        newpath = self.projectdir + '/' + newname
+
+        result = self.install_from_cloudv(ppath, newpath, importnode, stdcell, ydicts)
+        if result == None:
+            print('Error during import.')
+            return None
+        elif result == 0:
+            return 0    # Canceled
+        else:
+            return 1    # Success
+
+    #----------------------------------------------------------------------
+    # Make a copy of a project in the design folder.
+    #----------------------------------------------------------------------
+
+    def copyproject(self, value):
+        if not value['values']:
+            print('No project selected.')
+            return
+        # Require copy-to location and confirmation
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[/ \t\n\\\><\*\?].*")
+        warning = 'Copy project ' + value['text'] + ' to new project.'
+        print('Copy project directory ' + value['values'][0])
+        newname = ''
+        copylist = []
+        elprefs = False
+        spprefs = False
+        while True:
+            copylist = CopyProjectDialog(self, warning, seed=newname).result
+            if not copylist:
+                return		# Canceled, no action.
+            else:
+                newname = copylist[0]
+                elprefs = copylist[1]
+                spprefs = copylist[2]
+            newproject = self.projectdir + '/' + newname
+            if self.blacklisted(newname):
+                warning = newname + ' is not allowed for a project name.'
+            elif badrex1.match(newname):
+                warning = 'project name may not start with "."'
+            elif badrex2.match(newname):
+                warning = 'project name contains illegal characters or whitespace.'
+            elif os.path.exists(newproject):
+                warning = newname + ' is already a project name.'
+            else:
+                break
+
+        oldpath = value['values'][0]
+        oldname = os.path.split(oldpath)[1]
+        patterns = [oldname + '.log']
+        if not elprefs:
+            patterns.append('.java')
+        if not spprefs:
+            patterns.append('ngspice')
+            patterns.append('pv')
+
+        print("New project name will be " + newname)
+        try:
+            shutil.copytree(oldpath, newproject, symlinks = True,
+			ignore = shutil.ignore_patterns(*patterns))
+        except IOError as e:
+            print('Error copying files: ' + str(e))
+            return
+
+        # NOTE:  Behavior is for project files to depend on "ip-name".  Using
+        # the project filename as a project name is a fallback behavior.  If
+        # there is a project.json file, and it defines an ip-name entry, then
+        # there is no need to make changes within the project.  If there is
+        # no project.json file, then create one and set the ip-name entry to
+        # the old project name, which avoids the need to make changes within
+        # the project.
+
+        else:
+            # Check project.json
+            jsonname = newproject + '/project.json'
+            legacyname = newproject + '/' + oldname + '.json'
+            if not os.path.isfile(jsonname):
+                if os.path.isfile(legacyname):
+                    jsonname = legacyname
+
+            found = False
+            if os.path.isfile(jsonname):
+                # Pull the ipname into local store (may want to do this with the
+                # datasheet as well)
+                with open(jsonname, 'r') as f:
+                    datatop = json.load(f)
+                    dsheet = datatop['data-sheet']
+                    if 'ip-name' in dsheet:
+                        found = True
+
+            if not found:
+                jData = self.create_ad_hoc_json(oldname, newproject)
+                with open(newproject + '/project.json', 'w') as ofile:
+                    json.dump(jData, ofile, indent = 4)
+
+        # If ngspice and electric prefs were not copied from the source
+        # to the target, as recommended, then copy these from the
+        # skeleton repository as is done when creating a new project.
+
+        if not spprefs:
+            try:
+                os.makedirs(newproject + '/ngspice')
+                os.makedirs(newproject + '/ngspice/run')
+                os.makedirs(newproject + '/ngspice/run/.allwaves')
+            except FileExistsError:
+                pass
+        if not elprefs:
+            # Copy preferences
+            deskel = '/ef/efabless/deskel'
+            try:
+                shutil.copytree(deskel + '/dotjava', newproject + '/elec/.java', symlinks = True)
+            except IOError as e:
+                print('Error copying files: ' + e)
+
+    #----------------------------------------------------------------------
+    # Change a project IP to a different name.
+    #----------------------------------------------------------------------
+
+    def renameproject(self, value):
+        if not value['values']:
+            print('No project selected.')
+            return
+
+        # Require new project name and confirmation
+        badrex1 = re.compile("^\.")
+        badrex2 = re.compile(".*[/ \t\n\\\><\*\?].*")
+        projname = value['text']
+
+        # Find the IP name for project projname.  If it has a JSON file, then
+        # read it and pull the ip-name record.  If not, the fallback position
+        # is to assume that the project filename is the project name.
+
+        # Check project.json
+        projectpath = self.projectdir + '/' + projname
+        jsonname = projectpath + '/project.json'
+        legacyname = projectpath + '/' + projname + '.json'
+        if not os.path.isfile(jsonname):
+            if os.path.isfile(legacyname):
+                jsonname = legacyname
+
+        oldname = projname
+        if os.path.isfile(jsonname):
+            # Pull the ipname into local store (may want to do this with the
+            # datasheet as well)
+            with open(jsonname, 'r') as f:
+                datatop = json.load(f)
+                dsheet = datatop['data-sheet']
+                if 'ip-name' in dsheet:
+                    oldname = dsheet['ip-name']
+
+        warning = 'Rename IP "' + oldname + '" for project ' + projname + ':'
+        print(warning)
+        newname = projname
+        while True:
+            try:
+                newname = ProjectNameDialog(self, warning, seed=oldname + '_1').result
+            except TypeError:
+                # TypeError occurs when "Cancel" is pressed, just handle exception.
+                return None
+            if not newname:
+                return None	# Canceled, no action.
+
+            if self.blacklisted(newname):
+                warning = newname + ' is not allowed for an IP name.'
+            elif badrex1.match(newname):
+                warning = 'IP name may not start with "."'
+            elif badrex2.match(newname):
+                warning = 'IP name contains illegal characters or whitespace.'
+            else:
+                break
+
+        # Update everything, including schematic, symbol, layout, JSON file, etc.
+        print('New project IP name will be ' + newname + '.')
+        rename_project_all(projectpath, newname)
+
+    # class vars: one-time compile of regulare expressions for life of the process
+    projNameBadrex1 = re.compile("^[-.]")
+    projNameBadrex2 = re.compile(".*[][{}()!/ \t\n\\\><#$\*\?\"'|`~]")
+    importProjNameBadrex1 = re.compile(".*[.]bak$")
+
+    # centralize legal projectName check.
+    # TODO: Several code sections are not yet converted to use this.
+    # TODO: Extend to explain to the user the reason why.
+    def validProjectName(self, name):
+        return not (self.blacklisted(name) or
+                    self.projNameBadrex1.match(name) or
+                    self.projNameBadrex2.match(name))
+
+    #----------------------------------------------------------------------
+    # "Import As" a dir in import/ as a project. based on renameproject().
+    # addWarn is used to augment confirm-dialogue if redirected here via erroneous ImportInto
+    #----------------------------------------------------------------------
+
+    def import2project(self, importfile, addWarn=None):
+        name = os.path.split(importfile)[1]
+        projpath = self.projectdir + '/' + name
+
+        bakname = name + '.bak'
+        bakpath = self.projectdir + '/' + bakname
+        warns = []
+        if addWarn:
+            warns += [ addWarn ]
+
+        # Require new project name and confirmation
+        confirmPrompt = None    # use default: I am sure I want to do this.
+        if os.path.isdir(projpath):
+            if warns:
+                warns += [ '' ]  # blank line between addWarn and below two Warnings:
+            if os.path.isdir(bakpath):
+                warns += [ 'Warning: Replacing EXISTING: ' + name + ' AND ' + bakname + '!' ]
+            else:
+                warns += [ 'Warning: Replacing EXISTING: ' + name + '!' ]
+            warns += [ 'Warning: Check for & exit any Electric,magic,qflow... for above project(s)!\n' ]
+            confirmPrompt = 'I checked & exited apps and am sure I want to do this.'
+
+        warns += [ 'Confirm import-as new project: ' + name + '?' ]
+        warning = '\n'.join(warns)
+        confirm = ProtectedConfirmDialog(self, warning, confirmPrompt=confirmPrompt).result
+        if not confirm == 'okay':
+            return
+
+        print('New project name will be ' + name + '.')
+        try:
+            if os.path.isdir(projpath):
+                if os.path.isdir(bakpath):
+                    print('Deleting old project: ' + bakpath);
+                    shutil.rmtree(bakpath)
+                print('Moving old project ' + name + ' to ' + bakname)
+                os.rename(                projpath,           bakpath)
+            print("Importing as new project " + name)
+            os.rename(importfile, projpath)
+            return True
+        except IOError as e:
+            print("Error importing-as project: " + str(e))
+            return None
+
+    #----------------------------------------------------------------------
+    # Helper subroutine:
+    # Check if a project is a valid project.  Return the name of the
+    # datasheet if the project has a valid one in the project top level
+    # path.
+    #----------------------------------------------------------------------
+
+    def get_datasheet_name(self, dpath):
+        if not os.path.isdir(dpath):
+            print('Error:  Project is not a folder!')
+            return
+        # Check for valid datasheet name in the following order:
+        # (1) project.json (Legacy)
+        # (2) <name of directory>.json (Legacy)
+        # (3) not "datasheet.json" or "datasheet_anno.json" 
+        # (4) "datasheet.json"
+        # (5) "datasheet_anno.json"
+
+        dsname = os.path.split(dpath)[1]
+        if os.path.isfile(dpath + '/project.json'):
+            datasheet = dpath + '/project.json'
+        elif os.path.isfile(dpath + '/' + dsname + '.json'):
+            datasheet = dpath + '/' + dsname + '.json'
+        else:
+            has_generic = False
+            has_generic_anno = False
+            filelist = os.listdir(dpath)
+            for file in filelist[:]:
+                if os.path.splitext(file)[1] != '.json':
+                    filelist.remove(file)
+            if 'datasheet.json' in filelist:
+                has_generic = True
+                filelist.remove('datasheet.json')
+            if 'datasheet_anno.json' in filelist:
+                has_generic_anno = True
+                filelist.remove('datasheet_anno.json')
+            if len(filelist) == 1:
+                print('Trying ' + dpath + '/' + filelist[0])
+                datasheet = dpath + '/' + filelist[0]
+            elif has_generic:
+                datasheet + dpath + '/datasheet.json'
+            elif has_generic_anno:
+                datasheet + dpath + '/datasheet_anno.json'
+            else:
+                if len(filelist) > 1:
+                    print('Error:  Path ' + dpath + ' has ' + str(len(filelist)) +
+                            ' valid datasheets.')
+                else:
+                    print('Error:  Path ' + dpath + ' has no valid datasheets.')
+                return None
+
+        if not os.path.isfile(datasheet):
+            print('Error:  File ' + datasheet + ' not found.')
+            return None
+        else:
+            return datasheet
+
+    #----------------------------------------------------------------------
+    # Run the LVS manager
+    #----------------------------------------------------------------------
+
+    def run_lvs(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            designname = self.project_name
+            print('Run LVS on design ' + designname + ' (' + design + ')')
+            # use Popen, not run, so that application does not wait for it to exit.
+            subprocess.Popen([og_config.apps_path + '/lvs_manager.py', design, designname])
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the local characterization checker
+    #----------------------------------------------------------------------
+
+    def characterize(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            designname = self.project_name
+            datasheet = self.get_datasheet_name(design)
+            print('Characterize design ' + designname + ' (' + datasheet + ' )')
+            if datasheet:
+                # use Popen, not run, so that application does not wait for it to exit.
+                dsheetroot = os.path.splitext(datasheet)[0]
+                subprocess.Popen([og_config.apps_path + '/og_gui_characterize.py',
+				datasheet])
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the local synthesis tool (qflow)
+    #----------------------------------------------------------------------
+
+    def synthesize(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            designname = self.project_name
+            development = self.prefs['devstdcells']
+            if not designname:
+                # A project without a datasheet has no designname (which comes from
+                # the 'ip-name' record in the datasheet JSON) but can still be
+                # synthesized.
+                designname = design
+
+            # Normally there is one digital design in a project.  However, full-chip
+            # designs (in particular) may have multiple sub-projects that are
+            # independently synthesized digital blocks.  Find all subdirectories of
+            # the top level or subdirectories of qflow that contain a 'qflow_vars.sh'
+            # file.  If there is more than one, then present a list.  If there is
+            # only one but it is not in 'qflow/', then be sure to pass the actual
+            # directory name to the qflow manager.
+
+            qvlist = glob.glob(design + '/*/qflow_vars.sh')
+            qvlist.extend(glob.glob(design + '/qflow/*/qflow_vars.sh'))
+            if len(qvlist) > 1 or (len(qvlist) == 1 and not os.path.exists(design + '/qflow/qflow_vars.sh')):
+                # Generate selection menu
+                if len(qvlist) > 1:
+                    clist = list(os.path.split(item)[0] for item in qvlist)
+                    ppath = ExistingProjectDialog(self, clist, warning="Enter name of qflow project to open:").result
+                    if not ppath:
+                        return 0		# Canceled in dialog, no action.
+                else:
+                    ppath = os.path.split(qvlist[0])[0]
+
+                # pname is everything in ppath after matching design:
+                pname = ppath.replace(design + '/', '')
+
+                print('Synthesize design in qflow project directory ' + pname)
+                if development:
+                    subprocess.Popen([og_config.apps_path + '/qflow_manager.py',
+				design, '-development', '-subproject=' + pname])
+                else:
+                    subprocess.Popen([og_config.apps_path + '/qflow_manager.py',
+				design, '-subproject=' + pname])
+            else:
+                print('Synthesize design ' + designname + ' (' + design + ')')
+                # use Popen, not run, so that application does not wait for it to exit.
+                if development:
+                    subprocess.Popen([og_config.apps_path + '/qflow_manager.py',
+				design, designname, '-development'])
+                else:
+                    subprocess.Popen([og_config.apps_path + '/qflow_manager.py',
+				design, designname])
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Switch between showing and hiding the import list (default hidden)
+    #----------------------------------------------------------------------
+
+    def import_toggle(self):
+        import_state = self.toppane.import_frame.import_header3.cget('text')
+        if import_state == '+':
+            self.importselect.grid(row = 11, sticky = 'news')
+            self.toppane.import_frame.import_header3.config(text='-')
+        else:
+            self.importselect.grid_forget()
+            self.toppane.import_frame.import_header3.config(text='+')
+
+    def import_open(self):
+        self.importselect.grid(row = 11, sticky = 'news')
+        self.toppane.import_frame.import_header3.config(text='-')
+
+    #----------------------------------------------------------------------
+    # Switch between showing and hiding the IP library list (default hidden)
+    #----------------------------------------------------------------------
+
+    def library_toggle(self):
+        library_state = self.toppane.library_frame.library_header3.cget('text')
+        if library_state == '+':
+            self.ipselect.grid(row = 8, sticky = 'news')
+            self.toppane.library_frame.library_header3.config(text='-')
+        else:
+            self.ipselect.grid_forget()
+            self.toppane.library_frame.library_header3.config(text='+')
+
+    def library_open(self):
+        self.ipselect.grid(row = 8, sticky = 'news')
+        self.toppane.library_frame.library_header3.config(text='-')
+
+    #----------------------------------------------------------------------
+    # Run padframe-calc (today internally invokes libreoffice, we only need cwd set to design project)
+    #----------------------------------------------------------------------
+    def padframe_calc(self):
+        value = self.projectselect.selected()
+        if value:
+            designname = self.project_name
+            self.padframe_calc_work(newname=designname)
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #------------------------------------------------------------------------
+    # Run padframe-calc (today internally invokes libreoffice, we set cwd to design project)
+    # Modelled somewhat after 'def importvgl':
+    #   Prompt for an existing electric lib.
+    #   Prompt for a target cellname (for both mag and electric icon).
+    # (The AS vs INTO behavior is incomplete as yet. Used so far with current-project as newname arg).
+    #   newname : target project-name (INTO), or None (AS: i.e. prompt to create one).
+    # Either newname is given: we PROMPT to pick an existing elecLib;
+    # Else PROMPT for new projectName and CREATE it (and use elecLib of same name).
+    #------------------------------------------------------------------------
+    def padframe_calc_work(self, newname=None):
+        elecLib = newname
+        isnew = not newname
+        if isnew:
+            # Use create project code first to generate a valid project space.
+            newname = self.createproject(None)
+            if not newname: return 0		# Canceled in dialog, no action.
+            # print("padframe-calc in new project " + newname + ".")
+            elecLib = newname
+
+        # For life of this projectManager process, store/recall last PadFrame Settings per project
+        global project2pfd
+        try:
+            project2pfd
+        except:
+            project2pfd = {}
+        if newname not in project2pfd:
+            project2pfd[newname] = {"libEntry": None, "cellName": None}
+
+        ppath = self.projectdir + '/' + newname
+        choices = self.get_elecLib_list(newname)
+        if not choices:
+            print( "Aborted: No existing electric libraries found to write symbol into.")
+            return 0
+                
+        elecLib = newname + '/elec/' + elecLib + '.delib'
+        elecLib = project2pfd[newname]["libEntry"] or elecLib
+        cellname = project2pfd[newname]["cellName"] or "padframe"
+        libAndCell = ExistingElecLibCellDialog(self, None, title="PadFrame Settings", plist=choices, descPost="of icon&layout", seedLibNm=elecLib, seedCellNm=cellname).result
+        if not libAndCell:
+            return 0		# Canceled in dialog, no action.
+            
+        (elecLib, cellname) = libAndCell
+        if not cellname:
+            return 0		# empty cellname, no action.
+
+        project2pfd[newname]["libEntry"] = elecLib
+        project2pfd[newname]["cellName"] = cellname
+
+        # Isolate just electric lib name without extension. ../a/b.delib -> b
+        elecLib = os.path.splitext(os.path.split(elecLib)[-1])[0]
+        print("padframe-calc in project: %s, elecLib: %s, cellName: %s" % (newname, elecLib, cellname))
+
+        export = dict(os.environ)
+        export['EF_DESIGNDIR'] = ppath
+        subprocess.Popen(['/ef/apps/bin/padframe-calc', elecLib, cellname], cwd = ppath, env = export)
+
+        # not yet any useful return value or reporting of results here in projectManager...
+        return 1
+
+    #----------------------------------------------------------------------
+    # Run the schematic editor (tool as given by user preference)
+    #----------------------------------------------------------------------
+
+    def edit_schematic(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            
+            pdktechdir = design + self.config_path(design)+'/techdir/libs.tech'
+            
+            applist = self.list_valid_schematic_editors(pdktechdir)
+
+            if len(applist)==0:
+                print("Unable to find a valid schematic editor.")
+                return
+                
+            # If the preferred app is in the list, then use it.
+            
+            if self.prefs['schemeditor'] in applist:
+                appused = self.prefs['schemeditor']
+            else:
+                appused = applist[0]
+
+            if appused == 'xcircuit':
+                return self.edit_schematic_with_xcircuit()
+            elif appused == 'xschem':
+                return self.edit_schematic_with_xschem()
+            elif appused == 'electric':
+                return self.edit_schematic_with_electric()
+            else:
+                print("Unknown/unsupported schematic editor " + appused + ".", file=sys.stderr)
+
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the schematic editor (electric)
+    #----------------------------------------------------------------------
+
+    def edit_schematic_with_electric(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            # self.project_name set by setcurrent.  This is the true project
+            # name, as opposed to the directory name.
+            designname = self.project_name
+            print('Edit schematic ' + designname + ' (' + design + ' )')
+            # Collect libs on command-line;  electric opens these in Explorer
+            libs = []
+            ellibrex = re.compile(r'^(tech_.*|ef_examples)\.[dj]elib$', re.IGNORECASE)
+
+            self.reinitElec(design)
+
+            # /elec and /.java are prerequisites for running electric
+            if not os.path.exists(design + '/elec'):
+                print("No path to electric design folder.")
+                return
+
+            if not os.path.exists(design + '/elec/.java'):
+                print("No path to electric .java folder.")
+                return
+
+            # Fix the LIBDIRS file if needed
+            #fix_libdirs(design, create = True)
+
+            # Check for legacy directory (missing .ef-config and/or .ef-config/techdir);
+            # Handle as necessary.
+
+            # don't sometimes yield pdkdir as some subdir of techdir
+            pdkdir = design + self.config_path(design) + '/techdir/'
+            if not os.path.exists(pdkdir):
+                export = dict(os.environ)
+                export['EF_DESIGNDIR'] = design
+                '''
+                p = subprocess.run(['/ef/efabless/bin/ef-config', '-sh', '-t'],
+			stdout = subprocess.PIPE, env = export)
+                config_out = p.stdout.splitlines()
+                for line in config_out:
+                    setline = line.decode('utf-8').split('=')
+                    if setline[0] == 'EF_TECHDIR':
+                        pdkdir = re.sub("[';]", "", setline[1])
+                '''
+
+            for subpath in ('libs.tech/elec/', 'libs.ref/elec/'):
+                pdkelec = os.path.join(pdkdir, subpath)
+                if os.path.exists(pdkelec) and os.path.isdir(pdkelec):
+                    # don't use os.walk(), it is recursive, wastes time
+                    for entry in os.scandir(pdkelec):
+                        if ellibrex.match(entry.name):
+                                libs.append(entry.path)
+
+            # Locate most useful project-local elec-lib to open on electric cmd-line.
+            designroot = os.path.split(design)[1]
+            finalInDesDirLibAdded = False
+            if os.path.exists(design + '/elec/' + designname + '.jelib'):
+                libs.append(design + '/elec/' + designname + '.jelib')
+                finalInDesDirLibAdded = True
+            elif os.path.isdir(design + '/elec/' + designname + '.delib'):
+                libs.append(design + '/elec/' + designname + '.delib')
+                finalInDesDirLibAdded = True
+            else:
+                # Alternative path is the project name + .delib
+                if os.path.isdir(design + '/elec/' + designroot + '.delib'):
+                    libs.append(design + '/elec/' + designroot + '.delib')
+                    finalInDesDirLibAdded = True
+
+            # Finally, check for the one absolute requirement for a project,
+            # which is that there must be a symbol designname + .ic in the
+            # last directory.  If not, then do a search for it.
+            if not finalInDesDirLibAdded or not os.path.isfile(libs[-1] + '/' + designname + '.ic'):
+                delibdirs = os.listdir(design + '/elec')
+                for delibdir in delibdirs:
+                    if os.path.splitext(delibdir)[1] == '.delib':
+                        iconfiles = os.listdir(design + '/elec/' + delibdir)
+                        for iconfile in iconfiles:
+                            if iconfile == designname + '.ic':
+                                libs.append(design + '/elec/' + delibdir)
+                                finalInDesDirLibAdded = True
+                                break
+            
+            # Above project-local lib-adds are all conditional on finding some lib
+            # with an expected name or content: all of which may fail.
+            # Force last item ALWAYS to be 'a path' in the project's elec/ dir.
+            # Usually it's a real library (found above). (If lib does not exist the messages
+            # window does get an error message). But the purpose is for the universal side-effect:
+            # To EVERY TIME reseed the File/OpenLibrary dialogue WorkDir to start in
+            # project's elec/ dir; avoid it starting somewhere in the PDK, which
+            # is what will happen if last actual cmd-line arg is a lib in the PDK, and
+            # about which users have complained. (Optimal fix needs electric enhancement).
+            if not finalInDesDirLibAdded:
+                libs.append(design + '/elec/' + designroot + '.delib')
+
+            # Pull last item from libs and make it a command-line argument.
+            # All other libraries become part of the EOPENARGS environment variable,
+            # and electric is called with the elecOpen.bsh script.
+            indirectlibs = libs[:-1]
+            export = dict(os.environ)
+            arguments = []
+            if indirectlibs:
+                export['EOPENARGS'] = ' '.join(indirectlibs)
+                arguments.append('-s')
+                arguments.append('/ef/efabless/lib/elec/elecOpen.bsh')
+
+            try:
+                arguments.append(libs[-1])
+            except IndexError:
+                print('Error:  Electric project directories not set up correctly?')
+            else:
+                subprocess.Popen(['electric', *arguments], cwd = design + '/elec',
+				env = export)
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the schematic editor (xcircuit)
+    #----------------------------------------------------------------------
+
+    def edit_schematic_with_xcircuit(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            # self.project_name set by setcurrent.  This is the true project
+            # name, as opposed to the directory name.
+            designname = self.project_name
+            print('Edit schematic ' + designname + ' (' + design + ' )')
+            xcircdirpath = design + '/xcirc'
+            pdkdir = design + self.config_path(design) + '/techdir/libs.tech/xcircuit'
+
+            # /xcirc directory is a prerequisite for running xcircuit.  If it doesn't
+            # exist, create it and seed it with .xcircuitrc from the tech directory
+            if not os.path.exists(xcircdirpath):
+                os.makedirs(xcircdirpath)
+
+            # Copy xcircuit startup file from tech directory
+            hasxcircrcfile = os.path.exists(xcircdirpath + '/.xcircuitrc')
+            if not hasxcircrcfile:
+                if os.path.exists(pdkdir + '/xcircuitrc'):
+                    shutil.copy(pdkdir + '/xcircuitrc', xcircdirpath + '/.xcircuitrc')
+
+            # Command line argument is the project name
+            arguments = [design + '/xcirc' + designname]
+            subprocess.Popen(['xcircuit', *arguments])
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the schematic editor (xschem)
+    #----------------------------------------------------------------------
+
+    def edit_schematic_with_xschem(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # self.project_name set by setcurrent.  This is the true project
+            # name, as opposed to the directory name.
+            designname = self.project_name
+            print('Edit schematic ' + designname + ' (' + design + ' )')
+            xschemdirpath = design + '/xschem'
+            
+            pdkdir = design + self.config_path(design) + '/techdir/libs.tech/xschem'
+
+            
+            # /xschem directory is a prerequisite for running xschem.  If it doesn't
+            # exist, create it and seed it with xschemrc from the tech directory
+            if not os.path.exists(xschemdirpath):
+                os.makedirs(xschemdirpath)
+
+            # Copy xschem startup file from tech directory
+            hasxschemrcfile = os.path.exists(xschemdirpath + '/xschemrc')
+            if not hasxschemrcfile:
+                if os.path.exists(pdkdir + '/xschemrc'):
+                    shutil.copy(pdkdir + '/xschemrc', xschemdirpath + '/xschemrc')
+
+            # Command line argument is the project name.  The "-r" option is recommended if there
+            # is no stdin/stdout piping.
+            
+            arguments = ['-r', design + '/xschem/' + designname]
+            subprocess.Popen(['xschem', *arguments])
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the layout editor (magic or klayout)
+    #----------------------------------------------------------------------
+
+    def edit_layout(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            pdktechdir = design + self.config_path(design) + '/techdir/libs.tech'
+            
+            applist = self.list_valid_layout_editors(pdktechdir)
+
+            if len(applist)==0:
+                print("Unable to find a valid layout editor.")
+                return
+
+            # If the preferred app is in the list, then use it.
+            if self.prefs['layouteditor'] in applist:
+                appused = self.prefs['layouteditor']
+            else:
+                appused = applist[0]
+
+            if appused == 'magic':
+                return self.edit_layout_with_magic()
+            elif appused == 'klayout':
+                return self.edit_layout_with_klayout()
+            elif appused == 'electric':
+                return self.edit_layout_with_electric()
+            else:
+                print("Unknown/unsupported layout editor " + appused + ".", file=sys.stderr)
+
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the magic layout editor
+    #----------------------------------------------------------------------
+
+    def edit_layout_with_magic(self):
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            designname = self.project_name
+            
+            pdkdir = ''
+            pdkname = ''
+            
+            if os.path.exists(design + '/.ef-config/techdir/libs.tech'):
+                pdkdir = design + '/.ef-config/techdir/libs.tech/magic/current'
+                pdkname = os.path.split(os.path.realpath(design + '/.ef-config/techdir'))[1]
+            elif os.path.exists(design + '/.config/techdir/libs.tech'):
+                pdkdir = design + '/.config/techdir/libs.tech/magic'
+                pdkname = os.path.split(os.path.realpath(design + '/.config/techdir'))[1]
+            
+
+            # Check if the project has a /mag directory.  Create it and
+            # put the correct .magicrc file in it, if it doesn't.
+            magdirpath = design + '/mag'
+            hasmagdir = os.path.exists(magdirpath)
+            if not hasmagdir:
+                os.makedirs(magdirpath)
+
+            hasmagrcfile = os.path.exists(magdirpath + '/.magicrc')
+            if not hasmagrcfile:
+                shutil.copy(pdkdir + '/' + pdkname + '.magicrc', magdirpath + '/.magicrc')
+
+            # Check if the .mag file exists for the project.  If not,
+            # generate a dialog.
+            magpath = design + '/mag/' + designname + '.mag'
+            netpath = design + '/spi/' + designname + '.spi'
+            # print("magpath is " + magpath)
+            hasmag = os.path.exists(magpath)
+            hasnet = os.path.exists(netpath)
+            if hasmag:
+                if hasnet:
+                    statbuf1 = os.stat(magpath)
+                    statbuf2 = os.stat(netpath)
+                    # No specific action for out-of-date layout.  To be done:
+                    # Check contents and determine if additional devices need to
+                    # be added to the layout.  This may be more trouble than it's
+                    # worth.
+                    #
+                    # if statbuf2.st_mtime > statbuf1.st_mtime:
+                    #     hasmag = False
+
+            if not hasmag:
+                # Does the project have any .mag files at all?  If so, the project
+                # layout may be under a name different than the project name.  If
+                # so, present the user with a selectable list of layout names,
+                # with the option to start a new layout or import from schematic.
+
+                maglist = os.listdir(design + '/mag/')
+                if len(maglist) > 1:
+                    # Generate selection menu
+                    warning = 'No layout matches IP name ' + designname + '.'
+                    maglist = list(item for item in maglist if os.path.splitext(item)[1] == '.mag')
+                    clist = list(os.path.splitext(item)[0] for item in maglist)
+                    ppath = EditLayoutDialog(self, clist, ppath=design,
+					pname=designname, warning=warning,
+					hasnet=hasnet).result
+                    if not ppath:
+                        return 0		# Canceled in dialog, no action.
+                    elif ppath != '(New layout)':
+                        hasmag = True
+                        designname = ppath
+                elif len(maglist) == 1:
+                    # Only one magic file, no selection, just bring it up.
+                    designname = os.path.split(maglist[0])[1]
+                    hasmag = True
+
+            if not hasmag:
+                populate = NewLayoutDialog(self, "No layout for project.").result
+                if not populate:
+                    return 0	# Canceled, no action.
+                elif populate():
+                    # Name of PDK deprecated.  The .magicrc file in the /mag directory
+                    # will load the correct PDK and specify the proper library for the
+                    # low-level device namespace, which may not be the same as techdir.
+                    # NOTE:  netlist_to_layout script will attempt to generate a
+                    # schematic netlist if one does not exist.
+
+                    print('Running /ef/efabless/bin/netlist_to_layout.py ../spi/' + designname + '.spi')
+                    try:
+                        p = subprocess.run(['/ef/efabless/bin/netlist_to_layout.py',
+					'../spi/' + designname + '.spi'],
+					stdin = subprocess.PIPE, stdout = subprocess.PIPE,
+					stderr = subprocess.PIPE, cwd = design + '/mag')
+                        if p.stderr:
+                            err_string = p.stderr.splitlines()[0].decode('utf-8')
+                            # Print error messages to console
+                            print(err_string)
+
+                    except subprocess.CalledProcessError as e:
+                        print('Error running netlist_to_layout.py: ' + e.output.decode('utf-8'))
+                    else:
+                        if os.path.exists(design + '/mag/create_script.tcl'):
+                            with open(design + '/mag/create_script.tcl', 'r') as infile:
+                                magproc = subprocess.run(['/ef/apps/bin/magic',
+					'-dnull', '-noconsole', '-rcfile ',
+					pdkdir + '/' + pdkname + '.magicrc', designname],
+					stdin = infile, stdout = subprocess.PIPE,
+					stderr = subprocess.PIPE, cwd = design + '/mag')
+                            print("Populated layout cell")
+                            # os.remove(design + '/mag/create_script.tcl')
+                        else:
+                            print("No device generating script was created.", file=sys.stderr)
+
+            print('Edit layout ' + designname + ' (' + design + ' )')
+
+            magiccommand = ['magic']
+            # Select the graphics package used by magic from the profile settings.
+            if 'magic-graphics' in self.prefs:
+                magiccommand.extend(['-d', self.prefs['magic-graphics']])
+            # Check if .magicrc predates the latest and warn if so.
+            statbuf1 = os.stat(design + '/mag/.magicrc')
+            statbuf2 = os.stat(pdkdir + '/' + pdkname + '.magicrc')
+            if statbuf2.st_mtime > statbuf1.st_mtime:
+                print('NOTE:  File .magicrc predates technology startup file.  Using default instead.')
+                magiccommand.extend(['-rcfile', pdkdir + '/' + pdkname + '.magicrc'])
+            magiccommand.append(designname)
+
+            # Run magic and don't wait for it to finish
+            subprocess.Popen(magiccommand, cwd = design + '/mag')
+        else:
+            print("You must first select a project.", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the klayout layout editor
+    #----------------------------------------------------------------------
+
+    def edit_layout_with_klayout(self):
+        value = self.projectselect.selected()
+        print("Klayout unsupported from project manager (work in progress);  run manually", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Run the electric layout editor
+    #----------------------------------------------------------------------
+
+    def edit_layout_with_electric(self):
+        value = self.projectselect.selected()
+        print("Electric layout editing unsupported from project manager (work in progress);  run manually", file=sys.stderr)
+
+    #----------------------------------------------------------------------
+    # Upload design to the marketplace
+    # NOTE:  This is not being called by anything.  Use version in the
+    # characterization script, which can check for local results before
+    # approving (or forcing) an upload.
+    #----------------------------------------------------------------------
+
+    def upload(self):
+        '''
+        value = self.projectselect.selected()
+        if value:
+            design = value['values'][0]
+            # designname = value['text']
+            designname = self.project_name
+            print('Upload design ' + designname + ' (' + design + ' )')
+            subprocess.run(['/ef/apps/bin/withnet',
+			og_config.apps_path + '/cace_design_upload.py',
+			design, '-test'])
+	'''
+
+    #--------------------------------------------------------------------------
+    # Upload a datasheet to the marketplace (Administrative use only, for now)
+    #--------------------------------------------------------------------------
+
+    # def make_challenge(self):
+    #      importp = self.cur_import
+    #      print("Make a Challenge from import " + importp + "!")
+    #      # subprocess.run([og_config.apps_path + '/cace_import_upload.py', importp, '-test'])
+
+    def setcurrent(self, value):
+        global currdesign
+        treeview = value.widget
+        selection = treeview.item(treeview.selection())
+        pname = selection['text']
+        #print("setcurrent returned value " + pname)
+        efmetapath = os.path.expanduser(currdesign)
+        if not os.path.exists(efmetapath):
+            os.makedirs(os.path.split(efmetapath)[0], exist_ok=True)
+        with open(efmetapath, 'w') as f:
+            f.write(pname + '\n')
+
+        # Pick up the PDK from "values", use it to find the PDK folder, determine
+        # if it has a "magic" subfolder, and enable/disable the "Edit Layout"
+        # button accordingly
+        
+        svalues = selection['values']
+        pdkitems = svalues[1].split()
+        pdkdir = ''
+        
+        ef_style=False
+        
+        if os.path.exists(svalues[0] + '/.config'):
+            pdkdir = svalues[0] + '/.config/techdir'
+        elif os.path.exists(svalues[0] + '/.ef-config'):
+            pdkdir = svalues[0] + '/.ef-config/techdir'
+            ef_style=True
+        
+        if pdkdir == '':
+            print('No pdkname found; layout editing disabled')
+            self.toppane.appbar.layout_button.config(state='disabled')
+        else:
+            try:
+                if ef_style:
+                    subf = os.listdir(pdkdir + '/libs.tech/magic/current')
+                else:
+                    subf = os.listdir(pdkdir + '/libs.tech/magic')
+            except:
+                print('PDK ' + pdkname + ' has no layout setup; layout editing disabled')
+                self.toppane.appbar.layout_button.config(state='disabled') 
+        '''
+        svalues = selection['values'][1]
+        print('selection: '+str(selection))
+        pdkitems = svalues.split()
+        print('Contents of pdkitems: '+str(pdkitems))
+        pdkname = ''
+        if ':' in pdkitems:
+            pdkitems.remove(':')
+        if len(pdkitems) == 2:
+            # New behavior Sept. 2017, have to cope with <foundry>.<N> directories, ugh.
+            pdkdirs = os.listdir('/usr/share/pdk/')
+            #TODO: pdkdirs = os.listdir('PREFIX/pdk/')
+            
+            for pdkdir in pdkdirs:
+                if pdkitems[0] == pdkdir:
+                    pdkname = pdkdir
+                    #TODO: PREFIX
+                    if os.path.exists('/usr/share/pdk/' + pdkname + '/' + pdkitems[1]):
+                        break
+                else:
+                    pdkpair = pdkdir.split('.')
+                    if pdkpair[0] == pdkitems[0]:
+                        pdkname = pdkdir
+                        #TODO: PREFIX
+                        if os.path.exists('/usr/share/pdk/' + pdkname + '/' + pdkitems[1]):
+                            break
+            if pdkname == '':
+                print('No pdkname found; layout editing disabled')
+                self.toppane.appbar.layout_button.config(state='disabled')
+            else:
+                try:
+                    subf = os.listdir('/ef/tech/' + pdkname + '/' + pdkitems[1] + '/libs.tech/magic/current')
+                except:
+                    print('PDK ' + pdkname + ' has no layout setup; layout editing disabled')
+                    self.toppane.appbar.layout_button.config(state='disabled')
+                else:
+                    self.toppane.appbar.layout_button.config(state='enabled')
+        else:
+            print('No PDK returned in project selection data;  layout editing disabled.')
+            self.toppane.appbar.layout_button.config(state='disabled')
+        '''
+        # If the selected project directory has a JSON file and netlists in the "spi"
+        # and "testbench" folders, then enable the "Characterize" button;  else disable
+        # it.
+        # NOTE:  project.json is the preferred name for the datasheet
+        # file.  However, the .spi file, .delib file, etc., all have the name of the
+        # project from "ip-name" in the datasheet.
+        # "<project_folder_name>.json" is the legacy name for the datasheet, deprecated.
+
+        found = False
+        ppath = selection['values'][0]
+        jsonname = ppath + '/project.json'
+        legacyname = ppath + '/' + pname + '.json'
+        if not os.path.isfile(jsonname):
+            if os.path.isfile(legacyname):
+                jsonname = legacyname
+
+        if os.path.isfile(jsonname):
+            # Pull the ipname into local store (may want to do this with the
+            # datasheet as well)
+            with open(jsonname, 'r') as f:
+                datatop = json.load(f)
+                dsheet = datatop['data-sheet']
+                ipname = dsheet['ip-name']
+                self.project_name = ipname
+                found = True
+       
+            # Do not specifically prohibit opening the characterization app if
+            # there is no schematic or netlist.  Otherwise the user is prevented
+            # even from seeing the electrical parameters.  Let the characterization
+            # tool allow or prohibit simulation based on this.
+            # if os.path.exists(ppath + '/spi'):
+            #     if os.path.isfile(ppath + '/spi/' + ipname + '.spi'):
+            #         found = True
+            #
+            # if found == False and os.path.exists(ppath + '/elec'):
+            #     if os.path.isdir(ppath + '/elec/' + ipname + '.delib'):
+            #         if os.path.isfile(ppath + '/elec/' + ipname + '.delib/' + ipname + '.sch'):
+            #             found = True
+        else:
+            # Use 'pname' as the default project name.
+            print('No characterization file ' + jsonname)
+            print('Setting project ip-name from the project folder name.')
+            self.project_name = pname
+
+        # If datasheet has physical parameters but not electrical parameters, then it's okay
+        # for it not to have a testbench directory;  it's still valid.  However, having
+        # neither physical nor electrical parameters means there's nothing to characterize.
+        if found and 'electrical-params' in dsheet and len(dsheet['electrical-params']) > 0:
+            if not os.path.isdir(ppath + '/testbench'):
+                print('No testbench directory for eletrical parameter simulation methods.', file=sys.stderr)
+                found = False
+        elif found and not 'physical-params' in dsheet:
+            print('Characterization file defines no characterization tests.', file=sys.stderr)
+            found = False
+        elif found and 'physical-params' in dsheet and len(dsheet['physical-params']) == 0:
+            print('Characterization file defines no characterization tests.', file=sys.stderr)
+            found = False
+
+        if found == True:
+            self.toppane.appbar.char_button.config(state='enabled')
+        else:
+            self.toppane.appbar.char_button.config(state='disabled')
+
+        # Warning: temporary hack (Tim, 1/9/2018)
+        # Pad frame generator is currently limited to the XH035 cells, so if the
+        # project PDK is not XH035, disable the pad frame button
+
+        if len(pdkitems) > 1 and pdkitems[1] == 'EFXH035B':
+            self.toppane.appbar.padframeCalc_button.config(state='enabled')
+        else:
+            self.toppane.appbar.padframeCalc_button.config(state='disabled')
+
+# main app. fyi: there's a 2nd/earlier __main__ section for splashscreen
+if __name__ == '__main__':
+    OpenGalaxyManager(root)
+    if deferLoad:
+        # Without this, mainloop may find&run very short clock-delayed events BEFORE main form display.
+        # With it 1st project-load can be scheduled using after-time=0 (needn't tune a delay like 100ms).
+        root.update_idletasks()
+    root.mainloop()
diff --git a/common/profile.py b/common/profile.py
new file mode 100755
index 0000000..83a8dc1
--- /dev/null
+++ b/common/profile.py
@@ -0,0 +1,281 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#------------------------------------------------------------
+# Profile settings window for the Open Galaxy project manager
+#
+#------------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# July 21, 2017
+# Version 0.1
+#--------------------------------------------------------
+
+import os
+import re
+import json
+import tkinter
+import subprocess
+from tkinter import ttk
+
+import og_config
+
+class Profile(tkinter.Toplevel):
+    """Open Galaxy project manager profile settings management."""
+
+    def __init__(self, parent=None, fontsize = 11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        self.protocol("WM_DELETE_WINDOW", self.close)
+        self.parent = parent
+        self.withdraw()
+        self.title('Open Galaxy Project Manager Profile Settings')
+        self.sframe = tkinter.Frame(self)
+        self.sframe.grid(column = 0, row = 0, sticky = "news")
+
+        self.sframe.stitle = ttk.Label(self.sframe,
+		style='title.TLabel', text = 'Profile')
+        self.sframe.stitle.grid(column = 0, row = 0, sticky = 'news', padx = 5, pady = 5, columnspan = 2)
+        self.sframe.sbar = ttk.Separator(self.sframe, orient='horizontal')
+        self.sframe.sbar.grid(column = 0, row = 1, sticky = 'news', padx = 5, pady = 5, columnspan = 2)
+
+        # Read profile JSON file
+        self.prefsfile = os.path.expanduser('~/.open_pdks/prefs.json')
+
+        if os.path.exists(self.prefsfile):
+            with open(self.prefsfile, 'r') as f:
+                prefs = json.load(f)
+        else:
+            prefs = {}
+
+        # Default font size preference
+
+        self.fontsize = tkinter.IntVar(self.sframe)
+        self.sframe.lfsize = ttk.Label(self.sframe, text='Font size', style='blue.TLabel', anchor='e')
+        self.sframe.fsize = ttk.Entry(self.sframe, textvariable=self.fontsize)
+        self.sframe.lfsize.grid(column = 0, row = 2, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.fsize.grid(column = 1, row = 2, sticky = 'news', padx = 5, pady = 5)
+
+        if 'fontsize' in prefs:
+            self.fontsize.set(int(prefs['fontsize']))
+        else:
+            self.fontsize.set(11)
+
+        # User name as written at the top of the project manager and characterization tools
+
+        self.username = tkinter.StringVar(self.sframe)
+        self.sframe.luser = ttk.Label(self.sframe, text='User name', style='blue.TLabel', anchor='e')
+        self.sframe.user = ttk.Entry(self.sframe, textvariable=self.username)
+        self.sframe.luser.grid(column = 0, row = 3, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.user.grid(column = 1, row = 3, sticky = 'news', padx = 5, pady = 5)
+
+        if 'username' in prefs:
+            self.username.set(prefs['username'])
+        else:
+            userid = os.environ['USER']
+            '''
+            p = subprocess.run(['/ef/apps/bin/withnet',
+                        og_config.apps_path + '/og_uid_service.py', userid],
+                        stdout = subprocess.PIPE)
+            
+            if p.stdout:
+                uid_string = p.stdout.splitlines()[0].decode('utf-8')
+                userspec = re.findall(r'[^"\s]\S*|".+?"', uid_string)
+                if len(userspec) > 0:
+                    username = userspec[0].strip('"')
+                    # Note userspec[1] = UID and userspec[2] = role, useful
+                    # for future applications.
+                else:
+                    username = userid
+            else:
+                username = userid
+            '''
+            username=userid
+
+            self.username.set(username)
+
+        # Graphics format for magic
+        magicgraphics = ['X11', 'CAIRO', 'OPENGL']
+
+        self.maggraph = tkinter.StringVar(self.sframe)
+        self.maggraph.set(0)
+        self.sframe.lmaggraph = ttk.Label(self.sframe, text='Layout editor graphics format', style='blue.TLabel', anchor='e')
+        self.sframe.maggraph = ttk.OptionMenu(self.sframe, self.maggraph,
+		self.maggraph.get(), *magicgraphics)
+
+        self.sframe.lmaggraph.grid(column = 0, row = 4, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.maggraph.grid(column = 1, row = 4, sticky = 'news', padx = 5, pady = 5)
+
+        if 'magic-graphics' in prefs:
+            self.maggraph.set(prefs['magic-graphics'])
+        else:
+            self.maggraph.set('X11')
+
+        # Choice of layout editor
+        layouteditors = ['magic', 'klayout', 'electric']
+
+        self.layouteditor = tkinter.StringVar(self.sframe)
+        self.layouteditor.set(0)
+        self.sframe.llayouteditor = ttk.Label(self.sframe, text='Layout editor', style='blue.TLabel', anchor='e')
+        self.sframe.layouteditor = ttk.OptionMenu(self.sframe, self.layouteditor,
+		self.layouteditor.get(), *layouteditors)
+
+        self.sframe.llayouteditor.grid(column = 0, row = 5, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.layouteditor.grid(column = 1, row = 5, sticky = 'news', padx = 5, pady = 5)
+
+        if 'layout-editor' in prefs:
+            self.layouteditor.set(prefs['layout-editor'])
+        else:
+            self.layouteditor.set('magic')
+
+        # Choice of schematic editor
+        schemeditors = ['electric', 'xschem', 'xcircuit']
+
+        self.schemeditor = tkinter.StringVar(self.sframe)
+        self.schemeditor.set(0)
+        self.sframe.lschemeditor = ttk.Label(self.sframe, text='Schematic editor', style='blue.TLabel', anchor='e')
+        self.sframe.schemeditor = ttk.OptionMenu(self.sframe, self.schemeditor,
+		self.schemeditor.get(), *schemeditors)
+
+        self.sframe.lschemeditor.grid(column = 0, row = 6, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.schemeditor.grid(column = 1, row = 6, sticky = 'news', padx = 5, pady = 5)
+
+        
+        if 'schemeditor' in prefs:
+            self.schemeditor.set(prefs['schemeditor'])
+        else:
+            self.schemeditor.set('electric')
+
+        # Allow the project manager to list development PDKs and create projects
+        # using them.
+
+        self.development = tkinter.IntVar(self.sframe)
+        self.sframe.ldev = ttk.Label(self.sframe, text='Create projects with development PDKs', style='blue.TLabel', anchor='e')
+        self.sframe.dev = ttk.Checkbutton(self.sframe, variable=self.development)
+        self.sframe.ldev.grid(column = 0, row = 7, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.dev.grid(column = 1, row = 7, sticky = 'news', padx = 5, pady = 5)
+
+        if 'development' in prefs:
+            self.development.set(True)
+        else:
+            self.development.set(False)
+
+        # Allow the synthesis tool to list PDK development standard cell sets
+
+        self.devstdcells = tkinter.IntVar(self.sframe)
+        self.sframe.ldev = ttk.Label(self.sframe, text='Use development libraries for digital synthesis', style='blue.TLabel', anchor='e')
+        self.sframe.dev = ttk.Checkbutton(self.sframe, variable=self.devstdcells)
+        self.sframe.ldev.grid(column = 0, row = 8, sticky = 'news', padx = 5, pady = 5)
+        self.sframe.dev.grid(column = 1, row = 8, sticky = 'news', padx = 5, pady = 5)
+
+        if 'devstdcells' in prefs:
+            self.devstdcells.set(True)
+        else:
+            self.devstdcells.set(False)
+
+        # Button bar
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 1, sticky = "news")
+
+        self.bbar.save_button = ttk.Button(self.bbar, text='Save',
+		command=self.save, style = 'normal.TButton')
+        self.bbar.save_button.grid(column=0, row=0, padx = 5)
+
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=1, row=0, padx = 5)
+
+    def save(self):
+        # Create JSON record of options and write them to prefs.json
+        with open(self.prefsfile, 'w') as f:
+            prefs = {}
+            prefs['fontsize'] = self.get_fontsize()
+            prefs['username'] = self.get_username()
+            prefs['schemeditor'] = self.get_schemeditor()
+            prefs['magic-graphics'] = self.get_magic_graphics()
+            prefs['development'] = self.get_development()
+            prefs['devstdcells'] = self.get_devstdcells()
+            json.dump(prefs, f, indent = 4)
+        # Live-updates where easy. Due read_prefs, a magic-graphics takes effect immediately.
+        self.parent.read_prefs()
+        self.parent.refreshToolTips()
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def redisplay(self):
+        pass
+
+    def get_fontsize(self):
+        # return the fontsize value
+        return self.fontsize.get()
+
+    def get_username(self):
+        # return the username
+        return self.username.get()
+
+    def get_schemeditor(self):
+        # return the state of the "keep simulation files" checkbox
+        return self.schemeditor.get()
+
+    def get_magic_graphics(self):
+        # return the format of graphics to use in Magic
+        return self.maggraph.get()
+
+    def get_development(self):
+        # return the T/F value for creating projects with development PDKs
+        return self.development.get()
+
+    def get_devstdcells(self):
+        # return the T/F value for synthesizing projects with development standard cells
+        return self.devstdcells.get()
+
+    def refresh(self):
+        self.prefsfile = os.path.expanduser('~/.open_pdks/prefs.json')
+
+        if os.path.exists(self.prefsfile):
+            with open(self.prefsfile, 'r') as f:
+                prefs = json.load(f)
+        else:
+            prefs = {}
+        if 'fontsize' in prefs:
+            self.fontsize.set(int(prefs['fontsize']))
+        else:
+            self.fontsize.set(11)
+        if 'username' in prefs:
+            self.username.set(prefs['username'])
+        else:
+            userid = os.environ['USER']
+        if 'magic-graphics' in prefs:
+            self.maggraph.set(prefs['magic-graphics'])
+        else:
+            self.maggraph.set('X11')
+        if 'layout-editor' in prefs:
+            self.layouteditor.set(prefs['layout-editor'])
+        else:
+            self.layouteditor.set('magic')
+        if 'schemeditor' in prefs:
+            self.schemeditor.set(prefs['schemeditor'])
+        else:
+            self.schemeditor.set('electric')
+        if 'development' in prefs:
+            self.development.set(True)
+        else:
+            self.development.set(False)
+        if 'devstdcells' in prefs:
+            self.devstdcells.set(True)
+        else:
+            self.devstdcells.set(False)
+    
+    def close(self):
+        # pop down profile settings window
+        self.withdraw()
+
+    def open(self):
+        # pop up profile settings window
+        self.refresh()
+        self.deiconify()
+        self.lift()
diff --git a/common/rename_project.py b/common/rename_project.py
new file mode 100755
index 0000000..8c06c8b
--- /dev/null
+++ b/common/rename_project.py
@@ -0,0 +1,291 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3 -B
+#
+# rename_project.py ---  Perform all tasks required for renaming a project.
+#
+# In the context of this script, "renaming" a project means changing the
+# 'ip-name' entry in the JSON file and all that that implies.  To create a
+# new project with an existing ip-name is essentially a trivial process of
+# renaming the parent directory.
+#
+# Note that when a catalog entry is generated in the marketplace, the entry's
+# default name is taken from the parent directory name, not the ip-name.  The
+# resulting entry name is irrelevant to most everything except how and where
+# the IP is listed in the catalog.  The read-only IP version has the name of
+# the ip-name.  Implies it is important that ip-name does not collide with
+# the ip-name of anything else in the marketplace catalog (but this is not
+# currently enforced).
+#
+# Modified 12/20/2018: New protocol is that the .json file is always called
+# 'project.json' and does not take the name of the parent directory.  This
+# makes projects more portable.
+#
+import shutil
+import json
+import stat
+import sys
+import os
+import re
+
+"""
+    This module converts an entire project from one ip-name to another, making
+    sure that all filenames and file contents are updated.
+"""
+
+def copy_meta_with_ownership(src, dst, follow_symlinks=False):
+    # Copy file metadata using copystat() and preserve ownership through stat calls.
+    file_stat = os.stat(src)
+    owner = file_stat[stat.ST_UID]
+    group = file_stat[stat.ST_GID]
+    shutil.copystat(src, dst)
+    os.chown(dst, owner, group, follow_symlinks=follow_symlinks)
+
+def rename_json(project_path, json_file, new_name, orig_name = ''):
+    # Make sure we have the full absolute path to the project
+    fullpath = os.path.abspath(project_path)
+    # Project directory name is the last component of the full path
+    dirname = os.path.split(fullpath)[1]
+    # Get contents of the file, then recast the ip-name and rewrite it.
+    with open(json_file, 'r') as ifile:
+        datatop = json.load(ifile)
+
+    # Find ip-name and replace it
+    if 'data-sheet' in datatop:
+        dsheet = datatop['data-sheet']
+        if 'ip-name' in dsheet:
+            ipname = dsheet['ip-name']
+            if ipname == orig_name:
+                dsheet['ip-name'] = new_name
+            elif orig_name == '':
+                dsheet['ip-name'] = new_name
+            else:
+                print('Error: original name ' + orig_name + ' specified in command line,')
+                print('but ip-name is ' + ipname + ' in the datasheet.')
+                return ipname
+
+    # Change name of file.  This must match the name of the directory whether
+    # or not the directory name matches the IP name.
+    # (New protocol from Dec. 2018:  JSON file is now always named 'project.json')
+
+    opath = os.path.split(json_file)[0]
+    # oname = opath + '/' + dirname + '.json'
+    oname = opath + '/project.json'
+    
+    with open(oname, 'w') as ofile:
+        json.dump(datatop, ofile, indent = 4)
+
+    # Remove original file.  Avoid destroying the file if rename_project happens
+    # to be called with the same name for old and new.
+    if (json_file != oname):
+        copy_meta_with_ownership(json_file, oname)
+        os.remove(json_file)
+
+    # Return the original IP name
+    return ipname
+
+def rename_netlist(netlist_path, orig_name, new_name):
+    # All netlists can be regenerated on the fly, so remove any that are
+    # from orig_name
+    if not os.path.exists(netlist_path):
+        return
+
+    filelist = os.listdir(netlist_path)
+    for file in filelist:
+       rootname = os.path.splitext(file)[0]
+       fullpath = netlist_path + '/' + file
+       if rootname == orig_name:
+           if os.path.isdir(fullpath):
+               rename_netlist(fullpath, orig_name, new_name);
+           else:
+               print('Removing netlist file ' + file)
+               os.remove(fullpath)
+
+def rename_magic(magic_path, orig_name, new_name):
+    # remove old files that will get regenerated:  comp.out, comp.json, any *.log
+    # move any file beginnng with orig_name to the same file with new_name
+    filelist = os.listdir(magic_path)
+
+    # All netlists can be regenerated on the fly, so remove any that are
+    # from orig_name
+    for file in filelist:
+       rootname, fext = os.path.splitext(file)
+
+       if file == 'comp.out' or file == 'comp.json':
+           os.remove(magic_path + '/' + file)
+       elif rootname == orig_name:
+           if fext == '.spc' or fext == '.spice':
+               print('Removing netlist file ' + file)
+               os.remove(magic_path + '/' + file)
+           elif fext == '.ext' or fext == '.lef':
+               os.remove(magic_path + '/' + file)
+           else:
+               shutil.move(magic_path + '/' + file, magic_path + '/' + new_name + fext)
+
+       elif fext == '.log':
+           os.remove(magic_path + '/' + file)
+
+def rename_verilog(verilog_path, orig_name, new_name):
+    filelist = os.listdir(verilog_path)
+
+    # The root module name can remain as the original (may not match orig_name
+    # anyway), but the verilog file containing it gets renamed.
+    # To be done:  Any file (e.g., simulation testbenches, makefiles) referencing
+    # the file must be modified to match.  These may be in subdirectories, so
+    # walk the filesystem from verilog_path.
+
+    for file in filelist:
+       rootname, fext = os.path.splitext(file)
+       if rootname == orig_name:
+           if fext == '.v' or fext == '.sv':
+               shutil.move(verilog_path + '/' + file, verilog_path + '/' + new_name + fext)
+
+def rename_electric(electric_path, orig_name, new_name):
+    # <project_name>.delib gets renamed
+
+    filelist = os.listdir(electric_path)
+    for file in filelist:
+        rootname, fext = os.path.splitext(file)
+        if rootname == orig_name:
+            shutil.move(electric_path + '/' + file, electric_path + '/' + new_name + fext)
+
+    delib_path = electric_path + '/' + new_name + '.delib'
+    if os.path.exists(delib_path):
+        filelist = os.listdir(delib_path)
+        for file in filelist:
+            if os.path.isdir(file):
+                continue
+            rootname, fext = os.path.splitext(file)
+            if rootname == orig_name:
+                # Read and do name substitution where orig_name occurs
+                # in 'H', 'C', and 'L' statements.  The top-level should not appear in
+                # an 'I' (instance) statement.
+                with open(delib_path + '/' + file, 'r') as ifile:
+                    contents = ifile.read()
+                    contents = re.sub('H' + orig_name + '\|', 'H' + new_name + '|', contents)
+                    contents = re.sub('C' + orig_name + ';', 'C' + new_name + ';', contents)
+                    contents = re.sub('L' + orig_name + '\|' + orig_name, 'L' + new_name + '|' + new_name, contents)
+		
+                oname = new_name + fext
+                with open(delib_path + '/' + oname, 'w') as ofile:
+                    ofile.write(contents)
+
+                # Copy ownership and permissions from the old file
+                # Remove the original file
+                copy_meta_with_ownership(delib_path + '/' + file, delib_path + '/' + oname)
+                os.remove(delib_path + '/' + file)
+
+            elif rootname == 'header':
+                # Read and do name substitution where orig_name occurs in 'H' statements.
+                with open(delib_path + '/' + file, 'r') as ifile:
+                    contents = ifile.read()
+                    contents = re.sub('H' + orig_name + '\|', 'H' + new_name + '|', contents)
+		
+                with open(delib_path + '/' + file + '.tmp', 'w') as ofile:
+                    ofile.write(contents)
+
+                copy_meta_with_ownership(delib_path + '/' + file, delib_path + '/' + file + '.tmp')
+                os.remove(delib_path + '/' + file)
+                shutil.move(delib_path + '/' + file + '.tmp', delib_path + '/' + file)
+
+# Top level routine (call this one)
+
+def rename_project_all(project_path, new_name, orig_name=''):
+    # project_path is the original full path to the project in the user's design space.
+    #
+    # new_name is the new name to give to the project.  It is assumed to have been
+    # already checked for uniqueness against existing names
+
+    # Original name is determined from the 'ip-name' field in the JSON file
+    # unless it is specified as a separate argument.
+
+    proj_name = os.path.split(project_path)[1]
+    json_path = project_path + '/project.json'  
+
+    # The JSON file is assumed to have the name "project.json" always.
+    # However, if the project directory just got named, or if the project pre-dates
+    # December 2018, then that may not be true.  If json_path does not exist, look
+    # for any JSON file containing a data-sheet entry.
+
+    if not os.path.exists(json_path):
+        json_path = ''
+        filelist = os.listdir(project_path)
+        for file in filelist:
+            if os.path.splitext(file)[1] == '.json':
+                with open(project_path + '/' + file) as ifile:
+                    datatop = json.load(ifile)
+                    if 'data-sheet' in datatop:
+                        json_path = project_path + '/' + file
+                        break
+
+    if os.path.exists(json_path):
+        if (orig_name == ''):
+            orig_name = rename_json(project_path, json_path, new_name)
+        else:
+            test_name = rename_json(project_path, json_path, new_name, orig_name)
+            if test_name != orig_name:
+                # Refusing to make a change because the orig_name didn't match ip-name
+                return
+    else:
+        if (orig_name == ''):
+            orig_name = proj_name
+
+    if orig_name == new_name:
+        print('Warning:  project old and new names are the same;  nothing to change.', file=sys.stderr)
+        return
+
+    # Each subroutine renames a specific group of files.
+    electric_path = project_path + '/elec'
+    if os.path.exists(electric_path):
+        rename_electric(electric_path, orig_name, new_name)
+
+    magic_path = project_path + '/mag'
+    if os.path.exists(magic_path):
+        rename_magic(magic_path, orig_name, new_name)
+
+    verilog_path = project_path + '/verilog'
+    if os.path.exists(verilog_path):
+        rename_verilog(verilog_path, orig_name, new_name)
+
+    # Maglef is deprecated in anything but readonly IP and PDKs, but
+    # handle for backwards compatibility.
+    maglef_path = project_path + '/maglef'
+    if os.path.exists(maglef_path):
+        rename_magic(maglef_path, orig_name, new_name)
+
+    netlist_path = project_path + '/spi'
+    if os.path.exists(netlist_path):
+        rename_netlist(netlist_path, orig_name, new_name)
+        rename_netlist(netlist_path + '/pex', orig_name, new_name)
+        rename_netlist(netlist_path + '/cdl', orig_name, new_name)
+        rename_netlist(netlist_path + '/lvs', orig_name, new_name)
+
+    # To be done:  handle qflow directory if it exists.
+
+    print('Renamed project ' + orig_name + ' to ' + new_name + '; done.')
+
+# If called as main, run rename_project_all.
+
+if __name__ == '__main__':
+
+    # Divide up command line into options and arguments
+    options = []
+    arguments = []
+    for item in sys.argv[1:]:
+        if item.find('-', 0) == 0:
+            options.append(item)
+        else:
+            arguments.append(item)
+
+    # Need two arguments:  path to directory, and new project name.
+
+    if len(arguments) < 2:
+        print("Usage:  rename_project.py <project_path> <new_name> [<orig_name>]")
+    elif len(arguments) >= 2:
+        project_path = arguments[0]
+        new_name = arguments[1]
+
+        if len(arguments) == 3:
+            orig_name = arguments[2]
+            rename_project_all(project_path, new_name, orig_name)
+        else:
+            rename_project_all(project_path, new_name)
+
diff --git a/common/settings.py b/common/settings.py
new file mode 100755
index 0000000..f25c107
--- /dev/null
+++ b/common/settings.py
@@ -0,0 +1,158 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#-----------------------------------------------------------
+# Settings window for the Open Galaxy characterization tool
+#
+#-----------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# March 17, 2017
+# Version 0.1
+#--------------------------------------------------------
+
+import re
+import tkinter
+from tkinter import ttk
+
+class Settings(tkinter.Toplevel):
+    """Open Galaxy characterization tool settings management."""
+
+    def __init__(self, parent=None, fontsize = 11, callback = None, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        self.protocol("WM_DELETE_WINDOW", self.close)
+        self.parent = parent
+        self.withdraw()
+        self.title('Open Galaxy Characterization Tool Settings')
+        self.sframe = tkinter.Frame(self)
+        self.sframe.grid(column = 0, row = 0, sticky = "news")
+
+        self.sframe.stitle = ttk.Label(self.sframe,
+		style='title.TLabel', text = 'Settings')
+        self.sframe.stitle.pack(side = 'top', fill = 'x', expand = 'true')
+        self.sframe.sbar = ttk.Separator(self.sframe, orient='horizontal')
+        self.sframe.sbar.pack(side = 'top', fill = 'x', expand = 'true')
+
+        self.doforce = tkinter.IntVar(self.sframe)
+        self.doforce.set(0)
+        self.sframe.force = ttk.Checkbutton(self.sframe, text='Force netlist regeneration',
+		variable = self.doforce)
+        self.sframe.force.pack(side = 'top', anchor = 'w')
+
+        self.doedit = tkinter.IntVar(self.sframe)
+        self.doedit.set(0)
+        self.sframe.edit = ttk.Checkbutton(self.sframe, text='Allow edit of all parameters',
+		variable = self.doedit)
+        self.sframe.edit.pack(side = 'top', anchor = 'w')
+
+        self.dokeep = tkinter.IntVar(self.sframe)
+        self.dokeep.set(0)
+        self.sframe.keep = ttk.Checkbutton(self.sframe, text='Keep simulation files',
+		variable = self.dokeep)
+        self.sframe.keep.pack(side = 'top', anchor = 'w')
+
+        self.doplot = tkinter.IntVar(self.sframe)
+        self.doplot.set(1)
+        self.sframe.plot = ttk.Checkbutton(self.sframe, text='Create plot files locally',
+		variable = self.doplot)
+        self.sframe.plot.pack(side = 'top', anchor = 'w')
+
+        self.dotest = tkinter.IntVar(self.sframe)
+        self.dotest.set(0)
+        self.sframe.test = ttk.Checkbutton(self.sframe, text='Submission test-only mode',
+		variable = self.dotest)
+        self.sframe.test.pack(side = 'top', anchor = 'w')
+
+        self.doschem = tkinter.IntVar(self.sframe)
+        self.doschem.set(0)
+        self.sframe.schem = ttk.Checkbutton(self.sframe,
+		text='Force submit as schematic only',
+		variable = self.doschem)
+        self.sframe.schem.pack(side = 'top', anchor = 'w')
+
+        self.dosubmitfailed = tkinter.IntVar(self.sframe)
+        self.dosubmitfailed.set(0)
+        self.sframe.submitfailed = ttk.Checkbutton(self.sframe,
+		text='Allow submission of unsimulated/failing design',
+		variable = self.dosubmitfailed)
+        self.sframe.submitfailed.pack(side = 'top', anchor = 'w')
+
+        self.dolog = tkinter.IntVar(self.sframe)
+        self.dolog.set(0)
+        self.sframe.log = ttk.Checkbutton(self.sframe, text='Log simulation output',
+		variable = self.dolog)
+        self.sframe.log.pack(side = 'top', anchor = 'w')
+
+        self.loadsave = tkinter.IntVar(self.sframe)
+        self.loadsave.set(0)
+        self.sframe.loadsave = ttk.Checkbutton(self.sframe, text='Unlimited loads/saves',
+		variable = self.loadsave)
+        self.sframe.loadsave.pack(side = 'top', anchor = 'w')
+
+        # self.sframe.sdisplay.sopts(side = 'top', fill = 'x', expand = 'true')
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 1, sticky = "news")
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=0, row=0, padx = 5)
+
+        # Callback-on-close
+        self.callback = callback
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def redisplay(self):
+        pass
+
+    def get_force(self):
+        # return the state of the "force netlist regeneration" checkbox
+        return False if self.doforce.get() == 0 else True
+
+    def get_edit(self):
+        # return the state of the "edit all parameters" checkbox
+        return False if self.doedit.get() == 0 else True
+
+    def get_keep(self):
+        # return the state of the "keep simulation files" checkbox
+        return False if self.dokeep.get() == 0 else True
+
+    def get_plot(self):
+        # return the state of the "create plot files locally" checkbox
+        return False if self.doplot.get() == 0 else True
+
+    def get_test(self):
+        # return the state of the "submit test mode" checkbox
+        return False if self.dotest.get() == 0 else True
+
+    def get_schem(self):
+        # return the state of the "submit as schematic" checkbox
+        return False if self.doschem.get() == 0 else True
+
+    def get_submitfailed(self):
+        # return the state of the "submit failed" checkbox
+        return False if self.dosubmitfailed.get() == 0 else True
+
+    def get_log(self):
+        # return the state of the "log simulation output" checkbox
+        return False if self.dolog.get() == 0 else True
+
+    def get_loadsave(self):
+        # return the state of the "unlimited loads/saves" checkbox
+        return False if self.loadsave.get() == 0 else True
+
+    def close(self):
+        # pop down settings window
+        self.withdraw()
+        # execute the callback function, if one is given
+        if self.callback:
+            self.callback()
+
+    def open(self):
+        # pop up settings window
+        self.deiconify()
+        self.lift()
diff --git a/common/simhints.py b/common/simhints.py
new file mode 100755
index 0000000..3dbe48f
--- /dev/null
+++ b/common/simhints.py
@@ -0,0 +1,372 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#-----------------------------------------------------------
+# Simulation hints management for the Open Galaxy
+# characterization tool
+#-----------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# March 21, 2017
+# Version 0.1
+#--------------------------------------------------------
+
+import re
+import tkinter
+from tkinter import ttk
+
+class SimHints(tkinter.Toplevel):
+    """Characterization tool simulation hints management."""
+
+    def __init__(self, parent=None, fontsize = 11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+        self.protocol("WM_DELETE_WINDOW", self.close)
+        self.parent = parent
+        self.withdraw()
+        self.title('Simulation hints management')
+        self.sframe = tkinter.Frame(self)
+        self.sframe.grid(column = 0, row = 0, sticky = "news")
+
+        self.sframe.stitle = ttk.Label(self.sframe,
+		style='title.TLabel', text = 'Hints')
+        self.sframe.stitle.pack(side = 'top', fill = 'x', expand = 'true')
+        self.sframe.sbar = ttk.Separator(self.sframe, orient='horizontal')
+        self.sframe.sbar.pack(side = 'top', fill = 'x', expand = 'true')
+
+        self.sframe.curparam = ttk.Label(self.sframe,
+		style='title.TLabel', text = 'None')
+        self.sframe.curparam.pack(side = 'top', fill = 'x', expand = 'true')
+        self.sframe.sbar2 = ttk.Separator(self.sframe, orient='horizontal')
+        self.sframe.sbar2.pack(side = 'top', fill = 'x', expand = 'true')
+
+        # Keep current parameter
+        self.param = None
+
+        #--------------------------------------------------------
+        # reltol option
+        #--------------------------------------------------------
+
+        self.sframe.do_reltol = tkinter.Frame(self.sframe)
+        self.sframe.do_reltol.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_reltol = tkinter.IntVar(self.sframe)
+        self.do_reltol.set(0)
+        self.sframe.do_reltol.enable = ttk.Checkbutton(self.sframe.do_reltol,
+		text='Set reltol', variable = self.do_reltol,
+		command=self.apply_reltol)
+        self.sframe.do_reltol.value = ttk.Entry(self.sframe.do_reltol)
+        self.sframe.do_reltol.value.delete(0, 'end')
+        self.sframe.do_reltol.value.insert(0, '1.0E-3')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_reltol.value.bind('<Return>', self.apply_reltol)
+        self.sframe.do_reltol.value.bind('<Leave>', self.apply_reltol)
+        self.sframe.do_reltol.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_reltol.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+        # rshunt option
+        #--------------------------------------------------------
+
+        self.sframe.do_rshunt = tkinter.Frame(self.sframe)
+        self.sframe.do_rshunt.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_rshunt = tkinter.IntVar(self.sframe)
+        self.do_rshunt.set(0)
+        self.sframe.do_rshunt.enable = ttk.Checkbutton(self.sframe.do_rshunt,
+		text='Use shunt resistance', variable = self.do_rshunt,
+		command=self.apply_rshunt)
+        self.sframe.do_rshunt.value = ttk.Entry(self.sframe.do_rshunt)
+        self.sframe.do_rshunt.value.delete(0, 'end')
+        self.sframe.do_rshunt.value.insert(0, '1.0E20')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_rshunt.value.bind('<Return>', self.apply_rshunt)
+        self.sframe.do_rshunt.value.bind('<Leave>', self.apply_rshunt)
+        self.sframe.do_rshunt.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_rshunt.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+        # nodeset option
+        #--------------------------------------------------------
+
+        self.sframe.do_nodeset = tkinter.Frame(self.sframe)
+        self.sframe.do_nodeset.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_nodeset = tkinter.IntVar(self.sframe)
+        self.do_nodeset.set(0)
+        self.sframe.do_nodeset.enable = ttk.Checkbutton(self.sframe.do_nodeset,
+		text='Use nodeset', variable = self.do_nodeset,
+		command=self.apply_nodeset)
+        self.sframe.do_nodeset.value = ttk.Entry(self.sframe.do_nodeset)
+        self.sframe.do_nodeset.value.delete(0, 'end')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_nodeset.value.bind('<Return>', self.apply_nodeset)
+        self.sframe.do_nodeset.value.bind('<Leave>', self.apply_nodeset)
+        self.sframe.do_nodeset.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_nodeset.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+        # itl1 option
+        #--------------------------------------------------------
+
+        self.sframe.do_itl1 = tkinter.Frame(self.sframe)
+        self.sframe.do_itl1.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_itl1 = tkinter.IntVar(self.sframe)
+        self.do_itl1.set(0)
+        self.sframe.do_itl1.enable = ttk.Checkbutton(self.sframe.do_itl1,
+		text='Set gmin iterations', variable = self.do_itl1,
+		command=self.apply_itl1)
+        self.sframe.do_itl1.value = ttk.Entry(self.sframe.do_itl1)
+        self.sframe.do_itl1.value.delete(0, 'end')
+        self.sframe.do_itl1.value.insert(0, '100')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_itl1.value.bind('<Return>', self.apply_itl1)
+        self.sframe.do_itl1.value.bind('<Leave>', self.apply_itl1)
+        self.sframe.do_itl1.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_itl1.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+        # include option
+        # Disabled for now.  This needs to limit the selection of
+        # files to include to a drop-down selection list.
+        #--------------------------------------------------------
+        self.sframe.do_include = tkinter.Frame(self.sframe)
+        # self.sframe.do_include.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_include = tkinter.IntVar(self.sframe)
+        self.do_include.set(0)
+        self.sframe.do_include.enable = ttk.Checkbutton(self.sframe.do_include,
+		text='Include netlist', variable = self.do_include,
+		command=self.apply_include)
+        self.sframe.do_include.value = ttk.Entry(self.sframe.do_include)
+        self.sframe.do_include.value.delete(0, 'end')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_include.value.bind('<Return>', self.apply_include)
+        self.sframe.do_include.value.bind('<Leave>', self.apply_include)
+        self.sframe.do_include.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_include.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+        # alternative method option
+        #--------------------------------------------------------
+
+        self.sframe.do_method = tkinter.Frame(self.sframe)
+        self.sframe.do_method.pack(side = 'top', anchor = 'w', fill = 'x', expand = 'true')
+
+        self.do_method = tkinter.IntVar(self.sframe)
+        self.do_method.set(0)
+        self.sframe.do_method.enable = ttk.Checkbutton(self.sframe.do_method,
+		text='Use alternate method', variable = self.do_method,
+		command=self.apply_method)
+        self.sframe.do_method.value = ttk.Entry(self.sframe.do_method)
+        self.sframe.do_method.value.delete(0, 'end')
+        self.sframe.do_method.value.insert(0, '0')
+        # Return or leave window applies hint, if enabled
+        self.sframe.do_method.value.bind('<Return>', self.apply_method)
+        self.sframe.do_method.value.bind('<Leave>', self.apply_method)
+        self.sframe.do_method.enable.pack(side = 'left', anchor = 'w')
+        self.sframe.do_method.value.pack(side = 'left', anchor = 'w', fill='x', expand='true')
+
+        #--------------------------------------------------------
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 1, sticky = "news")
+
+        self.bbar.apply_button = ttk.Button(self.bbar, text='Apply',
+		command=self.apply_hints, style = 'normal.TButton')
+        self.bbar.apply_button.grid(column=0, row=0, padx = 5)
+
+        self.bbar.close_button = ttk.Button(self.bbar, text='Close',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.close_button.grid(column=1, row=0, padx = 5)
+
+    def apply_reltol(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_reltol = self.do_reltol.get()
+        if have_reltol:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['reltol'] = self.sframe.do_reltol.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('reltol', None)
+            
+    def apply_rshunt(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_rshunt = self.do_rshunt.get()
+        if have_rshunt:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['rshunt'] = self.sframe.do_rshunt.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('rshunt', None)
+            
+    def apply_itl1(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_itl1 = self.do_itl1.get()
+        if have_itl1:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['itl1'] = self.sframe.do_itl1.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('itl1', None)
+            
+
+    def apply_nodeset(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_nodeset = self.do_nodeset.get()
+        if have_nodeset:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['nodeset'] = self.sframe.do_nodeset.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('nodeset', None)
+            
+    def apply_include(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_include = self.do_include.get()
+        if have_include:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['include'] = self.sframe.do_include.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('include', None)
+            
+    def apply_method(self, value = ''):
+        # "value" is passed from binding callback but is not used
+        have_method = self.do_method.get()
+        if have_method:
+            if not 'hints' in self.param:
+                phints = {}
+            else:
+                phints = self.param['hints']
+            phints['method'] = self.sframe.do_method.value.get()
+            self.param['hints'] = phints
+        else:
+            if 'hints' in self.param:
+                self.param['hints'].pop('method', None)
+
+    def apply_hints(self, value = ''):
+        self.apply_reltol(value)
+        self.apply_rshunt(value)
+        self.apply_itl1(value)
+        self.apply_nodeset(value)
+        self.apply_include(value)
+        self.apply_method(value)
+
+        # Update 'Simulate' button in characterization tool with a mark
+        # indicating hints are present.  Also remove the hints record
+        # from the parameter dictionary if it is empty.
+        if 'method' in self.param:
+            if 'hints' in self.param:
+                if self.param['hints'] == {}:
+                    self.param.pop('hints', None)
+                    simtext = 'Simulate'
+                else:
+                    simtext = '\u2022Simulate'
+            else:
+                simtext = 'Simulate'
+            self.simbutton.config(text=simtext)
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def redisplay(self):
+        pass
+
+    def populate(self, param, simbutton = False):
+        if 'display' in param:
+            self.sframe.curparam.config(text=param['display'])
+        else:
+            self.sframe.curparam.config(text=param['method'])
+
+        # Set the current parameter
+        self.param = param
+
+        # Remember the simulate button so we can mark or unmark hints
+        self.simbutton = simbutton
+
+        # Regenerate view and update for the indicated param.
+        if 'hints' in param:
+            phints = param['hints']
+            if 'reltol' in phints:
+                # (1) Reltol adjustment
+                self.do_reltol.set(1)
+                self.sframe.do_reltol.value.delete(0, 'end')
+                self.sframe.do_reltol.value.insert(0, phints['reltol'])
+            else:
+                self.do_reltol.set(0)
+            if 'rshunt' in phints:
+                # (2) Gshunt option
+                self.do_rshunt.set(1)
+                self.sframe.do_rshunt.value.delete(0, 'end')
+                self.sframe.do_rshunt.value.insert(0, phints['rshunt'])
+            else:
+                self.do_rshunt.set(0)
+            if 'nodeset' in phints:
+                # (3) Nodeset
+                self.do_nodeset.set(1)
+                self.sframe.do_nodeset.value.delete(0, 'end')
+                self.sframe.do_nodeset.value.insert(0, phints['nodeset'])
+            else:
+                self.do_nodeset.set(0)
+            if 'itl1' in phints:
+                # (4) Gmin iterations (ITL1)
+                self.do_itl1.set(1)
+                self.sframe.do_itl1.value.delete(0, 'end')
+                self.sframe.do_itl1.value.insert(0, phints['itl1'])
+            else:
+                self.do_itl1.set(0)
+            if 'include' in phints:
+                # (5) Include library (from dropdown list)
+                self.do_include.set(1)
+                self.sframe.do_include.value.delete(0, 'end')
+                self.sframe.do_include.value.insert(0, phints['include'])
+            else:
+                self.do_include.set(0)
+            if 'method' in phints:
+                # (6) Alternative method (where indicated)
+                self.do_method.set(1)
+                self.sframe.do_method.value.delete(0, 'end')
+                self.sframe.do_method.value.insert(0, phints['method'])
+            else:
+                self.do_method.set(0)
+        else:
+            # No hints, so set everything to unchecked
+            self.do_reltol.set(0)
+            self.do_rshunt.set(0)
+            self.do_nodeset.set(0)
+            self.do_itl1.set(0)
+            self.do_include.set(0)
+            self.do_method.set(0)
+
+    def close(self):
+        # pop down settings window
+        self.withdraw()
+
+    def open(self):
+        # pop up settings window
+        self.deiconify()
+        self.lift()
diff --git a/common/symbolbuilder.py b/common/symbolbuilder.py
new file mode 100755
index 0000000..7a38781
--- /dev/null
+++ b/common/symbolbuilder.py
@@ -0,0 +1,103 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+#--------------------------------------------------------
+# Symbol Builder for the Open Galaxy project manager
+#
+#--------------------------------------------------------
+# Written by Tim Edwards
+# efabless, inc.
+# September 21, 2016
+# Version 0.1
+#--------------------------------------------------------
+
+import tkinter
+from tkinter import ttk
+
+class SymbolBuilder(tkinter.Toplevel):
+    """Open Galaxy symbol builder."""
+
+    def __init__(self, parent = None, pinlist = None, fontsize = 11, *args, **kwargs):
+        '''See the __init__ for Tkinter.Toplevel.'''
+        tkinter.Toplevel.__init__(self, parent, *args, **kwargs)
+        self.transient(parent)
+        self.parent = parent
+
+        s = ttk.Style()
+        s.configure('normal.TButton', font=('Helvetica', fontsize), border = 3, relief = 'raised')
+
+        self.title('Open Galaxy Symbol Builder')
+        self.pframe = tkinter.Frame(self)
+        self.pframe.grid(column = 0, row = 0, sticky = "news")
+
+        self.pframe.pindisplay = tkinter.Text(self.pframe)
+        self.pframe.pindisplay.pack(side = 'left', fill = 'y')
+        # Add scrollbar to symbol builder window
+        self.pframe.scrollbar = ttk.Scrollbar(self.pframe)
+        self.pframe.scrollbar.pack(side='right', fill='y')
+        # attach symbol builder window to scrollbar
+        self.pframe.pindisplay.config(yscrollcommand = self.pframe.scrollbar.set)
+        self.pframe.scrollbar.config(command = self.pframe.pindisplay.yview)
+
+        self.bbar = ttk.Frame(self)
+        self.bbar.grid(column = 0, row = 1, sticky = "news")
+        self.bbar.cancel_button = ttk.Button(self.bbar, text='Cancel',
+		command=self.close, style = 'normal.TButton')
+        self.bbar.cancel_button.grid(column=0, row=0, padx = 5)
+
+        self.bbar.okay_button = ttk.Button(self.bbar, text='Okay',
+		command=self.okay, style = 'normal.TButton')
+        self.bbar.okay_button.grid(column=1, row=0, padx = 5)
+
+        typelist = ['input', 'output', 'inout', 'power', 'gnd']
+        self.pinlist = []
+        self.pvar = []
+        self.result = None
+
+        # Each pinlist entry is in the form <pin_name>:<type>
+        # where <type> is one of "input", "output", "inout",
+        # "power", or "gnd".
+
+        n = 0
+        for pin in pinlist:
+            p = pin.split(':')
+            pinname = p[0]
+            pintype = p[1]
+
+            newpvar = tkinter.StringVar(self.pframe.pindisplay)
+            self.pinlist.append(pinname)
+            self.pvar.append(newpvar)
+            newpvar.set(pintype) 
+            ttk.Label(self.pframe.pindisplay, text=pinname,
+			style = 'normal.TButton').grid(row = n,
+			column = 0, padx = 5, sticky = 'nsew')
+            ttk.OptionMenu(self.pframe.pindisplay, newpvar,
+			pintype, *typelist, style = 'blue.TMenubutton').grid(row = n,
+			column = 1, padx = 5, sticky = 'nswe')
+            n += 1
+
+        self.grab_set()
+        self.initial_focus = self
+        self.protocol("WM_DELETE_WINDOW", self.close)
+        self.initial_focus.focus_set()
+        self.wait_window(self)
+
+    def grid_configure(self, padx, pady):
+        pass
+
+    def okay(self):
+        # return the new pin list.
+        pinlist = []
+        n = 0
+        for p in self.pinlist:
+            pinlist.append(p + ':' + str(self.pvar[n].get()))
+            n += 1
+
+        self.withdraw()
+        self.update_idletasks()
+        self.result = pinlist
+        self.close()
+
+    def close(self):
+        # remove symbol builder window
+        self.parent.focus_set()
+        self.destroy()
diff --git a/common/tksimpledialog.py b/common/tksimpledialog.py
index 4734291..f11bbfa 100755
--- a/common/tksimpledialog.py
+++ b/common/tksimpledialog.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/ef/efabless/opengalaxy/venv/bin/python3
 #
 # Dialog class for tkinter
 
diff --git a/common/tooltip.py b/common/tooltip.py
new file mode 100755
index 0000000..4423bb9
--- /dev/null
+++ b/common/tooltip.py
@@ -0,0 +1,158 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+'''Michael Lange <klappnase (at) freakmail (dot) de>
+The ToolTip class provides a flexible tooltip widget for tkinter; it is based on IDLE's ToolTip
+module which unfortunately seems to be broken (at least the version I saw).
+INITIALIZATION OPTIONS:
+anchor :        where the text should be positioned inside the widget, must be on of "n", "s", "e", "w", "nw" and so on;
+                default is "center"
+bd :            borderwidth of the widget; default is 1 (NOTE: don't use "borderwidth" here)
+bg :            background color to use for the widget; default is "lightyellow" (NOTE: don't use "background")
+delay :         time in ms that it takes for the widget to appear on the screen when the mouse pointer has
+                entered the parent widget; default is 1500
+fg :            foreground (i.e. text) color to use; default is "black" (NOTE: don't use "foreground")
+follow_mouse :  if set to 1 the tooltip will follow the mouse pointer instead of being displayed
+                outside of the parent widget; this may be useful if you want to use tooltips for
+                large widgets like listboxes or canvases; default is 0
+font :          font to use for the widget; default is system specific
+justify :       how multiple lines of text will be aligned, must be "left", "right" or "center"; default is "left"
+padx :          extra space added to the left and right within the widget; default is 4
+pady :          extra space above and below the text; default is 2
+relief :        one of "flat", "ridge", "groove", "raised", "sunken" or "solid"; default is "solid"
+state :         must be "normal" or "disabled"; if set to "disabled" the tooltip will not appear; default is "normal"
+text :          the text that is displayed inside the widget
+textvariable :  if set to an instance of tkinter.StringVar() the variable's value will be used as text for the widget
+width :         width of the widget; the default is 0, which means that "wraplength" will be used to limit the widgets width
+wraplength :    limits the number of characters in each line; default is 150
+
+WIDGET METHODS:
+configure(**opts) : change one or more of the widget's options as described above; the changes will take effect the
+                    next time the tooltip shows up; NOTE: follow_mouse cannot be changed after widget initialization
+
+Other widget methods that might be useful if you want to subclass ToolTip:
+enter() :           callback when the mouse pointer enters the parent widget
+leave() :           called when the mouse pointer leaves the parent widget
+motion() :          is called when the mouse pointer moves inside the parent widget if follow_mouse is set to 1 and the
+                    tooltip has shown up to continually update the coordinates of the tooltip window
+coords() :          calculates the screen coordinates of the tooltip window
+create_contents() : creates the contents of the tooltip window (by default a tkinter.Label)
+'''
+# Ideas gleaned from PySol
+
+import tkinter
+
+class ToolTip:
+    def __init__(self, master, text='Your text here', delay=1500, **opts):
+        self.master = master
+        self._opts = {'anchor':'center', 'bd':1, 'bg':'lightyellow', 'delay':delay, 'fg':'black',\
+                      'follow_mouse':0, 'font':None, 'justify':'left', 'padx':4, 'pady':2,\
+                      'relief':'solid', 'state':'normal', 'text':text, 'textvariable':None,\
+                      'width':0, 'wraplength':150}
+        self.configure(**opts)
+        self._tipwindow = None
+        self._id = None
+        self._id1 = self.master.bind("<Enter>", self.enter, '+')
+        self._id2 = self.master.bind("<Leave>", self.leave, '+')
+        self._id3 = self.master.bind("<ButtonPress>", self.leave, '+')
+        self._follow_mouse = 0
+        if self._opts['follow_mouse']:
+            self._id4 = self.master.bind("<Motion>", self.motion, '+')
+            self._follow_mouse = 1
+    
+    def configure(self, **opts):
+        for key in opts:
+            if key in self._opts:
+                self._opts[key] = opts[key]
+            else:
+                KeyError = 'KeyError: Unknown option: "%s"' %key
+                raise KeyError
+    
+    ##----these methods handle the callbacks on "<Enter>", "<Leave>" and "<Motion>"---------------##
+    ##----events on the parent widget; override them if you want to change the widget's behavior--##
+    
+    def enter(self, event=None):
+        self._schedule()
+        
+    def leave(self, event=None):
+        self._unschedule()
+        self._hide()
+    
+    def motion(self, event=None):
+        if self._tipwindow and self._follow_mouse:
+            x, y = self.coords()
+            self._tipwindow.wm_geometry("+%d+%d" % (x, y))
+    
+    ##------the methods that do the work:---------------------------------------------------------##
+    
+    def _schedule(self):
+        self._unschedule()
+        if self._opts['state'] == 'disabled':
+            return
+        self._id = self.master.after(self._opts['delay'], self._show)
+
+    def _unschedule(self):
+        id = self._id
+        self._id = None
+        if id:
+            self.master.after_cancel(id)
+
+    def _show(self):
+        if self._opts['state'] == 'disabled':
+            self._unschedule()
+            return
+        if not self._tipwindow:
+            self._tipwindow = tw = tkinter.Toplevel(self.master)
+            # hide the window until we know the geometry
+            tw.withdraw()
+            tw.wm_overrideredirect(1)
+
+            if tw.tk.call("tk", "windowingsystem") == 'aqua':
+                tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, "help", "none")
+
+            self.create_contents()
+            tw.update_idletasks()
+            x, y = self.coords()
+            tw.wm_geometry("+%d+%d" % (x, y))
+            tw.deiconify()
+            tw.lift()
+    
+    def _hide(self):
+        tw = self._tipwindow
+        self._tipwindow = None
+        if tw:
+            tw.destroy()
+                
+    ##----these methods might be overridden in derived classes:----------------------------------##
+    
+    def coords(self):
+        # The tip window must be completely outside the master widget;
+        # otherwise when the mouse enters the tip window we get
+        # a leave event and it disappears, and then we get an enter
+        # event and it reappears, and so on forever :-(
+        # or we take care that the mouse pointer is always outside the tipwindow :-)
+        tw = self._tipwindow
+        twx, twy = tw.winfo_reqwidth(), tw.winfo_reqheight()
+        w, h = tw.winfo_screenwidth(), tw.winfo_screenheight()
+        # calculate the y coordinate:
+        if self._follow_mouse:
+            y = tw.winfo_pointery() + 20
+            # make sure the tipwindow is never outside the screen:
+            if y + twy > h:
+                y = y - twy - 30
+        else:
+            y = self.master.winfo_rooty() + self.master.winfo_height() + 3
+            if y + twy > h:
+                y = self.master.winfo_rooty() - twy - 3
+        # we can use the same x coord in both cases:
+        x = tw.winfo_pointerx() - twx / 2
+        if x < 0:
+            x = 0
+        elif x + twx > w:
+            x = w - twx
+        return x, y
+
+    def create_contents(self):
+        opts = self._opts.copy()
+        for opt in ('delay', 'follow_mouse', 'state'):
+            del opts[opt]
+        label = tkinter.Label(self._tipwindow, **opts)
+        label.pack()
diff --git a/common/treeviewchoice.py b/common/treeviewchoice.py
new file mode 100755
index 0000000..dba0f26
--- /dev/null
+++ b/common/treeviewchoice.py
@@ -0,0 +1,227 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+# Simple ttk treeview with scrollbar and select button
+
+import os
+import json
+
+import tkinter
+from tkinter import ttk
+import natsort
+#------------------------------------------------------
+# Tree view used as a multi-column list box
+#------------------------------------------------------
+
+class TreeViewChoice(ttk.Frame):
+    def __init__(self, parent, fontsize=11, markDir=False, deferLoad=False, selectVal=None, natSort=False, *args, **kwargs):
+        ttk.Frame.__init__(self, parent, *args, **kwargs)
+        s = ttk.Style()
+        s.configure('normal.TLabel', font=('Helvetica', fontsize))
+        s.configure('title.TLabel', font=('Helvetica', fontsize, 'bold'))
+        s.configure('normal.TButton', font=('Helvetica', fontsize),
+			border = 3, relief = 'raised')
+        s.configure('Treeview.Heading', font=('Helvetica', fontsize, 'bold'))
+        s.configure('Treeview.Column', font=('Helvetica', fontsize))
+        self.markDir = markDir
+        self.initSelected = selectVal
+        self.natSort = natSort
+        self.emptyMessage1 = '(no items)'
+        self.emptyMessage  = '(loading...)' if deferLoad else self.emptyMessage1
+
+    # Last item is a list of 2-item lists, each containing the name of a button
+    # to place along the button bar at the bottom, and a callback function to
+    # run when the button is pressed.
+
+    def populate(self, title, itemlist=[], buttons=[], height=10, columns=[0],
+		versioning=False):
+        self.itemlist = itemlist[:]
+        
+        treeFrame = ttk.Frame(self)
+        treeFrame.pack(side='top', padx=5, pady=5, fill='both', expand='true')
+        
+        scrollBar = ttk.Scrollbar(treeFrame)
+        scrollBar.pack(side='right', fill='y')
+        self.treeView = ttk.Treeview(treeFrame, selectmode='browse', columns=columns, height=height)
+        self.treeView.pack(side='left', fill='both', expand='true')
+        scrollBar.config(command=self.treeView.yview)
+        self.treeView.config(yscrollcommand=scrollBar.set)
+        self.treeView.heading('#0', text=title, anchor='w')
+        buttonFrame = ttk.Frame(self)
+        buttonFrame.pack(side='bottom', fill='x')
+
+        self.treeView.tag_configure('odd',background='white',foreground='black')
+        self.treeView.tag_configure('even',background='gray90',foreground='black')
+        self.treeView.tag_configure('select',background='darkslategray',foreground='white')
+
+        self.func_buttons = []
+        for button in buttons:
+            func = button[2]
+            # Each func_buttons entry is a list of two items;  first is the
+            # button widget, and the second is a boolean that is True if the
+            # button is to be present always, False if the button is only
+            # present when there are entries in the itemlist.
+            self.func_buttons.append([ttk.Button(buttonFrame, text=button[0],
+			style = 'normal.TButton',
+			command = lambda func=func: self.func_callback(func)),
+			button[1]])
+
+        self.selectcallback = None
+        self.lastselected = None
+        self.lasttag = None
+        self.treeView.bind('<<TreeviewSelect>>', self.retag)
+        self.repopulate(itemlist, versioning)
+
+    def get_button(self, index):
+        if index >= 0 and index < len(self.func_buttons):
+            return self.func_buttons[index][0]
+        else:
+            return None
+
+    def retag(self, value):
+        treeview = value.widget
+        try:
+            selection = treeview.selection()[0]
+            oldtag = self.treeView.item(selection, 'tag')[0]
+        except IndexError:
+            # No items in view;  can't select the "(no items)" line.
+            return
+
+        self.treeView.item(selection, tag='selected')
+        if self.lastselected:
+            try:
+                self.treeView.item(self.lastselected, tag=self.lasttag)
+            except:
+                # Last selected item got deleted.  Ignore this.
+                pass
+        if self.selectcallback:
+            self.selectcallback(value)
+        self.lastselected = selection
+        self.lasttag = oldtag
+
+    def repopulate(self, itemlist=[], versioning=False):
+
+        # Remove all children of treeview
+        self.treeView.delete(*self.treeView.get_children())
+
+        if self.natSort:
+            self.itemlist = natsort.natsorted( itemlist,
+                                               alg=natsort.ns.INT |
+                                               natsort.ns.UNSIGNED |
+                                               natsort.ns.IGNORECASE )
+        else:
+            self.itemlist = itemlist[:]
+            self.itemlist.sort()
+
+        mode = 'even'
+        for item in self.itemlist:
+            # Special handling of JSON files.  The following reads a JSON file and
+            # finds key 'ip-name' in dictionary 'data-sheet', if such exists.  If
+            # not, it looks for key 'project' in the top level.  Failing that, it
+            # lists the name of the JSON file (which is probably an ungainly hash
+            # name).
+            
+            fileext = os.path.splitext(item)
+            if fileext[1] == '.json':
+                # Read contents of JSON file
+                with open(item, 'r') as f:
+                    try:
+                        datatop = json.load(f)
+                    except json.decoder.JSONDecodeError:
+                        name = os.path.split(item)[1]
+                    else:
+                        name = []
+                        if 'data-sheet' in datatop:
+                            dsheet = datatop['data-sheet']
+                            if 'ip-name' in dsheet:
+                                name = dsheet['ip-name']
+                        if not name and 'project' in datatop:
+                            name = datatop['project']
+                        if not name:
+                            name = os.path.split(item)[1]
+            elif versioning == True:
+                # If versioning is true, then the last path component is the
+                # version number, and the penultimate path component is the
+                # name.
+                version = os.path.split(item)[1]
+                name = os.path.split(os.path.split(item)[0])[1] + ' (v' + version + ')'
+            else:
+                name = os.path.split(item)[1]
+
+            # Watch for duplicate items!
+            n = 0
+            origname = name
+            while self.treeView.exists(name):
+                n += 1
+                name = origname + '(' + str(n) + ')'
+            mode = 'even' if mode == 'odd' else 'odd'
+            # Note: iid value with spaces in it is a bad idea.
+            if ' ' in name:
+                name = name.replace(' ', '_')
+
+            # optionally: Mark directories with trailing slash
+            if self.markDir and os.path.isdir(item):
+                origname += "/"
+                
+            self.treeView.insert('', 'end', text=origname, iid=name, value=item, tag=mode)
+        if self.initSelected and self.treeView.exists(self.initSelected):
+            self.setselect(self.initSelected)
+            self.initSelected = None
+
+        for button in self.func_buttons:
+            button[0].pack_forget()
+
+        if len(self.itemlist) == 0:
+            self.treeView.insert('', 'end', text=self.emptyMessage)
+            self.emptyMessage = self.emptyMessage1  # discard optional special 1st loading... message
+            for button in self.func_buttons:
+                if button[1]:
+                    button[0].pack(side='left', padx = 5)
+        else:
+            for button in self.func_buttons:
+                button[0].pack(side='left', padx = 5)
+
+    # Return values from the treeview
+    def getvaluelist(self):
+        valuelist = []
+        itemlist = self.treeView.get_children()
+        for item in itemlist:
+            value = self.treeView.item(item, 'values')
+            valuelist.append(value)
+        return valuelist
+
+    # Return items from the treeview
+    def getlist(self):
+        return self.treeView.get_children()
+
+    # This is a bit of a hack way to populate a second column,
+    # but it works.  It only works for one additional column,
+    # though, or else tuples will have to be generated differently.
+
+    def populate2(self, title, itemlist=[], valuelist=[]):
+        # Populate another column
+        self.treeView.heading(1, text = title)
+        self.treeView.column(1, anchor='center')
+        n = 0
+        for item in valuelist:
+            child = os.path.split(itemlist[n])[1]
+            # Get the item at this index
+            oldvalue = self.treeView.item(child, 'values')
+            newvalue = (oldvalue, item)
+            self.treeView.item(child, values = newvalue)
+            n += 1
+
+    def func_callback(self, callback, event=None):
+        callback(self.treeView.item(self.treeView.selection()))
+
+    def bindselect(self, callback):
+        self.selectcallback = callback
+
+    def setselect(self, value):
+        self.treeView.selection_set(value)
+
+    def selected(self):
+        value = self.treeView.item(self.treeView.selection())
+        if value['values']:
+            return value
+        else:
+            return None
diff --git a/common/treeviewsplit.py b/common/treeviewsplit.py
new file mode 100755
index 0000000..0435d7b
--- /dev/null
+++ b/common/treeviewsplit.py
@@ -0,0 +1,649 @@
+#!/ef/efabless/opengalaxy/venv/bin/python3
+#
+# Simple ttk treeview with split view, scrollbar, and
+# row of callback buttons
+
+import os
+import re
+import itertools
+
+import tkinter
+from tkinter import ttk
+
+#------------------------------------------------------
+# Tree view used as a multi-column list box
+#------------------------------------------------------
+
+class TreeViewSplit(ttk.Frame):
+    def __init__(self, parent, fontsize=11, *args, **kwargs):
+        ttk.Frame.__init__(self, parent, *args, **kwargs)
+        s = ttk.Style()
+        s.configure('normal.TLabel', font=('Helvetica', fontsize))
+        s.configure('title.TLabel', font=('Helvetica', fontsize, 'bold'))
+        s.configure('normal.TButton', font=('Helvetica', fontsize),
+			border = 3, relief = 'raised')
+        s.configure('Treeview.Heading', font=('Helvetica', fontsize, 'bold'))
+        s.configure('Treeview.Column', font=('Helvetica', fontsize))
+        self.fontsize = fontsize
+
+    # Last item is a list of 2-item lists, each containing the name of a button
+    # to place along the button bar at the bottom, and a callback function to
+    # run when the button is pressed.
+
+    def populate(self, title1="", item1list=[], title2="", item2list=[], buttons=[], height=10):
+        self.item1list = item1list[:]
+        self.item2list = item2list[:]
+        columns = [0, 1]
+        
+        treeFrame = ttk.Frame(self)
+        treeFrame.pack(side='top', padx=5, pady=5, fill='both', expand='true')
+        
+        scrollBar = ttk.Scrollbar(treeFrame)
+        scrollBar.pack(side='right', fill='y')
+        self.treeView = ttk.Treeview(treeFrame, selectmode='browse', columns=columns, height=height)
+        self.treeView.pack(side='left', fill='both', expand='true')
+        scrollBar.config(command=self.treeView.yview)
+        self.treeView.config(yscrollcommand=scrollBar.set)
+        self.treeView.column('#0', width=120, stretch='false')
+        self.treeView.heading(0, text=title1, anchor='w')
+        self.treeView.heading(1, text=title2, anchor='w')
+        buttonFrame = ttk.Frame(self)
+        buttonFrame.pack(side='bottom', fill='x')
+
+        self.treeView.tag_configure('select',background='darkslategray',foreground='white')
+
+        # Test type tags
+        self.treeView.tag_configure('error', font=('Helvetica', self.fontsize - 1), foreground = 'red')
+        self.treeView.tag_configure('clean', font=('Helvetica', self.fontsize - 1), foreground = 'green3')
+        self.treeView.tag_configure('normal', font=('Helvetica', self.fontsize - 1), foreground = 'black')
+        self.treeView.tag_configure('prep', font=('Helvetica', self.fontsize, 'bold italic'),
+                        foreground = 'black', anchor = 'center')
+        self.treeView.tag_configure('header1', font=('Helvetica', self.fontsize, 'bold italic'),
+                        foreground = 'brown', anchor = 'center')
+        self.treeView.tag_configure('header2', font=('Helvetica', self.fontsize - 1, 'bold'),
+                        foreground = 'blue', anchor = 'center')
+        self.treeView.tag_configure('header3', font=('Helvetica', self.fontsize - 1, 'bold'),
+                        foreground = 'green2', anchor = 'center')
+        self.treeView.tag_configure('header4', font=('Helvetica', self.fontsize - 1),
+                        foreground = 'purple', anchor = 'center')
+
+
+        self.func_buttons = []
+        for button in buttons:
+            func = button[2]
+            # Each func_buttons entry is a list of two items;  first is the
+            # button widget, and the second is a boolean that is True if the
+            # button is to be present always, False if the button is only
+            # present when there are entries in the itemlists.
+            self.func_buttons.append([ttk.Button(buttonFrame, text=button[0],
+			style = 'normal.TButton',
+			command = lambda func=func: self.func_callback(func)),
+			button[1]])
+
+        self.selectcallback = None
+        self.lastselected = None
+        self.lasttag = None
+        self.repopulate(item1list, item2list)
+
+    def get_button(self, index):
+        if index >= 0 and index < len(self.func_buttons):
+            return self.func_buttons[index][0]
+        else:
+            return None
+
+    def set_title(self, title):
+        self.treeView.heading('#0', text=title, anchor='w')
+
+    def repopulate(self, item1list=[], item2list=[]):
+
+        # Remove all children of treeview
+        self.treeView.delete(*self.treeView.get_children())
+
+        self.item1list = item1list[:]
+        self.item2list = item2list[:]
+        lines = max(len(self.item1list), len(self.item2list))
+
+        # Parse the information coming from comp.out.  This is preferably
+        # handled from inside netgen, but that requires development in netgen.
+        # Note:  A top-level group is denoted by an empty string.
+
+        nested = ['']
+        if lines > 0:
+            # print("Create item ID 0 parent = ''")
+            self.treeView.insert(nested[-1], 'end', text='-', iid='0',
+			value=['Initialize', 'Initialize'], tags=['prep'])
+            nested.append('0')
+            tagstyle = 'header1'
+
+        emptyrec = re.compile('^[\t ]*$')
+        subrex = re.compile('Subcircuit summary')
+        cktrex = re.compile('Circuit[\t ]+[12]:[\t ]+([^ \t]+)')
+        netrex = re.compile('NET mismatches')
+        devrex = re.compile('DEVICE mismatches')
+        seprex = re.compile('-----')
+        sumrex = re.compile('Netlists ')
+        matchrex = re.compile('.*\*\*Mismatch\*\*')
+        incircuit = False
+        watchgroup = False
+        groupnum = 0
+
+        for item1, item2, index in zip(self.item1list, self.item2list, range(lines)):
+            # Remove blank lines from the display
+            lmatch = emptyrec.match(item1)
+            if lmatch:
+                lmatch = emptyrec.match(item2)
+                if lmatch:
+                    continue
+            index = str(index + 1)
+            # Parse text to determine how to structure and display it.
+            tagstyle = 'normal'
+            nextnest = None
+            lmatch = subrex.match(item1)
+            if lmatch:
+                nested = ['']		# pop back to topmost level
+                nextnest = index
+                tagstyle = 'header1'
+                incircuit = False
+                watchgroup = False
+                groupnum = 0
+                item1 = 'Layout compare'
+                item2 = 'Schematic compare'
+                cname1 = 'Layout'		# Placeholder
+                cname2 = 'Schematic'		# Placeholder
+            else:
+                lmatch = cktrex.match(item1)
+                if lmatch and not incircuit:
+                    # Pick up circuit names and replace them in the title, then use them
+                    # for all following titles.
+                    cname1 = lmatch.group(1)
+                    lmatch = cktrex.match(item2)
+                    cname2 = lmatch.group(1)
+                    print("Circuit names " + cname1 + " " + cname2)
+                    # Rewrite title
+                    cktitem = self.treeView.item(nested[-1], values=[cname1 + ' compare',
+				cname2 + ' compare'])
+                    nextnest = index
+                    tagstyle = 'header2'
+                    incircuit = True
+                    item1 = cname1 + ' Summary'
+                    item2 = cname2 + ' Summary'
+                elif lmatch:
+                    continue
+                else:
+                    lmatch = netrex.match(item1)
+                    if lmatch:
+                        if watchgroup:
+                            nested = nested[0:-1]
+                        nested = nested[0:-1]
+                        nextnest = index
+                        tagstyle = 'header2'
+                        groupnum = 1
+                        watchgroup = True
+                        item1 = cname1 + ' Net mismatches'
+                        item2 = cname2 + ' Net mismatches'
+                    else:
+                        lmatch = devrex.match(item1)
+                        if lmatch:
+                            if watchgroup:
+                                nested = nested[0:-1]
+                            nested = nested[0:-1]
+                            nextnest = index
+                            tagstyle = 'header2'
+                            groupnum = 1
+                            watchgroup = True
+                            item1 = cname1 + ' Device mismatches'
+                            item2 = cname2 + ' Device mismatches'
+                        else:
+                            lmatch = seprex.match(item1)
+                            if lmatch:
+                                if watchgroup:
+                                    tagstyle = 'header3'
+                                    item1 = 'Group ' + str(groupnum)
+                                    item2 = 'Group ' + str(groupnum)
+                                    if groupnum > 1:
+                                        nested = nested[0:-1]
+                                    groupnum += 1 
+                                    nextnest = index
+                                    watchgroup = False
+                                else:
+                                    if groupnum > 0:
+                                        watchgroup = True
+                                    continue
+                            else:
+                                lmatch = sumrex.match(item1)
+                                if lmatch:
+                                    if watchgroup:
+                                        nested = nested[0:-1]
+                                    nested = nested[0:-1]
+                                    watchgroup = False
+                                    tagstyle = 'header2'
+                                    groupnum = 0
+
+            lmatch1 = matchrex.match(item1)
+            lmatch2 = matchrex.match(item2)
+            if lmatch1 or lmatch2:
+                tagstyle='error'
+            
+            # print("Create item ID " + str(index) + " parent = " + str(nested[-1]))
+            self.treeView.insert(nested[-1], 'end', text=index, iid=index, value=[item1, item2],
+			tags=[tagstyle])
+
+            if nextnest:
+                nested.append(nextnest)
+
+        for button in self.func_buttons:
+            button[0].pack_forget()
+
+        if lines == 0:
+            self.treeView.insert('', 'end', text='-', value=['(no items)', '(no items)'])
+            for button in self.func_buttons:
+                if button[1]:
+                    button[0].pack(side='left', padx = 5)
+        else:
+            for button in self.func_buttons:
+                button[0].pack(side='left', padx = 5)
+
+    # Special routine to pull in the JSON file data produced by netgen-1.5.72
+    def json_repopulate(self, lvsdata):
+
+        # Remove all children of treeview
+        self.treeView.delete(*self.treeView.get_children())
+
+        # Parse the information coming from comp.out.  This is preferably
+        # handled from inside netgen, but that requires development in netgen.
+        # Note:  A top-level group is denoted by an empty string.
+
+        index = 0
+        errtotal = {}
+        errtotal['net'] = 0
+        errtotal['netmatch'] = 0
+        errtotal['device'] = 0
+        errtotal['devmatch'] = 0
+        errtotal['property'] = 0
+        errtotal['pin'] = 0
+        ncells = len(lvsdata)
+        for c in range(0, ncells):
+            cellrec = lvsdata[c]
+            if c == ncells - 1:
+                topcell = True
+            else:
+                topcell = False
+
+            errcell = {}
+            errcell['net'] = 0
+            errcell['netmatch'] = 0
+            errcell['device'] = 0
+            errcell['devmatch'] = 0
+            errcell['property'] = 0
+            errcell['pin'] = 0;
+
+	    # cellrec is a dictionary.  Parse the cell summary, then failing nets,
+	    # devices, and properties, and finally pins.
+
+            if 'name' in cellrec:
+                names = cellrec['name']
+                cname1 = names[0]
+                cname2 = names[1]
+
+                item1 = cname1
+                item2 = cname2
+                tagstyle = 'header1'
+                index += 1
+                nest0 = index
+                self.treeView.insert('', 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+            else:
+                # Some cells have pin comparison but are missing names (needs to be
+                # fixed in netgen.  Regardless, if there's no name, then ignore.
+                continue
+
+            if 'devices' in cellrec or 'nets' in cellrec:
+                item1 = cname1 + " Summary"
+                item2 = cname2 + " Summary"
+                tagstyle = 'header2'
+                index += 1
+                nest1 = index
+                self.treeView.insert(nest0, 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+            if 'devices' in cellrec:
+                item1 = cname1 + " Devices"
+                item2 = cname2 + " Devices"
+                tagstyle = 'header3'
+                index += 1
+                nest2 = index
+                self.treeView.insert(nest1, 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+                devices = cellrec['devices']
+                devlist = [val for pair in zip(devices[0], devices[1]) for val in pair]
+                devpair = list(devlist[p:p + 2] for p in range(0, len(devlist), 2))
+                for dev in devpair:
+                    c1dev = dev[0]
+                    c2dev = dev[1]
+
+                    item1 = c1dev[0] + "(" + str(c1dev[1]) + ")"
+                    item2 = c2dev[0] + "(" + str(c2dev[1]) + ")"
+
+                    diffdevs = abs(c1dev[1] - c2dev[1])
+                    if diffdevs == 0:
+                        tagstyle = 'normal'
+                    else:
+                        tagstyle = 'error'
+                        errcell['device'] += diffdevs
+                        if topcell:
+                            errtotal['device'] += diffdevs
+                    index += 1
+                    nest2 = index
+                    self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+            if 'nets' in cellrec:
+                item1 = cname1 + " Nets"
+                item2 = cname2 + " Nets"
+                tagstyle = 'header3'
+                index += 1
+                nest2 = index
+                self.treeView.insert(nest1, 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+                nets = cellrec['nets']
+
+                item1 = nets[0]
+                item2 = nets[1]
+                diffnets = abs(nets[0] - nets[1])
+                if diffnets == 0:
+                    tagstyle = 'normal'
+                else:
+                    tagstyle = 'error'
+                    errcell['net'] = diffnets
+                    if topcell:
+                        errtotal['net'] += diffnets
+                index += 1
+                nest2 = index
+                self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+            if 'badnets' in cellrec:
+                badnets = cellrec['badnets']
+
+                if len(badnets) > 0:
+                    item1 = cname1 + " Net Mismatches"
+                    item2 = cname2 + " Net Mismatches"
+                    tagstyle = 'header2'
+                    index += 1
+                    nest1 = index
+                    self.treeView.insert(nest0, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+                groupnum = 0
+                for group in badnets:
+                    groupc1 = group[0]
+                    groupc2 = group[1]
+                    nnets = len(groupc1)
+
+                    groupnum += 1
+                    tagstyle = 'header3'
+                    index += 1
+                    nest2 = index
+                    item1 = "Group " + str(groupnum) + ' (' + str(nnets) + ' nets)'
+                    self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item1], tags=[tagstyle])
+
+                    tagstyle = 'error'
+                    errcell['netmatch'] += nnets
+                    if topcell:
+                        errtotal['netmatch'] += nnets
+
+                    for netnum in range(0, nnets):
+                        if netnum > 0:
+                            item1 = ""
+                            index += 1
+                            nest3 = index
+                            self.treeView.insert(nest2, 'end', text=index, iid=index,
+					value=[item1, item1], tags=[tagstyle])
+
+                        net1 = groupc1[netnum]
+                        net2 = groupc2[netnum]
+                        tagstyle = 'header4'
+                        item1 = net1[0]
+                        item2 = net2[0]
+                        index += 1
+                        nest3 = index
+                        self.treeView.insert(nest2, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+                        # Pad shorter device list to the length of the longer one
+                        netdevs = list(itertools.zip_longest(net1[1], net2[1]))
+                        for devpair in netdevs:
+                            devc1 = devpair[0]
+                            devc2 = devpair[1]
+                            tagstyle = 'normal'
+                            if devc1 and devc1[0] != "":
+                                item1 = devc1[0] + '/' + devc1[1] + ' = ' + str(devc1[2])
+                            else:
+                                item1 = ""
+                            if devc2 and devc2[0] != "":
+                                item2 = devc2[0] + '/' + devc2[1] + ' = ' + str(devc2[2])
+                            else:
+                                item2 = ""
+                            index += 1
+                            nest3 = index
+                            self.treeView.insert(nest2, 'end', text=index, iid=index,
+					value=[item1, item2], tags=[tagstyle])
+
+            if 'badelements' in cellrec:
+                badelements = cellrec['badelements']
+
+                if len(badelements) > 0:
+                    item1 = cname1 + " Device Mismatches"
+                    item2 = cname2 + " Device Mismatches"
+                    tagstyle = 'header2'
+                    index += 1
+                    nest1 = index
+                    self.treeView.insert(nest0, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+                groupnum = 0
+                for group in badelements:
+                    groupc1 = group[0]
+                    groupc2 = group[1]
+                    ndevs = len(groupc1)
+
+                    groupnum += 1
+                    tagstyle = 'header3'
+                    index += 1
+                    nest2 = index
+                    item1 = "Group " + str(groupnum) + ' (' + str(ndevs) + ' devices)'
+                    self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item1], tags=[tagstyle])
+
+                    tagstyle = 'error'
+                    errcell['devmatch'] += ndevs
+                    if topcell:
+                        errtotal['devmatch'] += ndevs
+
+                    for elemnum in range(0, ndevs):
+                        if elemnum > 0:
+                            item1 = ""
+                            index += 1
+                            nest3 = index
+                            self.treeView.insert(nest2, 'end', text=index, iid=index,
+					value=[item1, item1], tags=[tagstyle])
+
+                        elem1 = groupc1[elemnum]
+                        elem2 = groupc2[elemnum]
+                        tagstyle = 'header4'
+                        item1 = elem1[0]
+                        item2 = elem2[0]
+                        index += 1
+                        nest3 = index
+                        self.treeView.insert(nest2, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+                        # Pad shorter pin list to the length of the longer one
+                        elempins = list(itertools.zip_longest(elem1[1], elem2[1]))
+                        for pinpair in elempins:
+                            pinc1 = pinpair[0]
+                            pinc2 = pinpair[1]
+                            tagstyle = 'normal'
+                            if pinc1 and pinc1[0] != "":
+                                item1 = pinc1[0] + ' = ' + str(pinc1[1])
+                            else:
+                                item1 = ""
+                            if pinc2 and pinc2[0] != "":
+                                item2 = pinc2[0] + ' = ' + str(pinc2[1])
+                            else:
+                                item2 = ""
+                            index += 1
+                            nest3 = index
+                            self.treeView.insert(nest2, 'end', text=index, iid=index,
+					value=[item1, item2], tags=[tagstyle])
+
+            if 'properties' in cellrec:
+                properties = cellrec['properties']
+                numproperr = len(properties)
+                if numproperr > 0:
+                    item1 = cname1 + " Properties"
+                    item2 = cname2 + " Properties"
+                    tagstyle = 'header2'
+                    index += 1
+                    nest1 = index
+                    self.treeView.insert(nest0, 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+                    errcell['property'] = numproperr
+                    errtotal['property'] += numproperr
+
+                for prop in properties:
+
+                    if prop != properties[0]:
+                        item1 = ""
+                        index += 1
+                        nest2 = index
+                        self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item1], tags=[tagstyle])
+
+                    propc1 = prop[0]
+                    propc2 = prop[1]
+
+                    tagstyle = 'header3'
+                    item1 = propc1[0]
+                    item2 = propc2[0]
+                    index += 1
+                    nest2 = index
+                    self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+                   # Pad shorter property list to the length of the longer one
+                    elemprops = list(itertools.zip_longest(propc1[1], propc2[1]))
+                    for proppair in elemprops:
+                        perrc1 = proppair[0]
+                        perrc2 = proppair[1]
+                        tagstyle = 'normal'
+                        if perrc1 and perrc1[0] != "":
+                            item1 = perrc1[0] + ' = ' + str(perrc1[1])
+                        else:
+                            item1 = ""
+                        if perrc2 and perrc2[0] != "":
+                            item2 = perrc2[0] + ' = ' + str(perrc2[1])
+                        else:
+                            item2 = ""
+                        index += 1
+                        nest2 = index
+                        self.treeView.insert(nest1, 'end', text=index, iid=index,
+					value=[item1, item2], tags=[tagstyle])
+
+            if 'pins' in cellrec:
+                item1 = cname1 + " Pins"
+                item2 = cname2 + " Pins"
+                tagstyle = 'header2'
+                index += 1
+                nest1 = index
+                self.treeView.insert(nest0, 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+                pins = cellrec['pins']
+                pinlist = [val for pair in zip(pins[0], pins[1]) for val in pair]
+                pinpair = list(pinlist[p:p + 2] for p in range(0, len(pinlist), 2))
+                for pin in pinpair:
+                    item1 = re.sub('!$', '', pin[0].lower())
+                    item2 = re.sub('!$', '', pin[1].lower())
+                    if item1 == item2:
+                        tagstyle = 'header4'
+                    else:
+                        tagstyle = 'error'
+                        errcell['pin'] += 1
+                        if topcell:
+                            errtotal['pin'] += 1
+                    index += 1
+                    nest2 = index
+                    self.treeView.insert(nest1, 'end', text=index, iid=index,
+				value=[item1, item2], tags=[tagstyle])
+
+            allcellerror = errcell['net'] + errcell['device'] + errcell['property'] + errcell['pin'] + errcell['netmatch'] + errcell['devmatch']
+            if allcellerror > 0:
+                item1 = 'Errors:  Net = ' + str(errcell['net']) + ', Device = ' + str(errcell['device']) + ', Property = ' + str(errcell['property']) + ', Pin = ' + str(errcell['pin']) + ', Net match = ' + str(errcell['netmatch']) + ', Device match = ' + str(errcell['devmatch'])
+                tagstyle = 'error'
+            else:
+                item1 = 'LVS Clean'
+                tagstyle = 'clean'
+
+            item2 = ""
+            index += 1
+            nest0 = index
+            self.treeView.insert('', 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+        item1 = "Final LVS result:"
+        item2 = ""
+        tagstyle = 'header1'
+        index += 1
+        nest0 = index
+        self.treeView.insert('', 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+        allerror = errtotal['net'] + errtotal['device'] + errtotal['property'] + errtotal['pin'] + errtotal['netmatch'] + errtotal['devmatch']
+        if allerror > 0:
+            item1 = 'Errors:  Net = ' + str(errtotal['net']) + ', Device = ' + str(errtotal['device']) + ', Property = ' + str(errtotal['property']) + ', Pin = ' + str(errtotal['pin']) + ', Net match = ' + str(errtotal['netmatch']) + ', Device match = ' + str(errtotal['devmatch'])
+            tagstyle = 'error'
+        else:
+            item1 = 'LVS Clean'
+            tagstyle = 'clean'
+
+        item2 = ""
+        index += 1
+        nest0 = index
+        self.treeView.insert('', 'end', text=index, iid=index, value=[item1, item2],
+				tags=[tagstyle])
+
+        for button in self.func_buttons:
+            button[0].pack_forget()
+
+        if index == 0:
+            self.treeView.insert('', 'end', text='-', value=['(no items)', '(no items)'])
+            for button in self.func_buttons:
+                if button[1]:
+                    button[0].pack(side='left', padx = 5)
+        else:
+            for button in self.func_buttons:
+                button[0].pack(side='left', padx = 5)
+
+    # Return values from the treeview
+    def getlist(self):
+        return self.treeView.get_children()
+
+    def func_callback(self, callback, event=None):
+        callback(self.treeView.item(self.treeView.selection()))
+
+    def bindselect(self, callback):
+        self.selectcallback = callback
+
+    def setselect(self, value):
+        self.treeView.selection_set(value)
+
+    def selected(self):
+        value = self.treeView.item(self.treeView.selection())
+        if value['values']:
+            return value
+        else:
+            return None