diff --git a/LICENSE b/LICENSE
index f899fe5..9295a24 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2018 by the Optima Consortium for Decision Science (info@ocds.co)
+Copyright (c) 2020 by the Sciris Development Team
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/sciris/sc_dataframe.py b/sciris/sc_dataframe.py
index 7ff54dd..b12501b 100644
--- a/sciris/sc_dataframe.py
+++ b/sciris/sc_dataframe.py
@@ -17,7 +17,7 @@ __all__ = ['dataframe']
 class dataframe(object):
     '''
     A simple data frame, based on simple lists, for simply storing simple data.
-    
+
     Example usage:
         a = dataframe(cols=['x','y'],data=[[1238,2],[384,5],[666,7]]) # Create data frame
         print(a)['x'] # Print out a column
@@ -37,9 +37,9 @@ class dataframe(object):
         a.getrow(1) # Return the row starting with value '1'
         a.rmrow(); print(a) # Remove last row
         a.rmrow(1238); print(a) # Remove the row starting with element '3'
-    
+
     Works for both numeric and non-numeric data.
-    
+
     Version: 2019sep24
     '''
 
@@ -48,17 +48,17 @@ class dataframe(object):
         self.data = None
         self.make(cols=cols, data=data, nrows=nrows)
         return None
-    
-    
+
+
     def __repr__(self, spacing=2):
         ''' spacing = space between columns '''
         if not self.cols: # No keys, give up
             return '<empty dataframe>'
-        
+
         else: # Go for it
             outputlist = odict()
             outputformats = odict()
-            
+
             # Gather data
             nrows = self.nrows
             for c,col in enumerate(self.cols):
@@ -70,33 +70,33 @@ class dataframe(object):
                         maxlen = max(maxlen, len(output))
                         outputlist[col].append(output)
                 outputformats[col] = '%'+'%i'%(maxlen+spacing)+'s'
-            
+
             ndigits = (np.floor(np.log10(max(1,nrows)))+1) # Don't allow 0 rows
             indformat = '%%%is' % ndigits # Choose the right number of digits to print
-            
+
             # Assemble output
             output = indformat % '' # Empty column for index
             for col in self.cols: # Print out header
                 output += outputformats[col] % col
             output += '\n'
-            
+
             for ind in range(nrows): # Loop over rows to print out
                 output += indformat % ut.flexstr(ind)
                 for col in self.cols: # Print out data
                     output += outputformats[col] % outputlist[col][ind]
                 if ind<nrows-1: output += '\n'
-            
+
             return output
-    
-    
+
+
     def _val2row(self, value=None):
         ''' Convert a list, array, or dictionary to the right format for appending to a dataframe '''
         if isinstance(value, dict):
             output = np.zeros(self.ncols, dtype=object)
             for c,col in enumerate(self.cols):
-                try: 
+                try:
                     output[c] = value[col]
-                except: 
+                except:
                     errormsg = 'Entry for column %s not found; keys you supplied are: %s' % (col, value.keys())
                     raise Exception(errormsg)
             output = np.array(output, dtype=object)
@@ -109,8 +109,8 @@ class dataframe(object):
                 errormsg = 'Row has wrong length (%s supplied, %s expected)' % (len(value), self.ncols)
                 raise Exception(errormsg)
         return output
-    
-    
+
+
     def _sanitizecol(self, col, die=True):
         ''' Take None or a string and return the index of the column '''
         if col is None:
@@ -120,7 +120,7 @@ class dataframe(object):
                 output = self.cols.index(col) # Convert to index
             except Exception as E:
                 errormsg = 'Could not get index of column "%s"; columns are:\n%s\n%s' % (col, '\n'.join(self.cols), str(E))
-                if die: 
+                if die:
                     raise Exception(errormsg)
                 else:
                     print(errormsg)
@@ -135,8 +135,8 @@ class dataframe(object):
                 print(errormsg)
                 output = None
         return output
-    
-    
+
+
     @staticmethod
     def _cast(arr):
         ''' Attempt to cast an array to different data types, to avoid having completely numeric arrays of object type '''
@@ -147,8 +147,8 @@ class dataframe(object):
             except: # If anything goes wrong, do nothing
                 pass
         return output
-        
-    
+
+
     def __getitem__(self, key=None, die=True, cast=True):
         ''' Simple method for returning; see self.get() for a version based on col and row '''
         if ut.isstring(key): # e.g. df['a']
@@ -196,8 +196,8 @@ class dataframe(object):
                 print(errormsg)
                 output = None
         return output
-        
-    
+
+
     def __setitem__(self, key, value=None):
         if value is None:
             value = np.zeros(self.nrows, dtype=object)
@@ -224,7 +224,7 @@ class dataframe(object):
                 self.data = np.hstack((self.data, np.array(val_arr, dtype=object)))
         elif ut.isnumber(key):
             value = self._val2row(value) # Make sure it's in the correct format
-            if len(value) != self.ncols: 
+            if len(value) != self.ncols:
                 errormsg = 'Vector has incorrect length (%i vs. %i)' % (len(value), self.ncols)
                 raise Exception(errormsg)
             rowindex = int(key)
@@ -246,8 +246,8 @@ class dataframe(object):
                 errormsg = 'Could not insert element (%s,%s) in dataframe of shape %s: %s' % (key[0], key[1], self.data.shape, str(E))
                 raise Exception(errormsg)
         return None
-    
-    
+
+
     def __copy__(self, *args, **kwargs):
         ''' Warning, there is a bug in Numpy such that deepcopy(array) != array if array contains lists '''
         newcols = ut.dcp(self.cols)
@@ -255,26 +255,26 @@ class dataframe(object):
         copied = ut.dcp(listdata)
         newdata = np.array(copied, dtype=object)
         return dataframe(cols=newcols, data=newdata)
-    
-    
+
+
     def __deepcopy__(self, *args, **kwargs):
         ''' As above '''
         return self.__copy__(*args, **kwargs)
-    
-    
+
+
     def __eq__(self, other):
         ''' If columns and data match, the dataframes match '''
         if not isinstance(other, dataframe): return False
         if  self.cols != other.cols:         return False
         if (self.data != other.data).any():  return False
         return True
-    
-    
+
+
     def __len__(self):
         ''' Return the number of rows in the dataframe '''
         return self.nrows
-    
-    
+
+
     def make(self, cols=None, data=None, nrows=None):
         '''
         Creates a dataframe from the supplied input data. Usage examples:
@@ -285,7 +285,7 @@ class dataframe(object):
             df = sc.dataframe(['a','b','c'], [[1,2,3],[4,5,6]])
             df = sc.dataframe(cols=['a','b','c'], data=[[1,2,3],[4,5,6]])
         '''
-        
+
         # Handle columns
         if nrows is None:
             nrows = 0
@@ -295,18 +295,23 @@ class dataframe(object):
         elif cols is None and data is not None: # Shouldn't happen, but if it does, swap inputs
             cols = data
             data = None
-        
+
         if not ut.isstring(pd): # This is done because if pandas import fails, it replaces it with a string
             if isinstance(cols, pd.DataFrame): # It's actually a Pandas dataframe
                 self.pandas(df=cols)
                 return None # We're done
         else:
             print(pd)
-        
-        if not ut.checktype(cols, 'listlike'):
+
+        # A dictionary is supplied: assume keys are columns, and the rest is the data
+        if isinstance(cols, dict):
+            data = [col for col in cols.values()]
+            cols = list(cols.keys())
+
+        elif not ut.checktype(cols, 'listlike'):
             errormsg = 'Inputs to dataframe must be list, tuple, or array, not %s' % (type(cols))
             raise Exception(errormsg)
-        
+
         # Handle data
         if data is None:
             if np.ndim(cols)==2 and np.shape(cols)[0]>1: # It's a 2D array with more than one row: treat first as header
@@ -334,13 +339,13 @@ class dataframe(object):
         else:
             errormsg = 'Number of columns (%s) does not match array shape (%s)' % (len(cols), data.shape)
             raise Exception(errormsg)
-        
-        # Store it        
+
+        # Store it
         self.cols = list(cols)
         self.data = data
         return None
-    
-    
+
+
     def get(self, cols=None, rows=None, asarray=True, cast=True):
         '''
         More complicated way of getting data from a dataframe; example:
@@ -357,7 +362,7 @@ class dataframe(object):
             rowindices = Ellipsis
         else:
             rowindices = rows
-        
+
         output = self.data[:,colindices][rowindices,:] # Split up so can handle non-consecutive entries in either
         if output.size == 1: output = output[0] # If it's a single element, return the value rather than the array
         if asarray:
@@ -375,15 +380,15 @@ class dataframe(object):
         self.data = np.vstack((self.data[:rowindex,:], self.data[rowindex+1:,:]))
         if returnval: return thisrow
         else:         return None
-    
-    
+
+
     def append(self, value):
         ''' Add a row to the end of the data frame '''
         value = self._val2row(value) # Make sure it's in the correct format
         self.data = np.vstack((self.data, np.array(value, dtype=object)))
         return None
-    
-    
+
+
     @property
     def ncols(self):
         ''' Get the number of columns in the data frame '''
@@ -400,17 +405,17 @@ class dataframe(object):
         ''' Get the number of rows in the data frame '''
         try:    return self.data.shape[0]
         except: return 0 # If it didn't work, probably because it's empty
-    
-    
+
+
     @property
     def shape(self):
         ''' Equivalent to the shape of the data array, minus the headers '''
         return (self.nrows, self.ncols)
-    
+
     def addcol(self, key=None, value=None):
         ''' Add a new column to the data frame -- for consistency only '''
         self.__setitem__(key, value)
-    
+
     def rmcol(self, key, die=True):
         ''' Remove a column or columns from the data frame '''
         cols = ut.promotetolist(key)
@@ -424,7 +429,7 @@ class dataframe(object):
                 self.cols.pop(colindex) # Remove from list of columns
                 self.data = np.hstack((self.data[:,:colindex], self.data[:,colindex+1:])) # Remove from data
         return None
-    
+
     def addrow(self, value=None, overwrite=True, col=None, reverse=False):
         ''' Like append, but removes duplicates in the first column and resorts '''
         value = self._val2row(value) # Make sure it's in the correct format
@@ -434,24 +439,24 @@ class dataframe(object):
         else: self.data[index,:] = value # If it exists already, just replace it
         self.sort(col=col, reverse=reverse) # Sort
         return None
-    
+
     def _rowindex(self, key=None, col=None, die=False):
         ''' Get the sanitized row index for a given key and column '''
         col = self._sanitizecol(col)
         coldata = self.data[:,col] # Get data for this column
         if key is None: key = coldata[-1] # If not supplied, pick the last element
         try:    index = coldata.tolist().index(key) # Try to find duplicates
-        except: 
+        except:
             if die: raise Exception('Item %s not found; choices are: %s' % (key, coldata))
             else:   return None
         return index
-        
+
     def rmrow(self, key=None, col=None, returnval=False, die=True):
         ''' Like pop, but removes by matching the first column instead of the index -- WARNING, messy '''
         index = self._rowindex(key=key, col=col, die=die)
         if index is not None: self.pop(index, returnval=returnval)
         return None
-    
+
     def _diffindices(self, indices=None):
         ''' For a given set of indices, get the inverse, in set-speak '''
         if indices is None: indices = []
@@ -459,7 +464,7 @@ class dataframe(object):
         all_set = set(np.arange(self.nrows))
         diff_set = np.array(list(all_set - ind_set))
         return diff_set
-    
+
     def rmrows(self, indices=None, copy=None):
         ''' Remove rows by index -- WARNING, messy '''
         if copy is None: copy = False
@@ -471,7 +476,7 @@ class dataframe(object):
         else:
             self.data = keep_data
             return None
-    
+
     def replace(self, col=None, old=None, new=None):
         ''' Replace all of one value in a column with a new value '''
         col = self._sanitizecol(col)
@@ -479,20 +484,20 @@ class dataframe(object):
         inds = ma.findinds(coldata==old)
         self.data[inds,col] = new
         return None
-        
-    
+
+
     def _todict(self, row):
         ''' Return row as a dict rather than as an array '''
-        if len(row)!=len(self.cols): 
+        if len(row)!=len(self.cols):
             errormsg = 'Length mismatch between "%s" and "%s"' % (row, self.cols)
             raise Exception(errormsg)
         rowdict = odict(zip(self.cols, row))
         return rowdict
-    
+
     def findrow(self, key=None, col=None, default=None, closest=False, die=False, asdict=False):
         '''
         Return a row by searching for a matching value.
-        
+
         Arguments:
             key = the value to look for
             col = the column to look for this value in
@@ -500,7 +505,7 @@ class dataframe(object):
             closest = whether or not to return the closest row (overrides default and die)
             die = whether to raise an exception if the value is not found
             asdict = whether to return results as dict rather than list
-        
+
         Example:
             df = dataframe(cols=['year','val'],data=[[2016,0.3],[2017,0.5]])
             df.findrow(2016) # returns array([2016, 0.3], dtype=object)
@@ -508,7 +513,7 @@ class dataframe(object):
             df.findrow(2013, closest=True) # returns array([2016, 0.3], dtype=object)
             df.findrow(2016, asdict=True) # returns {'year':2016, 'val':0.3}
         '''
-        if not closest: # Usual case, get 
+        if not closest: # Usual case, get
             index = self._rowindex(key=key, col=col, die=(die and default is None))
         else:
             col = self._sanitizecol(col)
@@ -521,8 +526,8 @@ class dataframe(object):
         else:
             thisrow = default # If not found, return as default
         return thisrow
-    
-    
+
+
     def findrows(self, key=None, col=None, asarray=False):
         ''' A method like get() or indexing, but returns a dataframe by default -- WARNING, redundant? '''
         indices = self.rowindex(key=key, col=col)
@@ -533,14 +538,14 @@ class dataframe(object):
             df = dataframe(cols=self.cols, data=arr)
             return df
 
-    
+
     def rowindex(self, key=None, col=None):
         ''' Return the indices of all rows matching the given key in a given column. '''
         col = self._sanitizecol(col)
         coldata = self.data[:,col] # Get data for this column
         indices = ma.findinds(coldata==key)
         return indices
-        
+
     def _filterrows(self, key=None, col=None, keep=True, verbose=False, copy=None):
         ''' Filter rows and either keep the ones matching, or discard them '''
         indices = self.rowindex(key=key, col=col)
@@ -548,15 +553,15 @@ class dataframe(object):
         if verbose: print('Dataframe filtering: %s rows removed based on key="%s", column="%s"' % (len(indices), key, col))
         output = self.rmrows(indices=indices, copy=copy)
         return output
-    
+
     def filter_in(self, key=None, col=None, verbose=False, copy=None):
         '''Keep only rows matching a criterion (in place) '''
         return self._filterrows(key=key, col=col, keep=True, verbose=verbose, copy=copy)
-    
+
     def filter_out(self, key=None, col=None, verbose=False, copy=None):
         '''Remove rows matching a criterion (in place) '''
         return self._filterrows(key=key, col=col, keep=False, verbose=verbose, copy=copy)
-    
+
     def filtercols(self, cols=None, die=True, copy=None):
         if copy is None: copy = False
         if cols is None: cols = ut.dcp(self.cols) # By default, do nothing
@@ -581,14 +586,14 @@ class dataframe(object):
             self.cols = cols # These should be in the correct order
             self.data = ordered_data
             return None
-        
+
     def insert(self, row=0, value=None):
         ''' Insert a row at the specified location '''
         rowindex = int(row)
         value = self._val2row(value) # Make sure it's in the correct format
         self.data = np.vstack((self.data[:rowindex,:], value, self.data[rowindex:,:]))
         return None
-    
+
     def sort(self, col=None, reverse=False):
         ''' Sort the data frame by the specified column(s)'''
         if col is None:
@@ -601,7 +606,7 @@ class dataframe(object):
             if reverse: sortorder = np.array(list(reversed(sortorder)))
             self.data = self.data[sortorder,:]
         return sortorder
-    
+
     def sortcols(self, sortorder=None, reverse=False):
         if sortorder is None:
             sortorder = np.argsort(self.cols, kind='mergesort')
@@ -609,19 +614,19 @@ class dataframe(object):
         self.cols = list(np.array(self.cols)[sortorder])
         self.data = self.data[:,sortorder]
         return sortorder
-        
+
     def jsonify(self, cols=None, rows=None, header=None, die=True):
         ''' Export the dataframe to a JSON-compatible format '''
-        
+
         # Handle input arguments
         if cols   is None: cols   = self.cols # Use all columns by default
         if rows   is None: rows   = list(range(self.nrows)) # Use all rows by default
         if header is None: header = True # Include headers
-        
+
         # Optionally filter columns after making a copy
         exportdf = ut.dcp(self)
         exportdf.filtercols(cols)
-        
+
         # Handle output
         output = []
         if header: output.append(exportdf.cols)
@@ -635,7 +640,7 @@ class dataframe(object):
                     pass # This has already been handled by the validation above
             output.append(thisrow)
         return output
-    
+
     def pandas(self, df=None):
         ''' Function to export to pandas (if no argument) or import from pandas (with an argument) '''
         if ut.isstring(pd):
@@ -650,7 +655,7 @@ class dataframe(object):
             self.cols = list(df.columns)
             self.data = np.array(df, dtype=object)
             return None
-    
+
     def export(self, filename=None, cols=None, close=True):
         ''' Export to Excel '''
         from . import sc_fileio as io # Optional import
diff --git a/sciris/sc_fileio.py b/sciris/sc_fileio.py
index afe053a..61f9c3e 100644
--- a/sciris/sc_fileio.py
+++ b/sciris/sc_fileio.py
@@ -1,6 +1,6 @@
 """
-fileio.py -- code for file management in Sciris 
-    
+fileio.py -- code for file management in Sciris
+
 Last update: 2019feb18
 """
 
@@ -53,20 +53,20 @@ __all__ = ['loadobj', 'loadstr', 'saveobj', 'dumpstr']
 
 def loadobj(filename=None, folder=None, verbose=False, die=None):
     '''
-    Load a file that has been saved as a gzipped pickle file. Accepts either 
+    Load a file that has been saved as a gzipped pickle file. Accepts either
     a filename (standard usage) or a file object as the first argument.
 
     Usage:
         obj = loadobj('myfile.obj')
     '''
-    
+
     # Handle loading of either filename or file object
     if isinstance(filename, Path):
         filename = str(filename)
-    if ut.isstring(filename): 
+    if ut.isstring(filename):
         argtype = 'filename'
         filename = makefilepath(filename=filename, folder=folder, makedirs=False) # If it is a file, validate the folder (but don't create one if it's missing)
-    elif isinstance(filename, io.BytesIO): 
+    elif isinstance(filename, io.BytesIO):
         argtype = 'fileobj'
     else:
         errormsg = 'First argument to loadobj() must be a string or file object, not %s' % type(filename)
@@ -86,7 +86,7 @@ def loadstr(string=None, verbose=False, die=None):
     with closing(IO(string)) as output: # Open a "fake file" with the Gzip string pickle in it.
         with GzipFile(fileobj=output, mode='rb') as fileobj: # Set a Gzip reader to pull from the "file."
             picklestring = fileobj.read() # Read the string pickle from the "file" (applying Gzip decompression).
-    obj = unpickler(picklestring, filestring=string, verbose=verbose, die=die) # Return the object gotten from the string pickle.   
+    obj = unpickler(picklestring, filestring=string, verbose=verbose, die=die) # Return the object gotten from the string pickle.
     return obj
 
 
@@ -115,10 +115,10 @@ def saveobj(filename=None, obj=None, compresslevel=5, verbose=0, folder=None, me
             try:
                 if verbose>=2: print('Saving as pickle...')
                 savepickle(fileobj, obj) # Use pickle
-            except Exception as E: 
+            except Exception as E:
                 if verbose>=2: print('Exception when saving as pickle (%s), saving as dill...' % repr(E))
                 savedill(fileobj, obj) # ...but use Dill if that fails
-        
+
     if verbose and filename: print('Object saved to "%s"' % filename)
 
     if filename:
@@ -147,7 +147,7 @@ def dumpstr(obj=None):
 __all__ += ['loadtext', 'savetext', 'savezip', 'getfilelist', 'sanitizefilename', 'makefilepath']
 
 
-    
+
 def loadtext(filename=None, folder=None, splitlines=False):
     ''' Convenience function for reading a text file '''
     filename = makefilepath(filename=filename, folder=folder)
@@ -192,7 +192,7 @@ def getfilelist(folder=None, ext=None, pattern=None):
 
 def sanitizefilename(rawfilename):
     '''
-    Takes a potentially Linux- and Windows-unfriendly candidate file name, and 
+    Takes a potentially Linux- and Windows-unfriendly candidate file name, and
     returns a "sanitized" version that is more usable.
     '''
     filtername = re.sub('[\!\?\"\'<>]', '', rawfilename) # Erase certain characters we don't want at all: !, ?, ", ', <, >
@@ -203,7 +203,7 @@ def sanitizefilename(rawfilename):
 def makefilepath(filename=None, folder=None, ext=None, default=None, split=False, abspath=True, makedirs=True, verbose=False, sanitize=False):
     '''
     Utility for taking a filename and folder -- or not -- and generating a valid path from them.
-    
+
     Inputs:
         filename = the filename, or full file path, to save to -- in which case this utility does nothing
         folder = the name of the folder to be prepended to the filename
@@ -212,24 +212,24 @@ def makefilepath(filename=None, folder=None, ext=None, default=None, split=False
         split = whether to return the path and filename separately
         makedirs = whether or not to make the folders to save into if they don't exist
         verbose = how much detail to print
-    
+
     Example:
         makefilepath(filename=None, folder='./congee', ext='prj', default=[project.filename, project.name], split=True, abspath=True, makedirs=True)
-    
+
     Assuming project.filename is None and project.name is "soggyrice" and ./congee doesn't exist:
         * Makes folder ./congee
         * Returns e.g. ('/home/myname/congee', 'soggyrice.prj')
-    
+
     Actual code example from project.py:
         fullpath = makefilepath(filename=filename, folder=folder, default=[self.filename, self.name], ext='prj')
-    
-    Version: 2018sep22 
+
+    Version: 2018sep22
     '''
-    
+
     # Initialize
     filefolder = '' # The folder the file will be located in
     filebasename = '' # The filename
-    
+
     # Process filename
     if filename is None:
         defaultnames = ut.promotetolist(default) # Loop over list of default names
@@ -239,19 +239,19 @@ def makefilepath(filename=None, folder=None, ext=None, default=None, split=False
         filebasename = os.path.basename(filename)
         filefolder = os.path.dirname(filename)
     if not filebasename: filebasename = 'default' # If all else fails
-    
+
     # Add extension if it's defined but missing from the filebasename
-    if ext and not filebasename.endswith(ext): 
+    if ext and not filebasename.endswith(ext):
         filebasename += '.'+ext
     if verbose:
         print('From filename="%s", default="%s", extension="%s", made basename "%s"' % (filename, default, ext, filebasename))
-    
+
     # Sanitize base filename
     if sanitize: filebasename = sanitizefilename(filebasename)
-    
+
     # Process folder
     if folder is not None: # Replace with specified folder, if defined
-        filefolder = folder 
+        filefolder = folder
     if abspath: # Convert to absolute path
         filefolder = os.path.abspath(os.path.expanduser(filefolder))
     if makedirs: # Make sure folder exists
@@ -259,9 +259,9 @@ def makefilepath(filename=None, folder=None, ext=None, default=None, split=False
         except: pass
     if verbose:
         print('From filename="%s", folder="%s", abspath="%s", makedirs="%s", made folder name "%s"' % (filename, folder, abspath, makedirs, filefolder))
-    
+
     fullfile = os.path.join(filefolder, filebasename) # And the full thing
-    
+
     if split: return filefolder, filebasename
     else:     return fullfile # Or can do os.path.split() on output
 
@@ -271,10 +271,10 @@ def makefilepath(filename=None, folder=None, ext=None, default=None, split=False
 ### JSON functions
 ##############################################################################
 
-__all__ += ['sanitizejson', 'loadjson', 'savejson']
+__all__ += ['sanitizejson', 'jsonify', 'loadjson', 'savejson']
 
 
-def sanitizejson(obj, verbose=True, die=False, tostring=False):
+def sanitizejson(obj, verbose=True, die=False, tostring=False, **kwargs):
     """
     This is the main conversion function for Python data-structures into
     JSON-compatible data structures.
@@ -287,10 +287,10 @@ def sanitizejson(obj, verbose=True, die=False, tostring=False):
     """
     if obj is None: # Return None unchanged
         output = None
-    
+
     elif isinstance(obj, (bool, np.bool_)): # It's true/false
         output = bool(obj)
-    
+
     elif ut.isnumber(obj): # It's a number
         if np.isnan(obj): # It's nan, so return None
             output = None
@@ -304,23 +304,23 @@ def sanitizejson(obj, verbose=True, die=False, tostring=False):
 
     elif isinstance(obj, (list, set, tuple)): # It's another kind of interable, so iterate recurisevly
         output = [sanitizejson(p) for p in list(obj)]
-    
+
     elif isinstance(obj, dict): # Treat all dictionaries as ordered dictionaries
         output = OrderedDict()
         for key,val in obj.items():
             output[str(key)] = sanitizejson(val)
-    
+
     elif isinstance(obj, datetime.datetime):
         output = str(obj)
 
     elif isinstance(obj, uuid.UUID):
         output = str(obj)
-    
+
     elif ut.isstring(obj): # It's a string of some kind
         try:    string = str(obj) # Try to convert it to ascii
         except: string = obj # Give up and use original
         output = string
-    
+
     else: # None of the above
         try:
             output = json.loads(json.dumps(obj)) # Try passing it through jsonification
@@ -330,25 +330,30 @@ def sanitizejson(obj, verbose=True, die=False, tostring=False):
             elif verbose: print(errormsg)
             output = str(obj)
 
-    if tostring: return json.dumps(output)
+    if tostring: return json.dumps(output, **kwargs)
     else:        return output
 
 
 
-def loadjson(filename=None, folder=None):
+def jsonify(*args, **kwargs):
+    ''' Alias to sanitizejson() '''
+    return sanitizejson(*args, **kwargs)
+
+
+def loadjson(filename=None, folder=None, **kwargs):
     ''' Convenience function for reading a JSON file '''
     filename = makefilepath(filename=filename, folder=folder)
     with open(filename) as f:
-        output = json.load(f)
+        output = json.load(f, **kwargs)
     return output
 
 
 
-def savejson(filename=None, obj=None, folder=None):
+def savejson(filename=None, obj=None, folder=None, **kwargs):
     ''' Convenience function for saving a JSON '''
     filename = makefilepath(filename=filename, folder=folder)
     with open(filename, 'w') as f:
-        json.dump(sanitizejson(obj), f)
+        json.dump(sanitizejson(obj), f, **kwargs)
     return None
 
 
@@ -370,7 +375,7 @@ class Blobject(object):
         # - A io.BytesIO which will get dumped into this instance
         # Alternatively, can specify `blob` which is a binary string that gets stored directly
         # in the `blob` attribute
-        
+
         # Handle inputs
         if source   is None and filename is not None: source   = filename # Reset the source to be the filename, e.g. Spreadsheet(filename='foo.xlsx')
         if filename is None and ut.isstring(source):  filename = source   # Reset the filename to be the source, e.g. Spreadsheet('foo.xlsx')
@@ -401,7 +406,7 @@ class Blobject(object):
             source.seek(0)
             output = source.read()
             return output
-        
+
         def read_file(filename):
             ''' Helper to read an actual file '''
             filepath = makefilepath(filename=filename)
@@ -409,7 +414,7 @@ class Blobject(object):
             with open(filepath, mode='rb') as f:
                 output = f.read()
             return output
-            
+
         if source is None:
             if self.bytes is not None:
                 self.blob = read_bin(self.bytes)
@@ -427,7 +432,7 @@ class Blobject(object):
             else:
                 errormsg = 'Input source must be type string (for a filename) or BytesIO, not %s' % type(source)
                 raise Exception(errormsg)
-        
+
         self.modified = ut.now()
         return None
 
@@ -463,22 +468,22 @@ class Blobject(object):
         else:
             self.bytes = bytesblob
             return None
-    
+
     def freshbytes(self):
         ''' Refresh the bytes object to accept new data '''
         self.bytes = io.BytesIO()
         return self.bytes
-    
-    
-    
+
+
+
 class Spreadsheet(Blobject):
     '''
-    A class for reading and writing Excel files in binary format.No disk IO needs 
+    A class for reading and writing Excel files in binary format.No disk IO needs
     to happen to manipulate the spreadsheets with openpyexcel (or xlrd or pandas).
 
     Version: 2018sep03
     '''
-    
+
     def xlrd(self, *args, **kwargs):
         ''' Return a book as opened by xlrd '''
         try:
@@ -487,7 +492,7 @@ class Spreadsheet(Blobject):
             raise Exception('The "xlrd" Python package is not available; please install manually') from e
         book = xlrd.open_workbook(file_contents=self.tofile().read(), *args, **kwargs)
         return book
-    
+
     def openpyexcel(self, *args, **kwargs):
         ''' Return a book as opened by openpyexcel '''
         try:
@@ -497,7 +502,7 @@ class Spreadsheet(Blobject):
         self.tofile(output=False)
         book = openpyexcel.load_workbook(self.bytes, *args, **kwargs) # This stream can be passed straight to openpyexcel
         return book
-        
+
     def pandas(self, *args, **kwargs):
         ''' Return a book as opened by pandas '''
         try:
@@ -507,21 +512,21 @@ class Spreadsheet(Blobject):
         self.tofile(output=False)
         book = pandas.ExcelFile(self.bytes, *args, **kwargs)
         return book
-    
+
     def update(self, book):
         ''' Updated the stored spreadsheet with book instead '''
         self.tofile(output=False)
         book.save(self.freshbytes())
         self.load()
         return None
-    
+
     @staticmethod
     def _getsheet(book, sheetname=None, sheetnum=None):
         if   sheetname is not None: sheet = book[sheetname]
         elif sheetnum  is not None: sheet = book[book.sheetnames[sheetnum]]
         else:                       sheet = book.active
         return sheet
-    
+
     def readcells(self, wbargs=None, *args, **kwargs):
         ''' Alias to loadspreadsheet() '''
         if 'method' in kwargs:
@@ -562,7 +567,7 @@ class Spreadsheet(Blobject):
                     rownum -= 1
                 results.append(sheetoutput[rownum][colnum])  # Grab and append the result at the cell.
             return results
-    
+
     def writecells(self, cells=None, startrow=None, startcol=None, vals=None, sheetname=None, sheetnum=None, verbose=False, wbargs=None):
         '''
         Specify cells to write. Can supply either a list of cells of the same length
@@ -573,11 +578,11 @@ class Spreadsheet(Blobject):
         if wbargs is None: wbargs = {}
         wb = self.openpyexcel(**wbargs)
         if verbose: print('Workbook loaded: %s' % wb)
-        
+
         # Get right worksheet
         ws = self._getsheet(book=wb, sheetname=sheetname, sheetnum=sheetnum)
         if verbose: print('Worksheet loaded: %s' % ws)
-        
+
         # Determine the cells
         if cells is not None: # A list of cells is supplied
             cells = ut.promotetolist(cells)
@@ -618,17 +623,17 @@ class Spreadsheet(Blobject):
                     except Exception as E:
                         errormsg = 'Could not write "%s" to %s: %s' % (val, key, repr(E))
                         raise Exception(errormsg)
-        
+
         # Save
         wb.save(self.freshbytes())
         self.load()
-        
+
         return None
-        
-    
+
+
     pass
-    
-    
+
+
 def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sheetnum=None, asdataframe=None, header=True, cells=None):
     '''
     Load a spreadsheet as a list of lists or as a dataframe. Read from either a filename or a file object.
@@ -637,7 +642,7 @@ def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sh
         import xlrd # Optional import
     except ModuleNotFoundError as e:
         raise Exception('The "xlrd" Python package is not available; please install manually') from e
-    
+
     # Handle inputs
     if asdataframe is None: asdataframe = True
     if isinstance(filename, io.BytesIO): fileobj = filename # It's actually a fileobj
@@ -646,12 +651,12 @@ def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sh
         book = xlrd.open_workbook(fullpath)
     else:
         book = xlrd.open_workbook(file_contents=fileobj.read())
-    if sheetname is not None: 
+    if sheetname is not None:
         sheet = book.sheet_by_name(sheetname)
     else:
         if sheetnum is None: sheetnum = 0
         sheet = book.sheet_by_index(sheetnum)
-    
+
     # Load the raw data
     rawdata = []
     for rownum in range(sheet.nrows-header):
@@ -667,7 +672,7 @@ def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sh
                 try:    val = str(val)  # But give up easily and convert to a string (not Unicode)
                 except: pass # Still no dice? Fine, we tried
             rawdata[rownum][str(attr)] = val
-    
+
     # Convert to dataframe
     if asdataframe:
         cols = rawdata[0].keys()
@@ -677,7 +682,7 @@ def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sh
             reformatted.append(newrow)
         dfdata = dataframe(cols=cols, data=reformatted)
         return dfdata
-    
+
     # Or leave in the original format
     else:
         return rawdata
@@ -687,29 +692,29 @@ def loadspreadsheet(filename=None, folder=None, fileobj=None, sheetname=None, sh
 def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, close=True, formats=None, formatdata=None, verbose=False):
     '''
     Little function to format an output results nicely for Excel. Examples:
-    
+
     import sciris as sc
     import pylab as pl
-    
+
     # Simple example
     testdata1 = pl.rand(8,3)
     sc.savespreadsheet(filename='test1.xlsx', data=testdata1)
-    
+
     # Include column headers
     test2headers = [['A','B','C']] # Need double to get right shape
     test2values = pl.rand(8,3).tolist()
     testdata2 = test2headers + test2values
     sc.savespreadsheet(filename='test2.xlsx', data=testdata2)
-    
+
     # Multiple sheets
     testdata3 = [pl.rand(10,10), pl.rand(20,5)]
     sheetnames = ['Ten by ten', 'Twenty by five']
     sc.savespreadsheet(filename='test3.xlsx', data=testdata3, sheetnames=sheetnames)
-    
+
     # Supply data as an odict
     testdata4 = sc.odict([('First sheet', pl.rand(6,2)), ('Second sheet', pl.rand(3,3))])
     sc.savespreadsheet(filename='test4.xlsx', data=testdata4, sheetnames=sheetnames)
-    
+
     # Include formatting
     nrows = 15
     ncols = 3
@@ -734,7 +739,7 @@ def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, clos
     datadict   = odict()
     formatdict = odict()
     hasformats = (formats is not None) and (formatdata is not None)
-    
+
     # Handle input arguments
     if isinstance(data, dict) and sheetnames is None:
         if verbose: print('Data is a dict, taking sheetnames from keys')
@@ -765,11 +770,11 @@ def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, clos
                 raise Exception(errormsg)
     else:
         raise Exception('Cannot figure out the format of the data') # This shouldn't happen!
-        
+
     # Create workbook
     if verbose: print('Creating file %s' % fullpath)
     workbook = xlsxwriter.Workbook(fullpath)
-    
+
     # Optionally add formats
     if formats is not None:
         if verbose: print('  Adding %s formats' % len(formats))
@@ -778,7 +783,7 @@ def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, clos
             workbook_formats[formatkey] = workbook.add_format(formatval)
     else:
         thisformat = workbook.add_format({}) # Plain formatting
-       
+
     # Actually write the data
     for sheetname in datadict.keys():
         if verbose: print('  Creating sheet %s' % sheetname)
@@ -798,7 +803,7 @@ def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, clos
                 except Exception as E:
                     errormsg = 'Could not write cell [%s,%s] data="%s", format="%s": %s' % (r, c, cell_data, thisformat, str(E))
                     raise Exception(errormsg)
-    
+
     # Either close the workbook and write to file, or return it for further working
     if close:
         if verbose: print('Saving file %s and closing' % filename)
@@ -817,15 +822,15 @@ def savespreadsheet(filename=None, data=None, folder=None, sheetnames=None, clos
 class Failed(object):
     ''' An empty class to represent a failed object loading '''
     failure_info = odict()
-    
+
     def __init__(self, *args, **kwargs):
         pass
-    
+
     def __repr__(self):
         output = ut.prepr(self) # This does not include failure_info since it's a class attribute
         output += self.showfailures(verbose=False, tostring=True)
         return output
-    
+
     def showfailures(self, verbose=True, tostring=False):
         output = ''
         for f,failure in self.failure_info.enumvals():
@@ -846,7 +851,7 @@ class Failed(object):
 
 class Empty(object):
     ''' Another empty class to represent a failed object loading, but do not proceed with setstate '''
-    
+
     def __init__(self, *args, **kwargs):
         pass
 
@@ -867,10 +872,10 @@ def makefailed(module_name=None, name=None, error=None, exception=None):
 
 class RobustUnpickler(pickle.Unpickler):
     ''' Try to import an object, and if that fails, return a Failed object rather than crashing '''
-    
+
     def __init__(self, tmpfile, fix_imports=True, encoding="latin1", errors="ignore"):
         pickle.Unpickler.__init__(self, tmpfile, fix_imports=fix_imports, encoding=encoding, errors=errors)
-    
+
     def find_class(self, module_name, name, verbose=True):
         try:
             module = importlib.import_module(module_name)
@@ -883,12 +888,12 @@ class RobustUnpickler(pickle.Unpickler):
 
 
 def unpickler(string=None, filename=None, filestring=None, die=None, verbose=False):
-    
+
     if die is None: die = False
     try: # Try pickle first
         obj = pkl.loads(string) # Actually load it -- main usage case
     except Exception as E1:
-        if die: 
+        if die:
             raise E1
         else:
             try:
@@ -911,11 +916,11 @@ def unpickler(string=None, filename=None, filestring=None, die=None, verbose=Fal
                             if verbose: print('Python 2->3 conversion failed (%s), giving up...' % str(E5))
                             errormsg = 'All available unpickling methods failed:\n    Standard: %s\n     Encoded: %s\n        Dill: %s\n      Robust: %s\n  Python2->3: %s' % (E1, E2, E3, E4, E5)
                             raise Exception(errormsg)
-    
+
     if isinstance(obj, Failed):
         print('Warning, the following errors were encountered during unpickling:')
         obj.showfailures(verbose=False)
-    
+
     return obj
 
 
@@ -923,8 +928,8 @@ def savepickle(fileobj=None, obj=None):
         ''' Use pickle to do the salty work '''
         fileobj.write(pkl.dumps(obj, protocol=-1))
         return None
-    
-    
+
+
 def savedill(fileobj=None, obj=None):
     ''' Use dill to do the sour work '''
     try:
@@ -945,7 +950,7 @@ byte_objects = ['datetime', 'BytesIO', 'odict', 'spreadsheet', 'blobject']
 
 def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
     '''
-    Used automatically by loadobj() to load Python2 objects in Python3 if all other 
+    Used automatically by loadobj() to load Python2 objects in Python3 if all other
     loading methods fail. Uses a recursive approach, so can set a recursion limit.
     '''
 
@@ -990,13 +995,13 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
     def recursive_substitute(obj1, obj2, track=None, recursionlevel=0, recursionlimit=None):
         if recursionlimit is None: # Recursion limit
             recursionlimit = 1000 # Better to die here than hit Python's recursion limit
-        
+
         def recursion_warning(count, obj1, obj2):
             output = 'Warning, internal recursion depth exceeded, aborting: depth=%s, %s -> %s' % (count, type(obj1), type(obj2))
             return output
-        
+
         recursionlevel += 1
-        
+
         if track is None:
             track = []
 
@@ -1031,7 +1036,7 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
                     else:
                         print(recursion_warning(recursionlevel, obj1, obj2))
         return recursionlevel
-    
+
     def loadintostring(fileobj):
         unpickler1 = StringUnpickler(fileobj, encoding='latin1')
         try:
@@ -1041,7 +1046,7 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
             exception = traceback.format_exc() # Grab the trackback stack
             stringout = makefailed(module_name='String unpickler failed', name='n/a', error=E, exception=exception)
         return stringout
-    
+
     def loadintobytes(fileobj):
         unpickler2 = BytesUnpickler(fileobj,  encoding='bytes')
         try:
@@ -1058,9 +1063,9 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
             stringout = loadintostring(fileobj)
         with GzipFile(filename) as fileobj:
             bytesout = loadintobytes(fileobj)
-            
+
     elif filestring:
-        with closing(IO(filestring)) as output: 
+        with closing(IO(filestring)) as output:
             with GzipFile(fileobj=output, mode='rb') as fileobj:
                 stringout = loadintostring(fileobj)
         with closing(IO(filestring)) as output:
@@ -1069,7 +1074,7 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
     else:
         errormsg = 'You must supply either a filename or a filestring for loadobj() or loadstr(), respectively'
         raise Exception(errormsg)
-    
+
     # Actually do the load, with correct substitution
     recursive_substitute(stringout, bytesout, recursionlevel=0, recursionlimit=recursionlimit)
     return stringout
@@ -1082,7 +1087,7 @@ def loadobj2to3(filename=None, filestring=None, recursionlimit=None):
 ##############################################################################
 
 # NOTE: The code below is part of the Twisted package, and is included
-# here to allow functools.partial() objects (among other things) to be 
+# here to allow functools.partial() objects (among other things) to be
 # pickled; they are not for public consumption. --CK
 
 # From: twisted/persisted/styles.py
diff --git a/sciris/sc_plotting.py b/sciris/sc_plotting.py
index 101a1e9..bf6c61d 100644
--- a/sciris/sc_plotting.py
+++ b/sciris/sc_plotting.py
@@ -32,22 +32,22 @@ def _listify_colors(colors, origndim=None):
     if not origndim:
         colors = ut.dcp(colors) # So we don't overwrite the original
         origndim = np.ndim(colors) # Original dimensionality
-        if origndim==1: 
+        if origndim==1:
             colors = [colors] # Wrap it in another list if needed
         colors = np.array(colors) # Just convert it to an array
         return colors, origndim
     else: # Reverse the transformation
-        if origndim==1: 
+        if origndim==1:
             colors = colors[0] # Pull it out again
         return colors
 
 
 
 def _processcolors(colors=None, asarray=False, ashex=False, reverse=False):
-    ''' 
+    '''
     Small helper function to do common transformations on the colors, once generated.
-    Expects colors to be an array. If asarray is True and reverse are False, returns 
-    that array. Otherwise, does the required permutations.    
+    Expects colors to be an array. If asarray is True and reverse are False, returns
+    that array. Otherwise, does the required permutations.
     '''
     if asarray:
         output = colors
@@ -68,7 +68,7 @@ def _processcolors(colors=None, asarray=False, ashex=False, reverse=False):
 def shifthue(colors=None, hueshift=0.0):
     '''
     Shift the hue of the colors being fed in.
-    
+
     Example:
         colors = shifthue(colors=[(1,0,0),(0,1,0)], hueshift=0.5)
     '''
@@ -137,40 +137,40 @@ def hsv2rgb(colors=None):
 ### COLORMAPS
 ##############################################################################
 
-__all__ += ['vectocolor', 'arraycolors', 'gridcolors', 'colormapdemo', 'alpinecolormap', 'bicolormap', 'parulacolormap', 'bandedcolormap']
+__all__ += ['vectocolor', 'arraycolors', 'gridcolors', 'colormapdemo', 'alpinecolormap', 'bicolormap', 'parulacolormap', 'turbocolormap', 'bandedcolormap']
 
 
 def vectocolor(vector, cmap=None, asarray=True, reverse=False, minval=None, maxval=None):
     """
     This function converts a vector of N values into an Nx3 matrix of color
-    values according to the current colormap. It automatically scales the 
+    values according to the current colormap. It automatically scales the
     vector to provide maximum dynamic range for the color map.
-    
+
     Note: see arraycolors() for multidimensional input.
-    
+
     Usage:
         colors = vectocolor(vector, cmap=None)
-    
+
     Args:
         vector (array): Input vector (or list, it's converted to an array)
         cmap (str): is the colormap (default: current)
-    
+
     Returns:
         colors (array): Nx4 array of RGB-alpha color values
-    
+
     Example:
         n = 1000
         x = randn(n,1);
         y = randn(n,1);
         c = sc.vectocolor(y);
         pl.scatter(x, y, c=c, s=50)
-    
+
     Version: 2020mar07
     """
-    
+
     from numpy import array, zeros
     from pylab import cm
-    
+
     if cmap == None:
         cmap = pl.get_cmap() # Get current colormap
     elif type(cmap) == str:
@@ -179,11 +179,11 @@ def vectocolor(vector, cmap=None, asarray=True, reverse=False, minval=None, maxv
         except:
             choices = "\n".join(sorted(cm.datad.keys()))
             raise Exception(f'{cmap} is not a valid color map; choices are:\n{choices}')
-    
+
     # If a scalar is supplied, convert it to a vector instead
     if ut.isnumber(vector):
         vector = np.linspace(0, 1, vector)
-    
+
     # Usual situation -- the vector has elements
     vector = ut.dcp(vector) # To avoid in-place modification
     vector = np.array(vector) # Just to be sure
@@ -192,21 +192,21 @@ def vectocolor(vector, cmap=None, asarray=True, reverse=False, minval=None, maxv
             minval = vector.min()
         if maxval is None:
             maxval = vector.max()
-         
+
         vector = vector-minval # Subtract minimum
         vector = vector/float(maxval-minval) # Divide by maximum
         nelements = len(vector) # Count number of elements
         colors = zeros((nelements,4))
         for i in range(nelements):
             colors[i,:] = array(cmap(vector[i]))
-    
+
     # It doesn't; just return black
     else:
         colors=(0,0,0,1)
-    
+
     # Process output
     output = _processcolors(colors=colors, asarray=asarray, reverse=reverse)
-    
+
     return output
 
 
@@ -216,7 +216,7 @@ def arraycolors(arr, **kwargs):
     Map an N-dimensional array of values onto the current colormap. An extension
     of vectocolor() for multidimensional arrays; see that function for additional
     arguments.
-    
+
     Example:
         n = 1000
         ncols = 5
@@ -229,7 +229,7 @@ def arraycolors(arr, **kwargs):
         pl.figure(figsize=(20,16))
         for c in range(ncols):
             pl.scatter(x+c, y, s=50, c=colors[:,c])
-    
+
     Version: 2020mar07
     """
     arr = ut.dcp(arr) # Avoid modifications
@@ -246,7 +246,7 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
     Create a qualitative "color map" by assigning points according to the maximum pairwise distance in the
     color cube. Basically, the algorithm generates n points that are maximally uniformly spaced in the
     [R, G, B] color cube.
-    
+
     Arguments:
         ncolors: the number of colors to create
         limits: how close to the edges of the cube to make colors (to avoid white and black)
@@ -268,7 +268,7 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
 
     Version: 2018oct30
     """
-    
+
     # Choose default colors
     if basis == 'default':
         if ncolors<=9: basis = 'colorbrewer' # Use these cos they're nicer
@@ -286,14 +286,14 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
     [247, 129, 191],
     [153, 153, 153],
     ])/255.
-    
+
     # Steal Kelly's colors from https://gist.github.com/ollieglass/f6ddd781eeae1d24e391265432297538, removing
     # black: '222222', off-white: 'F2F3F4', mid-grey: '848482',
     kellycolors = ['F3C300', '875692', 'F38400', 'A1CAF1', 'BE0032', 'C2B280', '008856', 'E68FAC', '0067A5', 'F99379', '604E97', 'F6A600', 'B3446C', 'DCD300', '882D17', '8DB600', '654522', 'E25822', '2B3D26']
     for c,color in enumerate(kellycolors):
         kellycolors[c] = list(hex2rgb(color))
     kellycolors = np.array(kellycolors)
-    
+
     if basis == 'colorbrewer' and ncolors<=len(colorbrewercolors):
         colors = colorbrewercolors[:ncolors]
     elif basis == 'kelly' and ncolors<=len(kellycolors):
@@ -303,13 +303,13 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
         if limits is None:
             colorrange = 1-1/float(ncolors**0.5)
             limits = [0.5-colorrange/2, 0.5+colorrange/2]
-        
+
         ## Calculate primitives and dot locations
         primitive = np.linspace(limits[0], limits[1], nsteps) # Define primitive color vector
         x, y, z = np.meshgrid(primitive, primitive, primitive) # Create grid of all possible points
         dots = np.transpose(np.array([x.flatten(), y.flatten(), z.flatten()])) # Flatten into an array of dots
         ndots = nsteps**3 # Calculate the number of dots
-        
+
         ## Start from the colorbrewer colors
         if basis=='colorbrewer' or basis=='kelly':
             indices = [] # Initialize the array
@@ -322,7 +322,7 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
                 indices.append(closest)
         else:
             indices = [0]
-        
+
         ## Calculate the distances
         for pt in range(ncolors-len(indices)): # Loop over each point
             totaldistances = np.inf+np.zeros(ndots) # Initialize distances
@@ -331,13 +331,13 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
                 totaldistances = np.minimum(totaldistances, norm(rgbdistances,axis=1)) # Calculate the minimum Euclidean distance
             maxindex = np.argmax(totaldistances) # Find the point that maximizes the minimum distance
             indices.append(maxindex) # Append this index
-        
+
         colors = dots[indices,:]
-    
+
     ## Wrap up -- turn color array into a list, or reverse
     if hueshift: colors = shifthue(colors, hueshift=hueshift) # Shift hue if requested
     output = _processcolors(colors=colors, asarray=asarray, ashex=ashex, reverse=reverse)
-    
+
     ## For plotting -- optional
     if demo:
         ax = scatter3d(colors[:,0], colors[:,1], colors[:,2], c=output, s=200, depthshade=False, lw=0, figkwargs={'facecolor':'w'})
@@ -347,7 +347,7 @@ def gridcolors(ncolors=10, limits=None, nsteps=20, asarray=False, ashex=False, r
         ax.set_xlim((0,1))
         ax.set_ylim((0,1))
         ax.set_zlim((0,1))
-    
+
     return output
 
 
@@ -357,15 +357,15 @@ def colormapdemo(cmap=None, n=None, smoothing=None, randseed=None):
     Demonstrate a color map using simulated elevation data, shown in both 2D and
     3D. The argument can be either a colormap itself or a string describing a
     colormap.
-    
+
     Examples:
         sc.colormapdemo('inferno') # Use a registered Matplotlib colormap
         sc.colormapdemo('parula') # Use a registered Sciris colormap
         sc.colormapdemo(sc.alpinecolormap(), n=200, smoothing=20, randseed=2942) # Use a colormap object
-    
+
     Version: 2019aug22
     '''
-    
+
     # Set data
     if n         is None: n         = 100
     if smoothing is None: smoothing = 40
@@ -382,7 +382,7 @@ def colormapdemo(cmap=None, n=None, smoothing=None, randseed=None):
     data -= data.min()
     data /= data.max()
     data *= maxheight
-    
+
     # Plot in 2D
     fig1 = pl.figure(figsize=(10,8))
     X = np.linspace(0,horizontalsize,n)
@@ -404,28 +404,28 @@ def colormapdemo(cmap=None, n=None, smoothing=None, randseed=None):
     pl.xlabel('Position (km)')
     pl.ylabel('Position (km)')
     pl.show()
-    
+
     return {'2d':fig1, '3d':fig2}
-    
+
 
 
 def alpinecolormap(apply=False):
     """
     This function generates a map based on ascending height. Based on data from
     Kazakhstan.
- 
+
     Test case:
         sc.colormapdemo('alpine')
- 
+
     Usage example:
         import sciris as sc
         import pylab as pl
         from pylab import randn, imshow, show
         pl.imshow(pl.randn(20,20), interpolation='none', cmap=sc.alpinecolormap())
- 
+
     Version: 2014aug06
     """
-    
+
     # Set parameters
     water = np.array([3,18,59])/256.
     desert = np.array([194,175,160*0.6])/256.
@@ -434,7 +434,7 @@ def alpinecolormap(apply=False):
     rock = np.array([119,111,109])/256.*1.3
     snow = np.array([243,239,238])/256.
     breaks = [0.0,0.5,0.7,0.8,0.9,1.0]
-    
+
     # Create dictionary
     cdict = {'red': ((breaks[0], water[0], water[0]),
                      (breaks[1], desert[0], desert[0]),
@@ -442,21 +442,21 @@ def alpinecolormap(apply=False):
                      (breaks[3], forest2[0], forest2[0]),
                      (breaks[4], rock[0], rock[0]),
                      (breaks[5], snow[0], snow[0])),
- 
+
           'green':  ((breaks[0], water[1], water[1]),
                      (breaks[1], desert[1], desert[1]),
                      (breaks[2], forest1[1], forest1[1]),
                      (breaks[3], forest2[1], forest2[1]),
                      (breaks[4], rock[1], rock[1]),
                      (breaks[5], snow[1], snow[1])),
- 
+
           'blue':   ((breaks[0], water[2], water[2]),
                      (breaks[1], desert[2], desert[2]),
                      (breaks[2], forest1[2], forest1[2]),
                      (breaks[3], forest2[2], forest2[2]),
                      (breaks[4], rock[2], rock[2]),
                      (breaks[5], snow[2], snow[2]))}
-    
+
     # Make map
     cmap = makecolormap('alpine', cdict, 256)
     if apply:
@@ -470,64 +470,64 @@ def bicolormap(gap=0.1, mingreen=0.2, redbluemix=0.5, epsilon=0.01, demo=False,
     This function generators a two-color map, blue for negative, red for
     positive changes, with grey in the middle. The input argument is how much
     of a color gap there is between the red scale and the blue one.
- 
+
     The function has four parameters:
       gap: sets how big of a gap between red and blue color scales there is (0=no gap; 1=pure red and pure blue)
       mingreen: how much green to include at the extremes of the red-blue color scale
       redbluemix: how much red to mix with the blue and vice versa at the extremes of the scale
       epsilon: what fraction of the colormap to make gray in the middle
- 
+
     Examples:
       bicolormap(gap=0,mingreen=0,redbluemix=1,epsilon=0) # From pure red to pure blue with white in the middle
       bicolormap(gap=0,mingreen=0,redbluemix=0,epsilon=0.1) # Red -> yellow -> gray -> turquoise -> blue
       bicolormap(gap=0.3,mingreen=0.2,redbluemix=0,epsilon=0.01) # Red and blue with a sharp distinction between
- 
-    Version: 2013sep13 
+
+    Version: 2013sep13
     """
     mng=mingreen; # Minimum amount of green to add into the colors
     mix=redbluemix; # How much red to mix with the blue an vice versa
     eps=epsilon; # How much of the center of the colormap to make gray
     omg=1-gap # omg = one minus gap
-    
+
     cdict = {'red': ((0.00000, 0.0, 0.0),
                      (0.5-eps, mix, omg),
                      (0.50000, omg, omg),
                      (0.5+eps, omg, 1.0),
                      (1.00000, 1.0, 1.0)),
- 
+
           'green':  ((0.00000, mng, mng),
                      (0.5-eps, omg, omg),
                      (0.50000, omg, omg),
                      (0.5+eps, omg, omg),
                      (1.00000, mng, mng)),
- 
+
           'blue':   ((0.00000, 1.0, 1.0),
                      (0.5-eps, 1.0, omg),
                      (0.50000, omg, omg),
                      (0.5+eps, omg, mix),
                      (1.00000, 0.0, 0.0))}
-    
+
     cmap = makecolormap('bi',cdict,256)
     if apply:
         pl.set_cmap(cmap)
-    
+
     def demoplot():
         from pylab import figure, subplot, imshow, colorbar, rand, show
-        
+
         maps=[]
         maps.append(bicolormap()) # Default ,should work for most things
         maps.append(bicolormap(gap=0,mingreen=0,redbluemix=1,epsilon=0)) # From pure red to pure blue with white in the middle
         maps.append(bicolormap(gap=0,mingreen=0,redbluemix=0,epsilon=0.1)) # Red -> yellow -> gray -> turquoise -> blue
         maps.append(bicolormap(gap=0.3,mingreen=0.2,redbluemix=0,epsilon=0.01)) # Red and blue with a sharp distinction between
         nexamples=len(maps)
-        
-        figure(figsize=(5*nexamples,4))    
+
+        figure(figsize=(5*nexamples,4))
         for m in range(nexamples):
             subplot(1,nexamples,m+1)
             imshow(rand(20,20),cmap=maps[m],interpolation='none');
             colorbar()
         show()
-    
+
     if demo: demoplot()
 
     return cmap
@@ -537,11 +537,11 @@ def parulacolormap(apply=False):
     '''
     Create a map similar to Viridis, but brighter. Set apply=True to use
     immediately.
-    
+
     Demo and example:
         cmap = sc.parulacolormap()
         sc.colormapdemo(cmap=cmap)
-    
+
     Version: 2019aug22
     '''
     data = [[0.2422,0.1504,0.6603], [0.2444,0.1534,0.6728], [0.2464,0.1569,0.6847], [0.2484,0.1607,0.6961], [0.2503,0.1648,0.7071], [0.2522,0.1689,0.7179], [0.2540,0.1732,0.7286], [0.2558,0.1773,0.7393],
@@ -576,13 +576,67 @@ def parulacolormap(apply=False):
             [0.9748,0.8467,0.1726], [0.9720,0.8529,0.1695], [0.9694,0.8591,0.1665], [0.9671,0.8654,0.1636], [0.9651,0.8716,0.1608], [0.9634,0.8778,0.1582], [0.9619,0.8840,0.1557], [0.9608,0.8902,0.1532],
             [0.9601,0.8963,0.1507], [0.9596,0.9023,0.1480], [0.9595,0.9084,0.1450], [0.9597,0.9143,0.1418], [0.9601,0.9203,0.1382], [0.9608,0.9262,0.1344], [0.9618,0.9320,0.1304], [0.9629,0.9379,0.1261],
             [0.9642,0.9437,0.1216], [0.9657,0.9494,0.1168], [0.9674,0.9552,0.1116], [0.9692,0.9609,0.1061], [0.9711,0.9667,0.1001], [0.9730,0.9724,0.0938], [0.9749,0.9782,0.0872], [0.9769,0.9839,0.0805]]
-    
+
     cmap = makecolormap.from_list('parula', data)
     if apply:
         pl.set_cmap(cmap)
     return cmap
 
 
+def turbocolormap(apply=False):
+    '''
+    Copyright 2019 Google LLC.
+    SPDX-License-Identifier: Apache-2.0
+    Author: Anton Mikhailov
+
+    Borrowed directly from https://gist.github.com/mikhailov-work/ee72ba4191942acecc03fe6da94fc73f, with thanks!
+
+    Create a map similar to Jet, but better. Set apply=True to use
+    immediately.
+
+    Demo and example:
+        cmap = sc.turbocolormap()
+        sc.colormapdemo(cmap=cmap)
+
+    Version: 2020mar20
+    '''
+    data = [[0.18995,0.07176,0.23217],[0.19483,0.08339,0.26149],[0.19956,0.09498,0.29024],[0.20415,0.10652,0.31844],[0.20860,0.11802,0.34607],[0.21291,0.12947,0.37314],[0.21708,0.14087,0.39964],[0.22111,0.15223,0.42558],
+            [0.22500,0.16354,0.45096],[0.22875,0.17481,0.47578],[0.23236,0.18603,0.50004],[0.23582,0.19720,0.52373],[0.23915,0.20833,0.54686],[0.24234,0.21941,0.56942],[0.24539,0.23044,0.59142],[0.24830,0.24143,0.61286],
+            [0.25107,0.25237,0.63374],[0.25369,0.26327,0.65406],[0.25618,0.27412,0.67381],[0.25853,0.28492,0.69300],[0.26074,0.29568,0.71162],[0.26280,0.30639,0.72968],[0.26473,0.31706,0.74718],[0.26652,0.32768,0.76412],
+            [0.26816,0.33825,0.78050],[0.26967,0.34878,0.79631],[0.27103,0.35926,0.81156],[0.27226,0.36970,0.82624],[0.27334,0.38008,0.84037],[0.27429,0.39043,0.85393],[0.27509,0.40072,0.86692],[0.27576,0.41097,0.87936],
+            [0.27628,0.42118,0.89123],[0.27667,0.43134,0.90254],[0.27691,0.44145,0.91328],[0.27701,0.45152,0.92347],[0.27698,0.46153,0.93309],[0.27680,0.47151,0.94214],[0.27648,0.48144,0.95064],[0.27603,0.49132,0.95857],
+            [0.27543,0.50115,0.96594],[0.27469,0.51094,0.97275],[0.27381,0.52069,0.97899],[0.27273,0.53040,0.98461],[0.27106,0.54015,0.98930],[0.26878,0.54995,0.99303],[0.26592,0.55979,0.99583],[0.26252,0.56967,0.99773],
+            [0.25862,0.57958,0.99876],[0.25425,0.58950,0.99896],[0.24946,0.59943,0.99835],[0.24427,0.60937,0.99697],[0.23874,0.61931,0.99485],[0.23288,0.62923,0.99202],[0.22676,0.63913,0.98851],[0.22039,0.64901,0.98436],
+            [0.21382,0.65886,0.97959],[0.20708,0.66866,0.97423],[0.20021,0.67842,0.96833],[0.19326,0.68812,0.96190],[0.18625,0.69775,0.95498],[0.17923,0.70732,0.94761],[0.17223,0.71680,0.93981],[0.16529,0.72620,0.93161],
+            [0.15844,0.73551,0.92305],[0.15173,0.74472,0.91416],[0.14519,0.75381,0.90496],[0.13886,0.76279,0.89550],[0.13278,0.77165,0.88580],[0.12698,0.78037,0.87590],[0.12151,0.78896,0.86581],[0.11639,0.79740,0.85559],
+            [0.11167,0.80569,0.84525],[0.10738,0.81381,0.83484],[0.10357,0.82177,0.82437],[0.10026,0.82955,0.81389],[0.09750,0.83714,0.80342],[0.09532,0.84455,0.79299],[0.09377,0.85175,0.78264],[0.09287,0.85875,0.77240],
+            [0.09267,0.86554,0.76230],[0.09320,0.87211,0.75237],[0.09451,0.87844,0.74265],[0.09662,0.88454,0.73316],[0.09958,0.89040,0.72393],[0.10342,0.89600,0.71500],[0.10815,0.90142,0.70599],[0.11374,0.90673,0.69651],
+            [0.12014,0.91193,0.68660],[0.12733,0.91701,0.67627],[0.13526,0.92197,0.66556],[0.14391,0.92680,0.65448],[0.15323,0.93151,0.64308],[0.16319,0.93609,0.63137],[0.17377,0.94053,0.61938],[0.18491,0.94484,0.60713],
+            [0.19659,0.94901,0.59466],[0.20877,0.95304,0.58199],[0.22142,0.95692,0.56914],[0.23449,0.96065,0.55614],[0.24797,0.96423,0.54303],[0.26180,0.96765,0.52981],[0.27597,0.97092,0.51653],[0.29042,0.97403,0.50321],
+            [0.30513,0.97697,0.48987],[0.32006,0.97974,0.47654],[0.33517,0.98234,0.46325],[0.35043,0.98477,0.45002],[0.36581,0.98702,0.43688],[0.38127,0.98909,0.42386],[0.39678,0.99098,0.41098],[0.41229,0.99268,0.39826],
+            [0.42778,0.99419,0.38575],[0.44321,0.99551,0.37345],[0.45854,0.99663,0.36140],[0.47375,0.99755,0.34963],[0.48879,0.99828,0.33816],[0.50362,0.99879,0.32701],[0.51822,0.99910,0.31622],[0.53255,0.99919,0.30581],
+            [0.54658,0.99907,0.29581],[0.56026,0.99873,0.28623],[0.57357,0.99817,0.27712],[0.58646,0.99739,0.26849],[0.59891,0.99638,0.26038],[0.61088,0.99514,0.25280],[0.62233,0.99366,0.24579],[0.63323,0.99195,0.23937],
+            [0.64362,0.98999,0.23356],[0.65394,0.98775,0.22835],[0.66428,0.98524,0.22370],[0.67462,0.98246,0.21960],[0.68494,0.97941,0.21602],[0.69525,0.97610,0.21294],[0.70553,0.97255,0.21032],[0.71577,0.96875,0.20815],
+            [0.72596,0.96470,0.20640],[0.73610,0.96043,0.20504],[0.74617,0.95593,0.20406],[0.75617,0.95121,0.20343],[0.76608,0.94627,0.20311],[0.77591,0.94113,0.20310],[0.78563,0.93579,0.20336],[0.79524,0.93025,0.20386],
+            [0.80473,0.92452,0.20459],[0.81410,0.91861,0.20552],[0.82333,0.91253,0.20663],[0.83241,0.90627,0.20788],[0.84133,0.89986,0.20926],[0.85010,0.89328,0.21074],[0.85868,0.88655,0.21230],[0.86709,0.87968,0.21391],
+            [0.87530,0.87267,0.21555],[0.88331,0.86553,0.21719],[0.89112,0.85826,0.21880],[0.89870,0.85087,0.22038],[0.90605,0.84337,0.22188],[0.91317,0.83576,0.22328],[0.92004,0.82806,0.22456],[0.92666,0.82025,0.22570],
+            [0.93301,0.81236,0.22667],[0.93909,0.80439,0.22744],[0.94489,0.79634,0.22800],[0.95039,0.78823,0.22831],[0.95560,0.78005,0.22836],[0.96049,0.77181,0.22811],[0.96507,0.76352,0.22754],[0.96931,0.75519,0.22663],
+            [0.97323,0.74682,0.22536],[0.97679,0.73842,0.22369],[0.98000,0.73000,0.22161],[0.98289,0.72140,0.21918],[0.98549,0.71250,0.21650],[0.98781,0.70330,0.21358],[0.98986,0.69382,0.21043],[0.99163,0.68408,0.20706],
+            [0.99314,0.67408,0.20348],[0.99438,0.66386,0.19971],[0.99535,0.65341,0.19577],[0.99607,0.64277,0.19165],[0.99654,0.63193,0.18738],[0.99675,0.62093,0.18297],[0.99672,0.60977,0.17842],[0.99644,0.59846,0.17376],
+            [0.99593,0.58703,0.16899],[0.99517,0.57549,0.16412],[0.99419,0.56386,0.15918],[0.99297,0.55214,0.15417],[0.99153,0.54036,0.14910],[0.98987,0.52854,0.14398],[0.98799,0.51667,0.13883],[0.98590,0.50479,0.13367],
+            [0.98360,0.49291,0.12849],[0.98108,0.48104,0.12332],[0.97837,0.46920,0.11817],[0.97545,0.45740,0.11305],[0.97234,0.44565,0.10797],[0.96904,0.43399,0.10294],[0.96555,0.42241,0.09798],[0.96187,0.41093,0.09310],
+            [0.95801,0.39958,0.08831],[0.95398,0.38836,0.08362],[0.94977,0.37729,0.07905],[0.94538,0.36638,0.07461],[0.94084,0.35566,0.07031],[0.93612,0.34513,0.06616],[0.93125,0.33482,0.06218],[0.92623,0.32473,0.05837],
+            [0.92105,0.31489,0.05475],[0.91572,0.30530,0.05134],[0.91024,0.29599,0.04814],[0.90463,0.28696,0.04516],[0.89888,0.27824,0.04243],[0.89298,0.26981,0.03993],[0.88691,0.26152,0.03753],[0.88066,0.25334,0.03521],
+            [0.87422,0.24526,0.03297],[0.86760,0.23730,0.03082],[0.86079,0.22945,0.02875],[0.85380,0.22170,0.02677],[0.84662,0.21407,0.02487],[0.83926,0.20654,0.02305],[0.83172,0.19912,0.02131],[0.82399,0.19182,0.01966],
+            [0.81608,0.18462,0.01809],[0.80799,0.17753,0.01660],[0.79971,0.17055,0.01520],[0.79125,0.16368,0.01387],[0.78260,0.15693,0.01264],[0.77377,0.15028,0.01148],[0.76476,0.14374,0.01041],[0.75556,0.13731,0.00942],
+            [0.74617,0.13098,0.00851],[0.73661,0.12477,0.00769],[0.72686,0.11867,0.00695],[0.71692,0.11268,0.00629],[0.70680,0.10680,0.00571],[0.69650,0.10102,0.00522],[0.68602,0.09536,0.00481],[0.67535,0.08980,0.00449],
+            [0.66449,0.08436,0.00424],[0.65345,0.07902,0.00408],[0.64223,0.07380,0.00401],[0.63082,0.06868,0.00401],[0.61923,0.06367,0.00410],[0.60746,0.05878,0.00427],[0.59550,0.05399,0.00453],[0.58336,0.04931,0.00486],
+            [0.57103,0.04474,0.00529],[0.55852,0.04028,0.00579],[0.54583,0.03593,0.00638],[0.53295,0.03169,0.00705],[0.51989,0.02756,0.00780],[0.50664,0.02354,0.00863],[0.49321,0.01963,0.00955],[0.47960,0.01583,0.01055]]
+
+    cmap = makecolormap.from_list('turbo', data)
+    if apply:
+        pl.set_cmap(cmap)
+    return cmap
 
 
 
@@ -590,13 +644,13 @@ def bandedcolormap(minvalue=None, minsaturation=None, hueshift=None, saturations
     '''
     Map colors onto bands of hue and saturation, with lightness mapped linearly.
     Unlike most colormaps, this colormap does not aim to be percentually uniform,
-    but rather aims to make it easy to relate colors to as-exact-as-possible numbers 
+    but rather aims to make it easy to relate colors to as-exact-as-possible numbers
     (while still maintaining a semblance of overall trend from low to high).
-    
+
     Demo and example:
         cmap = sc.bandedcolormap(minvalue=0, minsaturation=0)
         sc.colormapdemo(cmap=cmap)
-    
+
     Version: 2019aug22
     '''
     # Set parameters
@@ -605,7 +659,7 @@ def bandedcolormap(minvalue=None, minsaturation=None, hueshift=None, saturations
     if minsaturation   is None: minsaturation   = 0.5
     if saturationscale is None: saturationscale = 4.3
     if npts            is None: npts            = 256
-    
+
     # Calculate
     x = pl.linspace(0, pl.pi, npts)
     hsv = pl.zeros((npts, 3))
@@ -613,7 +667,7 @@ def bandedcolormap(minvalue=None, minsaturation=None, hueshift=None, saturations
     hsv[:,0] = pl.sin(x+hueshift)**2 # Hue: a big sine wave
     hsv[:,1] = minsaturation+(1-minsaturation)*pl.sin(saturationscale*x)**2 # Saturation: a small sine wave
     data = hsv2rgb(hsv)
-    
+
     # Create and use
     cmap = makecolormap.from_list('banded', data)
     if apply:
@@ -659,9 +713,9 @@ def ax3d(fig=None, ax=None, returnfig=False, silent=False, elev=None, azim=None,
     nrows = axkwargs.pop('nrows', 1) # Since fig.add_subplot() can't handle kwargs...
     ncols = axkwargs.pop('ncols', 1)
     index = axkwargs.pop('index', 1)
-    
+
     # Handle the figure
-    if fig is None: 
+    if fig is None:
         if ax is None:
             fig = pl.figure(**figkwargs) # It's necessary to have an open figure or else the commands won't work
         else:
@@ -669,7 +723,7 @@ def ax3d(fig=None, ax=None, returnfig=False, silent=False, elev=None, azim=None,
             silent = False
     else:
         silent = False # Never close an already open figure
-            
+
     # Create and initialize the axis
     if ax is None:
         ax = fig.add_subplot(nrows, ncols, index, projection='3d', **axkwargs)
@@ -690,7 +744,7 @@ def plot3d(x, y, z, c=None, fig=None, ax=None, returnfig=False, figkwargs=None,
     if axkwargs   is None: axkwargs   = {}
     if plotkwargs is None: plotkwargs = {}
     plotkwargs = dict(default_plotkwargs, **plotkwargs) # Reverse of plotkwargs.update(default_plotkwargs)
-    
+
     # Create axis
     fig,ax = ax3d(returnfig=True, fig=fig, ax=ax, figkwargs=figkwargs, **axkwargs)
 
@@ -709,7 +763,7 @@ def scatter3d(x, y, z, c=None, fig=None, returnfig=False, figkwargs=None, axkwar
     if axkwargs   is None: axkwargs   = {}
     if plotkwargs is None: plotkwargs = {}
     plotkwargs = dict(default_plotkwargs, **plotkwargs) # Reverse of plotkwargs.update(default_plotkwargs)
-    
+
     # Create figure
     fig,ax = ax3d(returnfig=True, fig=fig, figkwargs=figkwargs, **axkwargs)
 
@@ -723,13 +777,13 @@ def scatter3d(x, y, z, c=None, fig=None, returnfig=False, figkwargs=None, axkwar
 
 def surf3d(data, fig=None, returnfig=False, colorbar=True, figkwargs=None, axkwargs=None, plotkwargs=None, **kwargs):
     ''' Plot 2D data as a 3D surface '''
-    
+
     # Set default arguments
     default_plotkwargs = {'rstride':1, 'cstride':1, 'linewidth':0, 'antialiased':False, 'cmap':'viridis'}
     if axkwargs   is None: axkwargs   = {}
     if plotkwargs is None: plotkwargs = {}
     plotkwargs = dict(default_plotkwargs, **plotkwargs) # Reverse of plotkwargs.update(default_plotkwargs)
-    
+
     # Create figure
     fig,ax = ax3d(returnfig=True, fig=fig, figkwargs=figkwargs, **axkwargs)
     ny,nx = pl.array(data).shape
@@ -739,7 +793,7 @@ def surf3d(data, fig=None, returnfig=False, colorbar=True, figkwargs=None, axkwa
     surf = ax.plot_surface(X, Y, data, **plotkwargs)
     if colorbar:
         fig.colorbar(surf)
-    
+
     if returnfig:
         return fig,ax
     else:
@@ -749,16 +803,16 @@ def surf3d(data, fig=None, returnfig=False, colorbar=True, figkwargs=None, axkwa
 
 def bar3d(data, fig=None, returnfig=False, cmap='viridis', figkwargs=None, axkwargs=None, plotkwargs=None, **kwargs):
     ''' Plot 2D data as 3D bars '''
-    
+
     # Set default arguments
     default_plotkwargs = {'dx':0.8, 'dy':0.8, 'shade':True}
     if axkwargs   is None: axkwargs   = {}
     if plotkwargs is None: plotkwargs = {}
     plotkwargs = dict(default_plotkwargs, **plotkwargs) # Reverse of plotkwargs.update(default_plotkwargs)
-    
+
     # Create figure
     fig,ax = ax3d(returnfig=True, fig=fig, figkwargs=figkwargs, **axkwargs)
-    
+
     x, y, z = [], [], []
     dz = []
     if 'color' not in plotkwargs:
@@ -773,7 +827,7 @@ def bar3d(data, fig=None, returnfig=False, cmap='viridis', figkwargs=None, axkwa
             z.append(0)
             dz.append(data[i,j])
     ax.bar3d(x=x, y=y, z=z, dz=dz, **plotkwargs)
-    
+
     if returnfig:
         return fig,ax
     else:
@@ -784,11 +838,11 @@ def bar3d(data, fig=None, returnfig=False, cmap='viridis', figkwargs=None, axkwa
 def boxoff(ax=None, removeticks=True, flipticks=True):
     '''
     I don't know why there isn't already a Matplotlib command for this.
-    
+
     Removes the top and right borders of a plot. Also optionally removes
     the tick marks, and flips the remaining ones outside.
 
-    Version: 2017may22    
+    Version: 2017may22
     '''
     from pylab import gca
     if ax is None: ax = gca()
@@ -800,21 +854,21 @@ def boxoff(ax=None, removeticks=True, flipticks=True):
     if flipticks:
         ax.tick_params(direction='out', pad=5)
     return ax
-    
-    
+
+
 
 def setaxislim(which=None, ax=None, data=None):
     '''
     A small script to determine how the y limits should be set. Looks
     at all data (a list of arrays) and computes the lower limit to
     use, e.g.
-    
+
         setaxislim([np.array([-3,4]), np.array([6,4,6])], ax)
-    
+
     will keep Matplotlib's lower limit, since at least one data value
     is below 0.
-    
-    Note, if you just want to set the lower limit, you can do that 
+
+    Note, if you just want to set the lower limit, you can do that
     with this function via:
         setaxislim()
     '''
@@ -837,7 +891,7 @@ def setaxislim(which=None, ax=None, data=None):
     # Get current limits
     if   which == 'x': currlower, currupper = ax.get_xlim()
     elif which == 'y': currlower, currupper = ax.get_ylim()
-    
+
     # Calculate the lower limit based on all the data
     lowerlim = 0
     upperlim = 0
@@ -845,11 +899,11 @@ def setaxislim(which=None, ax=None, data=None):
         flatdata = ut.promotetoarray(data).flatten() # Make sure it's iterable
         lowerlim = min(lowerlim, flatdata.min())
         upperlim = max(upperlim, flatdata.max())
-    
+
     # Set the new y limits
     if lowerlim<0: lowerlim = currlower # If and only if the data lower limit is negative, use the plotting lower limit
     upperlim = max(upperlim, currupper) # Shouldn't be an issue, but just in case...
-    
+
     # Specify the new limits and return
     if   which == 'x': ax.set_xlim((lowerlim, upperlim))
     elif which == 'y': ax.set_ylim((lowerlim, upperlim))
@@ -877,8 +931,8 @@ def commaticks(fig=None, ax=None, axis='y'):
         else: raise Exception('Axis must be x, y, or z')
         thisaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: format(int(x), ',')))
     return None
-    
-    
+
+
 
 def SItickformatter(x, pos=None, sigfigs=2, SI=True, *args, **kwargs):  # formatter function takes tick label and tick position
     ''' Formats axis ticks so that e.g. 34,243 becomes 34K '''
@@ -920,7 +974,7 @@ __all__ += ['savefigs', 'loadfig', 'emptyfig', 'separatelegend', 'savemovie']
 def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=None, aslist=False, verbose=False, **kwargs):
     '''
     Save the requested plots to disk.
-    
+
     Arguments:
         figs        -- the figure objects to save
         filetype    -- the file type; can be 'fig', 'singlepdf' (default), or anything supported by savefig()
@@ -928,7 +982,7 @@ def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=N
         folder      -- the folder to save the file(s) in
         savefigargs -- dictionary of arguments passed to savefig()
         aslist      -- whether or not return a list even for a single file
-    
+
     Example usages:
         import pylab as pl
         import sciris as sc
@@ -938,21 +992,21 @@ def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=N
         sc.savefigs(fig2, 'png', filename='myfig.png', savefigargs={'dpi':200})
         sc.savefigs([fig1, fig2], filepath='/home/me', filetype='svg')
         sc.savefigs(fig1, position=[0.3,0.3,0.5,0.5])
-    
+
     If saved as 'fig', then can load and display the plot using sc.loadfig().
-    
-    Version: 2018aug26 
+
+    Version: 2018aug26
     '''
-    
+
     # Preliminaries
     wasinteractive = pl.isinteractive() # You might think you can get rid of this...you can't!
     if wasinteractive: pl.ioff()
     if filetype is None: filetype = 'singlepdf' # This ensures that only one file is created
-    
+
     # Either take supplied plots, or generate them
     figs = odict.promote(figs)
     nfigs = len(figs)
-    
+
     # Handle file types
     filenames = []
     if filetype=='singlepdf': # See http://matplotlib.org/examples/pylab_examples/multipage_pdf.html
@@ -971,7 +1025,7 @@ def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=N
             keyforfilename = filter(str.isalnum, str(key)) # Strip out non-alphanumeric stuff for key
             defaultname = keyforfilename
             fullpath = fio.makefilepath(filename=filename, folder=folder, default=defaultname, ext=filetype)
-        
+
         # Do the saving
         if savefigargs is None: savefigargs = {}
         defaultsavefigargs = {'dpi':200, 'bbox_inches':'tight'} # Specify a higher default DPI and save the figure tightly
@@ -989,7 +1043,7 @@ def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=N
                 filenames.append(fullpath)
                 if verbose: print('%s plot saved to %s' % (filetype.upper(),fullpath))
             pl.close(plt)
-    
+
     # Do final tidying
     if filetype=='singlepdf': pdf.close()
     if wasinteractive: pl.ion()
@@ -1002,7 +1056,7 @@ def savefigs(figs=None, filetype=None, filename=None, folder=None, savefigargs=N
 def loadfig(filename=None):
     '''
     Load a plot from a file and reanimate it.
-    
+
     Example usage:
         import pylab as pl
         import sciris as sc
@@ -1039,7 +1093,7 @@ def emptyfig():
 
 def separatelegend(ax=None, handles=None, labels=None, reverse=False, figsettings=None, legendsettings=None):
     ''' Allows the legend of a figure to be rendered in a separate window instead '''
-    
+
     # Handle settings
     if figsettings    is None: figsettings = {}
     if legendsettings is None: legendsettings = {}
@@ -1086,7 +1140,7 @@ def separatelegend(ax=None, handles=None, labels=None, reverse=False, figsetting
     if reverse:
         handles2 = handles2[::-1]
         labels   = labels[::-1]
-    
+
     # Plot the new legend
     ax.legend(handles=handles2, labels=labels, **l_settings)
 
@@ -1096,64 +1150,64 @@ def separatelegend(ax=None, handles=None, labels=None, reverse=False, figsetting
 def savemovie(frames, filename=None, fps=None, quality=None, dpi=None, writer=None, bitrate=None, interval=None, repeat=False, repeat_delay=None, blit=False, verbose=True, **kwargs):
     '''
     Save a set of Matplotlib artists as a movie.
-    
+
     Parameters
     ----------
     frames : list
         The list of frames to animate
-    
+
     filename : str
         The name (or full path) of the file; expected to end with mp4 or gif (default movie.mp4)
-    
+
     fps : int
         The number of frames per second (default 10)
-    
+
     quality : string
         The quality of the movie, in terms of dpi (default "high" = 300 dpi)
-    
+
     dpi : int
         Instead of using quality, set an exact dpi
-    
+
     writer : str or object
         Specify the writer to be passed to matplotlib.animation.save() (default "ffmpeg")
-    
+
     bitrate : int
         The bitrate. Note, may be ignored; best to specify in a writer and to pass in the writer as an argument
-    
+
     interval : int
         The interval between frames; alternative to using fps
-    
+
     repeat : bool
         Whether or not to loop the animation (default False)
-        
+
     repeat_delay : bool
         Delay between repeats, if repeat=True (default None)
-    
+
     blit : bool
         Whether or not to "blit" the frames (default False, since otherwise does not detect changes )
-        
+
     verbose : bool
         Whether to print statistics on finishing.
-    
+
     kwargs : dict
         Passed to matplotlib.animation.save()
-    
-    
+
+
     Returns
     -------
     anim : object
         A Matplotlib animation object
-    
-    
+
+
     Examples
     --------
     import pylab as pl
     import sciris as sc
-    
+
     # Simple example (takes ~5 s)
     frames = [pl.plot(pl.cumsum(pl.randn(100))) for i in range(20)] # Create frames
     sc.savemovie(frames, 'dancing_lines.gif') # Save movie as medium-quality gif
-    
+
     # Complicated example (takes ~15 s)
     nframes = 100 # Set the number of frames
     ndots = 100 # Set the number of dots
@@ -1177,22 +1231,22 @@ def savemovie(frames, filename=None, fps=None, quality=None, dpi=None, writer=No
         frames.append((plot1, plot2, title)) # Store updated artists
         old_dots = pl.vstack([old_dots, dots]) # Store the new dots as old dots
     sc.savemovie(frames, 'fleeing_dots.mp4', fps=20, quality='high') # Save movie as a high-quality mp4
-    
+
     Version: 2019aug21
     '''
-    
+
     from matplotlib import animation # Place here since specific only to this function
-    
+
     if not isinstance(frames, list):
         raise Exception('savemovie(): Argument "frames" must be a list, not "%s"' % type(frames))
     for f in range(len(frames)):
         if not ut.isiterable(frames[f]):
             frames[f] = (frames[f],) # This must be either a tuple or a list to work with ArtistAnimation
-    
+
     # Try to get the figure from the frames, else use the current one
     try:    fig = frames[0][0].get_figure()
     except: fig = pl.gcf()
-    
+
     # Set parameters
     if filename is None:
         filename = 'movie.mp4'
@@ -1205,7 +1259,7 @@ def savemovie(frames, filename=None, fps=None, quality=None, dpi=None, writer=No
     if interval is None:
         interval = 1000./fps
         fps = 1000./interval # To ensure it's correct
-    
+
     # Handle dpi/quality
     if dpi is None and quality is None:
         quality = 'medium' # Make it medium quailty by default
@@ -1219,12 +1273,12 @@ def savemovie(frames, filename=None, fps=None, quality=None, dpi=None, writer=No
         elif quality == 'medium': dpi = 150
         elif quality == 'high':   dpi = 300
         else: raise Exception('Quality must be high, medium, or low, not "%s"' % quality)
-    
+
     # Optionally print progress
     if verbose:
         start = ut.tic()
         print('Saving %s frames at %s fps and %s dpi to "%s" using %s...' % (len(frames), fps, dpi, filename, writer))
-    
+
     # Actually create the animation -- warning, no way to not actually have it render!
     anim = animation.ArtistAnimation(fig, frames, interval=interval, repeat_delay=repeat_delay, repeat=repeat, blit=blit)
     anim.save(filename, writer=writer, fps=fps, dpi=dpi, bitrate=bitrate, **kwargs)
@@ -1238,5 +1292,5 @@ def savemovie(frames, filename=None, fps=None, quality=None, dpi=None, writer=No
         except:
             pass
         ut.toc(start)
-    
+
     return anim
\ No newline at end of file
diff --git a/sciris/sc_utils.py b/sciris/sc_utils.py
index 6b7d833..53dab69 100644
--- a/sciris/sc_utils.py
+++ b/sciris/sc_utils.py
@@ -18,6 +18,7 @@ import subprocess
 import numbers
 import numpy as np
 import uuid as py_uuid
+import traceback as py_traceback
 from textwrap import fill
 from functools import reduce
 from collections import OrderedDict as OD
@@ -52,7 +53,7 @@ else:
 
 
 # Define the modules being loaded
-__all__ = ['uuid', 'dcp', 'cp', 'pp', 'sha', 'wget', 'htmlify']
+__all__ = ['uuid', 'dcp', 'cp', 'pp', 'sha', 'wget', 'htmlify', 'thisdir', 'traceback']
 
 
 def uuid(uid=None, which=None, die=False, tostring=False, length=None):
@@ -177,7 +178,18 @@ def htmlify(string, reverse=False, tostring=False):
     return output
 
 
+def thisdir(filename):
+    '''
+    Tiny helper function to get the folder name of the current code. Usage:
+        thisdir = sc.thisdir(__file__)
+    '''
+    output = os.path.abspath(os.path.dirname(filename))
+    return output
+
 
+def traceback(*args, **kwargs):
+    ''' Shortcut for accessing the traceback '''
+    return py_traceback.format_exc(*args, **kwargs)
 
 
 ##############################################################################
diff --git a/sciris/sc_version.py b/sciris/sc_version.py
index 7f55abc..56544a8 100644
--- a/sciris/sc_version.py
+++ b/sciris/sc_version.py
@@ -1,5 +1,5 @@
 __all__ = ['__version__', '__versiondate__', '__license__']
 
-__version__      = '0.15.8'
-__versiondate__  = '2020-03-14'
+__version__      = '0.16.0'
+__versiondate__  = '2020-03-20'
 __license__      = 'Sciris %s (%s) -- (c) Sciris.org' % (__version__, __versiondate__)
diff --git a/setup.py b/setup.py
index 1b5df45..02de2f3 100644
--- a/setup.py
+++ b/setup.py
@@ -17,7 +17,7 @@ cwd = os.path.abspath(os.path.dirname(__file__))
 
 # Define the requirements for core functionality
 requirements = [
-        'matplotlib>=1.4.2',  # Plotting
+        'matplotlib>=2.2.2',  # Plotting
         'numpy>=1.10.1',      # Numerical functions
         'dill',               # File I/O
         'gitpython',          # Version information

Implement Turbo colormap
https://gist.github.com/mikhailov-work/ee72ba4191942acecc03fe6da94fc73f
diff --git a/tests/test_fileio.py b/tests/test_fileio.py
index fe5a4e1..8f1b378 100644
--- a/tests/test_fileio.py
+++ b/tests/test_fileio.py
@@ -1,164 +1,223 @@
-"""
-Version:
-"""
+'''
+Test Sciris file I/O functions.
+'''
 
-import sciris as sc
+import pytest
 import pylab as pl
-import openpyxl
-import os
-
-
-torun = [
-'savespreadsheet',
-'loadspreadsheet',
-'readcells',
-'Blobject',
-'Spreadsheet',
-'saveobj',
-'loadobj',
-'savetext',
-'loadtext',
-'getfilelist',
-'savezip',
-'loadfailed',
-]
-
-
-def check(test, dependencies=None):
-    if dependencies == None: dependencies = []
-    tf = any([t in torun for t in [test]+dependencies])
-    return tf
-    
-# Define filenames
-filedir = 'files' + os.sep
-files = sc.prettyobj()
-files.excel  = filedir+'test.xlsx'
-files.binary = filedir+'test.obj'
-files.text   = filedir+'text.txt'
-files.zip    = filedir+'test.zip'
-tidyup = True
-
-# Define the test data
-nrows = 15
-ncols = 3
-testdata   = pl.zeros((nrows+1, ncols), dtype=object) # Includes header row
-testdata[0,:] = ['A', 'B', 'C'] # Create header
-testdata[1:,:] = pl.rand(nrows,ncols) # Create data
-
-# Test spreadsheet writing, and create the file for later
-if check('savespreadsheet', ['loadspreadsheet', 'Spreadsheet', 'savezip']):
-    
-    formats = {
-        'header':{'bold':True, 'bg_color':'#3c7d3e', 'color':'#ffffff'},
-        'plain': {},
-        'big':   {'bg_color':'#ffcccc'}}
-    formatdata = pl.zeros((nrows+1, ncols), dtype=object) # Format data needs to be the same size
-    formatdata[1:,:] = 'plain' # Format data
-    formatdata[1:,:][testdata[1:,:]>0.7] = 'big' # Find "big" numbers and format them differently
-    formatdata[0,:] = 'header' # Format header
-    sc.savespreadsheet(filename=files.excel, data=testdata, formats=formats, formatdata=formatdata)
-
-
-# Test loading
-if check('loadspreadsheet'):
-    data = sc.loadspreadsheet(files.excel)
-    print(data)
-
-
-if check('readcells'):
-    wb = sc.Spreadsheet(filename=filedir+'exampledata.xlsx') # Load a sample databook to try pulling cells from
-    celltest = wb.readcells(method='xlrd', sheetname='Baseline year population inputs', cells=[[46, 2], [47, 2]]) # Grab cells using xlrd
-    celltest2 = wb.readcells(method='openpyexcel', wbargs={'data_only': True}, sheetname='Baseline year population inputs', cells=[[46, 2], [47, 2]]) # Grab cells using openpyexcel.  You have to set wbargs={'data_only': True} to pull out cached values instead of formula strings
-    print('xlrd output: %s' % celltest)
-    print('openpyxl output: %s' % celltest2)
-
-
-if check('Blobject'):
-    blob = sc.Blobject(files.excel)
-    f = blob.tofile()
-    wb = openpyxl.load_workbook(f)
-    ws = wb.active
-    ws['B7'] = 'Hi!     '
-    wb.save(f)
-    blob.load(f)
-    blob.tofile(output=False)
-    data = sc.loadspreadsheet(fileobj=blob.bytes)
-    print(blob)
-    sc.pp(data)
-    
-
-# Test spreadsheet saving
-if check('Spreadsheet'):
-    S = sc.Spreadsheet(files.excel)
-    S.writecells(cells=['A6','B7','C8','D9'], vals=['This','is','a','test']) # Method 1
-    S.writecells(cells=[pl.array([7,1])+i for i in range(4)], vals=['And','so','is','this']) # Method 2
-    newdata = (pl.rand(3,3)*100).round()
-    S.writecells(startrow=14, startcol=1, vals=newdata, verbose=True) # Method 3
-    S.save()
-    data = S.readcells(header=False)
-    print(S)
-    sc.pp(data)
-    
-
-if check('saveobj', ['loadobj']):
-    sc.saveobj(files.binary, testdata)
-
-
-if check('loadobj'):
-    obj = sc.loadobj(files.binary)
-    print(obj)
-
-
-if check('savetext', ['loadtext', 'savezip']):
-    sc.savetext(files.text, testdata)
-
-
-if check('loadtext'):
-    obj = sc.loadtext(files.text)
-    print(obj)
-    
-
-if check('getfilelist'):
-    print('Files in current folder:')
-    sc.pp(sc.getfilelist())
-
-
-if check('savezip'):
-    sc.savezip(files.zip, [files.text, files.excel])
-
-
-if check('loadfailed'):
+import sciris as sc
+
+
+def legacy_tests():
     '''
-    Check that loading an object with a non-existent class works. The file
-    deadclass.obj was created with:
-
-    deadclass.py:
-    -------------------------------------------------
-    class DeadClass():
-        def __init__(self, x):
-            self.x = x
-    -------------------------------------------------
-
-    then:
-    -------------------------------------------------
-    import deadclass as dc
-    import sciris as sc
-    deadclass = dc.DeadClass(238473)
-    sc.saveobj('deadclass.obj', deadclass)
-    -------------------------------------------------
+    Preserved for completeness, but too fragile to be used in automated unit testing
+    due to reliance on openpyxl (which is not a required Sciris dependency).
     '''
-    obj = sc.loadobj(filedir+'deadclass.obj')
-    print('Loading corrupted object succeeded, x=%s' % obj.x)
+
+    import openpyxl
+    import os
+
+    torun = [
+    'savespreadsheet',
+    'loadspreadsheet',
+    'readcells',
+    'Blobject',
+    'Spreadsheet',
+    'saveobj',
+    'loadobj',
+    'savetext',
+    'loadtext',
+    'getfilelist',
+    'savezip',
+    'loadfailed',
+    ]
+
+
+    def check(test, dependencies=None):
+        if dependencies == None: dependencies = []
+        tf = any([t in torun for t in [test]+dependencies])
+        return tf
+
+    # Define filenames
+    filedir = 'files' + os.sep
+    files = sc.prettyobj()
+    files.excel  = filedir+'test.xlsx'
+    files.binary = filedir+'test.obj'
+    files.text   = filedir+'text.txt'
+    files.zip    = filedir+'test.zip'
+    tidyup = True
+
+    # Define the test data
+    nrows = 15
+    ncols = 3
+    testdata   = pl.zeros((nrows+1, ncols), dtype=object) # Includes header row
+    testdata[0,:] = ['A', 'B', 'C'] # Create header
+    testdata[1:,:] = pl.rand(nrows,ncols) # Create data
+
+    # Test spreadsheet writing, and create the file for later
+    if check('savespreadsheet', ['loadspreadsheet', 'Spreadsheet', 'savezip']):
+
+        formats = {
+            'header':{'bold':True, 'bg_color':'#3c7d3e', 'color':'#ffffff'},
+            'plain': {},
+            'big':   {'bg_color':'#ffcccc'}}
+        formatdata = pl.zeros((nrows+1, ncols), dtype=object) # Format data needs to be the same size
+        formatdata[1:,:] = 'plain' # Format data
+        formatdata[1:,:][testdata[1:,:]>0.7] = 'big' # Find "big" numbers and format them differently
+        formatdata[0,:] = 'header' # Format header
+        sc.savespreadsheet(filename=files.excel, data=testdata, formats=formats, formatdata=formatdata)
+
+
+    # Test loading
+    if check('loadspreadsheet'):
+        sc.heading('Loading spreadsheet')
+        data = sc.loadspreadsheet(files.excel)
+        print(data)
+
+
+    if check('readcells'):
+        sc.heading('Reading cells')
+        wb = sc.Spreadsheet(filename=filedir+'exampledata.xlsx') # Load a sample databook to try pulling cells from
+        celltest = wb.readcells(method='xlrd', sheetname='Baseline year population inputs', cells=[[46, 2], [47, 2]]) # Grab cells using xlrd
+        celltest2 = wb.readcells(method='openpyexcel', wbargs={'data_only': True}, sheetname='Baseline year population inputs', cells=[[46, 2], [47, 2]]) # Grab cells using openpyexcel.  You have to set wbargs={'data_only': True} to pull out cached values instead of formula strings
+        print('xlrd output: %s' % celltest)
+        print('openpyxl output: %s' % celltest2)
+
+
+    if check('Blobject'):
+        sc.heading('Loading a blobject')
+        blob = sc.Blobject(files.excel)
+        f = blob.tofile()
+        wb = openpyxl.load_workbook(f)
+        ws = wb.active
+        ws['B7'] = 'Hi!     '
+        wb.save(f)
+        blob.load(f)
+        blob.tofile(output=False)
+        data = sc.loadspreadsheet(fileobj=blob.bytes)
+        print(blob)
+        sc.pp(data)
+
+
+    # Test spreadsheet saving
+    if check('Spreadsheet'):
+        sc.heading('Using a Spreadsheet')
+        S = sc.Spreadsheet(files.excel)
+        S.writecells(cells=['A6','B7','C8','D9'], vals=['This','is','a','test']) # Method 1
+        S.writecells(cells=[pl.array([7,1])+i for i in range(4)], vals=['And','so','is','this']) # Method 2
+        newdata = (pl.rand(3,3)*100).round()
+        S.writecells(startrow=14, startcol=1, vals=newdata, verbose=True) # Method 3
+        S.save()
+        data = S.readcells(header=False)
+        print(S)
+        sc.pp(data)
+
+
+    if check('saveobj', ['loadobj']):
+        sc.heading('Saveobj/loadobj')
+        sc.saveobj(files.binary, testdata)
+
+
+    if check('loadobj'):
+        obj = sc.loadobj(files.binary)
+        print(obj)
+
+
+    if check('savetext', ['loadtext', 'savezip']):
+        sc.heading('Savetext/loadtext')
+        sc.savetext(files.text, testdata)
+
+
+    if check('loadtext'):
+        obj = sc.loadtext(files.text)
+        print(obj)
+
+
+    if check('getfilelist'):
+        sc.heading('Get files')
+        print('Files in current folder:')
+        sc.pp(sc.getfilelist())
+
+
+    if check('savezip'):
+        sc.heading('Save zip')
+        sc.savezip(files.zip, [files.text, files.excel])
+
+
+    if check('loadfailed'):
+        '''
+        Check that loading an object with a non-existent class works. The file
+        deadclass.obj was created with:
+
+        deadclass.py:
+        -------------------------------------------------
+        class DeadClass():
+            def __init__(self, x):
+                self.x = x
+        -------------------------------------------------
+
+        then:
+        -------------------------------------------------
+        import deadclass as dc
+        import sciris as sc
+        deadclass = dc.DeadClass(238473)
+        sc.saveobj('deadclass.obj', deadclass)
+        -------------------------------------------------
+        '''
+        sc.heading('Intentionally loading corrupted file')
+        obj = sc.loadobj(filedir+'deadclass.obj')
+        print('Loading corrupted object succeeded, x=%s' % obj.x)
+
+
+    # Tidy up
+    if tidyup:
+        sc.blank()
+        sc.heading('Tidying up')
+        for fn in [files.excel, files.binary, files.text, files.zip]:
+            try:
+                os.remove(fn)
+                print('Removed %s' % fn)
+            except:
+                pass
+
+    print('Done, all fileio tests succeeded')
+
+
+def test_json():
+    sc.heading('Testing JSON read/write functions')
+
+    not_jsonifiable = sc.Blobject() # Create an object that can't be JSON serialized
+
+    print('Testing jsonifying a NON-jsonifiable object:')
+    sc.jsonify(not_jsonifiable, die=False) # Will return a string representation
+    with pytest.raises(Exception):
+        sc.sanitizejson(not_jsonifiable, die=True) # Will die
+
+    jsonifiable = sc.objdict().make(keys=['a','b'], vals=pl.rand(10))
+    json_obj = sc.jsonify(jsonifiable)
+    json_str = sc.jsonify(jsonifiable, tostring=True, indent=2) # kwargs are passed to json.dumps()
+
+    print('JSON as sanitized object:')
+    print(json_obj)
+    print('JSON as string:')
+    print(json_str)
+
+    return json_str
+
+
+
+#%% Run as a script
+if __name__ == '__main__':
+    sc.tic()
+
+    try:
+        legacy_tests()
+    except:
+        print('\n\nNOTE: Legacy tests failed -- this is no major cause for concern, but the cause was:\n')
+        print(sc.traceback())
+        print('Continuing with other tests...')
+
+    json = test_json()
 
 
-# Tidy up
-if tidyup:
-    sc.blank()
-    for fn in [files.excel, files.binary, files.text, files.zip]:
-        try:    
-            os.remove(fn)
-            print('Removed %s' % fn)
-        except:
-            pass
 
-print('Done, all fileio tests succeeded')
\ No newline at end of file
+    sc.toc()
\ No newline at end of file
diff --git a/tests/test_utils.py b/tests/test_utils.py
index de9a459..a30bd7a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,9 +1,13 @@
+'''
+Test Sciris utility/helper functions.
+'''
+
 import sciris as sc
 import pytest
 
 
 def test_colorize():
-    ''' Test text colorization '''
+    sc.heading('Test text colorization')
     sc.colorize(showhelp=True)
     sc.colorize('green', 'hi') # Simple example
     sc.colorize(['yellow', 'bgblack']); print('Hello world'); print('Goodbye world'); sc.colorize('reset') # Colorize all output in between
@@ -14,7 +18,7 @@ def test_colorize():
 
 
 def test_printing():
-    ''' Test printing functions '''
+    sc.heading('Test printing functions')
     example = sc.prettyobj()
     example.data = sc.vectocolor(10)
     print('sc.pr():')
@@ -26,7 +30,7 @@ def test_printing():
 
 
 def test_profile():
-    ''' Test profiling functions '''
+    sc.heading('Test profiling functions')
 
     def slow_fn():
         n = 10000
@@ -59,7 +63,7 @@ def test_profile():
 
 
 def test_prepr():
-    ''' Test pretty representation of an object '''
+    sc.heading('Test pretty representation of an object')
     n_attrs = 500
     myobj = sc.prettyobj()
     for i in range(n_attrs):
@@ -70,7 +74,7 @@ def test_prepr():
 
 
 def test_uuid():
-    ''' Test UID generation '''
+    sc.heading('Test UID generation')
     import uuid
     u0 = uuid.uuid4()
     u1 = sc.uuid()
@@ -85,6 +89,31 @@ def test_uuid():
     return u3
 
 
+def test_thisdir():
+    sc.heading('Test getting the current file directory')
+    import os
+
+    thisdir = sc.thisdir(__file__)
+    assert os.path.split(thisdir)[-1] == 'tests'
+    print(f'Current folder: {thisdir}')
+
+    return thisdir
+
+
+def test_traceback():
+    sc.heading('Test printing traceback text')
+
+    dct = {'a':3}
+    try:
+        dct['b'] # This will cause a KeyError
+    except:
+        text = sc.traceback()
+
+    print(f'Example traceback text:\n{text}')
+    print('NB: this is an example, not an actual error!')
+
+    return thisdir
+
 
 #%% Run as a script
 if __name__ == '__main__':
@@ -95,5 +124,7 @@ if __name__ == '__main__':
     foo = test_profile()
     myobj = test_prepr()
     uid = test_uuid()
+    thisdir = test_thisdir()
+    traceback = test_traceback()
 
     sc.toc()
\ No newline at end of file

pip install -e .
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y nodejs redis-server
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
Babel==2.14.0
certifi @ file:///croot/certifi_1671487769961/work/certifi
charset-normalizer==3.4.1
cycler==0.11.0
dill==0.3.7
docutils==0.19
et-xmlfile==1.1.0
exceptiongroup==1.2.2
execnet==2.0.2
fonttools==4.38.0
gitdb==4.0.12
GitPython==3.1.44
idna==3.10
imagesize==1.4.1
importlib-metadata==6.7.0
iniconfig==2.0.0
jdcal==1.4.1
Jinja2==3.1.6
kiwisolver==1.4.5
Levenshtein==0.23.0
line-profiler==4.1.3
MarkupSafe==2.1.5
matplotlib==3.5.3
numpy==1.21.6
openpyexcel==2.5.14
packaging==24.0
pandas==1.3.5
pandoc==2.4
Pillow==9.5.0
pluggy==1.2.0
plumbum==1.8.3
ply==3.11
psutil==7.0.0
Pygments==2.17.2
pyparsing==3.1.4
pytest==7.4.4
pytest-xdist==3.5.0
python-dateutil==2.9.0.post0
python-Levenshtein==0.23.0
pytz==2025.2
rapidfuzz==3.4.0
requests==2.31.0
-e git+https://github.com/sciris/sciris.git@cbe5056558a2fd7c89f73a90b3a3dbcbe5d90676#egg=sciris
six==1.17.0
smmap==5.0.2
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
tomli==2.0.1
typing_extensions==4.7.1
urllib3==2.0.7
xlrd==2.0.1
XlsxWriter==3.2.2
zipp==3.15.0

name: sciris
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - babel==2.14.0
      - charset-normalizer==3.4.1
      - cycler==0.11.0
      - dill==0.3.7
      - docutils==0.19
      - et-xmlfile==1.1.0
      - exceptiongroup==1.2.2
      - execnet==2.0.2
      - fonttools==4.38.0
      - gitdb==4.0.12
      - gitpython==3.1.44
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==6.7.0
      - iniconfig==2.0.0
      - jdcal==1.4.1
      - jinja2==3.1.6
      - kiwisolver==1.4.5
      - levenshtein==0.23.0
      - line-profiler==4.1.3
      - markupsafe==2.1.5
      - matplotlib==3.5.3
      - numpy==1.21.6
      - openpyexcel==2.5.14
      - packaging==24.0
      - pandas==1.3.5
      - pandoc==2.4
      - pillow==9.5.0
      - pluggy==1.2.0
      - plumbum==1.8.3
      - ply==3.11
      - psutil==7.0.0
      - pygments==2.17.2
      - pyparsing==3.1.4
      - pytest==7.4.4
      - pytest-xdist==3.5.0
      - python-dateutil==2.9.0.post0
      - python-levenshtein==0.23.0
      - pytz==2025.2
      - rapidfuzz==3.4.0
      - requests==2.31.0
      - six==1.17.0
      - smmap==5.0.2
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - tomli==2.0.1
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - xlrd==2.0.1
      - xlsxwriter==3.2.2
      - zipp==3.15.0
prefix: /opt/conda/envs/sciris

tests/test_fileio.py::test_json tests/test_utils.py::test_thisdir
tests/test_utils.py::test_traceback
tests/test_utils.py::test_colorize tests/test_utils.py::test_printing tests/test_utils.py::test_profile tests/test_utils.py::test_prepr tests/test_utils.py::test_uuid
sciris/sc_version.py setup.py sciris/sc_dataframe.py sciris/sc_utils.py sciris/sc_plotting.py LICENSE sciris/sc_fileio.py
sciris/sc_version.py setup.py sciris/sc_dataframe.py sciris/sc_utils.py sciris/sc_plotting.py LICENSE sciris/sc_fileio.py