NMODL Python Interface Tutorial
Introduction
NMODL is a code generation framework for NEURON Modeling Language. It is primarily designed to support optimised code generation backends for modern architectures including CPUs and GPUs. It provides high level Python interface that can be used for model introspection as well as performing various analysis on underlying model.
This tutorial provides introduction to Python API with examples.
To get started, let’s install nmodl via Python wheel as:
[1]:
%%capture
! pip install nmodl
Note : Python wheel is only available for Linux and Mac OS. Windows version will be available in the future. Today you can use Windows Subsystem for Linux.
Parsing Model And Constructing AST
Once the NMODL is setup properly we should be able to import nmodl
module :
[2]:
import nmodl.dsl as nmodl
If you see any issues, check the installation section. Lets take an example of a channel CaDynamics
:
[3]:
channel = """
NEURON {
SUFFIX CaDynamics
USEION ca READ ica WRITE cai
RANGE decay, gamma, minCai, depth
}
UNITS {
(mV) = (millivolt)
(mA) = (milliamp)
FARADAY = (faraday) (coulombs)
(molar) = (1/liter)
(mM) = (millimolar)
(um) = (micron)
}
PARAMETER {
gamma = 0.05 : percent of free calcium (not buffered)
decay = 80 (ms) : rate of removal of calcium
depth = 0.1 (um) : depth of shell
minCai = 1e-4 (mM)
}
ASSIGNED {ica (mA/cm2)}
INITIAL {
cai = minCai
}
STATE {
cai (mM)
}
BREAKPOINT { SOLVE states METHOD cnexp }
DERIVATIVE states {
cai' = -(10000)*(ica*gamma/(2*FARADAY*depth)) - (cai - minCai)/decay
}
FUNCTION foo() {
LOCAL temp
foo = 1.0 + gamma
}
"""
Now we can parse any valid NMODL constructs using NMODL’s parsing interface. First, we have to create nmodl parser object using nmodl::NmodlDriver
and then we can use parse_string
method :
[4]:
driver = nmodl.NmodlDriver()
modast = driver.parse_string(channel)
The parse_string
method will throw an exception if input is invalid. Otherwise it returns AST object.
If we simply print AST object, we can see JSON representation :
[5]:
print("%.200s" % modast) # only first 200 characters
NEURON {
SUFFIX CaDynamics
USEION ca READ ica WRITE cai
RANGE decay, gamma, minCai, depth
}
UNITS {
(mV) = (millivolt)
(mA) = (milliamp)
FARADAY = (faraday) (coulombs)
(mo
Querying AST object with Visitors
One of the strength of NMODL python interface is access to inbuilt Visitors. One can perform different queries and analysis on AST using different visitors. Lets start with the examples of inbuilt visitors.
Lookup visitor
As name suggest, lookup visitor allows to search different NMODL constructs in the AST. The visitor
module provides access to inbuilt visitors. In order to use this visitor, we create an object of AstLookupVisitor :
[6]:
from nmodl import ast, visitor
lookup_visitor = visitor.AstLookupVisitor()
Assuming we have created AST object (as shown here), we can search for any NMODL construct in the AST using AstLookupVisitor. For example, to find out STATE
block in the mod file, we can simply do:
[7]:
states = lookup_visitor.lookup(modast, ast.AstNodeType.STATE_BLOCK)
for state in states:
print(nmodl.to_nmodl(state))
STATE {
cai (mM)
}
We have used to_nmodl
helper function to convert AST object back to NMODL language. Note that the output of to_nmodl
should be same as input except for comments (?, :, COMMENT). There are very few edge cases where the NMODL output could slightly differ and this is considered as bug. This is being addressed by testing entire ModelDB database.
Using AstLookupVisitor we can introspect NMODL constructs at any level of details. Below are some examples to find out different constructs in the example mod file. All different kind of NMODL constructs are listeed here.
[8]:
odes = lookup_visitor.lookup(modast, ast.AstNodeType.DIFF_EQ_EXPRESSION)
primes = lookup_visitor.lookup(modast, ast.AstNodeType.PRIME_NAME)
range_vars = lookup_visitor.lookup(modast, ast.AstNodeType.RANGE_VAR)
parameters = lookup_visitor.lookup(modast, ast.AstNodeType.PARAM_ASSIGN)
units = lookup_visitor.lookup(modast, ast.AstNodeType.UNIT)
if odes:
print("%d differential equation(s) exist : " % len(odes))
for ode in odes:
print("\t %s " % nmodl.to_nmodl(ode))
if primes:
print("%d prime variables exist :" % len(primes), end="")
for prime in primes:
print(" %s" % nmodl.to_nmodl(prime), end="")
print()
if range_vars:
print("%d range variables exist :" % len(range_vars), end="")
for range_var in range_vars:
print(" %s" % nmodl.to_nmodl(range_var), end="")
print()
if parameters:
print("%d parameter variables exist :" % len(parameters), end="")
for range_var in range_vars:
print(" %s" % nmodl.to_nmodl(range_var), end="")
print()
if units:
print("%d units uses :" % len(units), end="")
for unit in units:
print(" %s" % nmodl.to_nmodl(unit), end="")
1 differential equation(s) exist :
cai' = -(10000)*(ica*gamma/(2*FARADAY*depth))-(cai-minCai)/decay
1 prime variables exist : cai'
4 range variables exist : decay gamma minCai depth
4 parameter variables exist : decay gamma minCai depth
17 units uses : (mV) (millivolt) (mA) (milliamp) (faraday) (coulombs) (molar) (1/liter) (mM) (millimolar) (um) (micron) (ms) (um) (mM) (mA/cm2) (mM)
Apart from performing lookup on whole AST object (i.e. entire NMODL file), we can perform analysis on the specific construct. Lets take a synthetic example : say we want to find out all assignment statements in function foo
. If we use lookup visitor on AST, it will returnn all statements in the mod file (e.g. including DERIVATIVE block). To avoid this, we can first find FUNCTION blocks and then search for statements within that block :
[9]:
functions = lookup_visitor.lookup(modast, ast.AstNodeType.FUNCTION_BLOCK)
function = functions[0] # first function
# expression statements include assignments
new_lookup_visitor = visitor.AstLookupVisitor(ast.AstNodeType.EXPRESSION_STATEMENT)
# using accept method of node we can visit it
function.accept(new_lookup_visitor)
statements = new_lookup_visitor.get_nodes()
for statement in statements:
print(nmodl.to_nmodl(statement))
foo = 1.0+gamma
Every AST node provides accept
method that takes visitor object as parameter. In above example, we used accept
method of FunctionBock
node. This allows to run a given visitor on a specific node. AstLookupVisitor
provides get_nodes()
method that can be used to retrive the result of visitor. List of all AstNodeType
can be found here (todo).
Symbol Table Visitor
Symbol table visitor is used to find out all variables and their usage in mod file. To use this, first create a visitor object as:
[10]:
from nmodl import symtab
symv = symtab.SymtabVisitor()
Once the visitor object is created, we can run visitor on AST object to populate symbol table. Symbol table provides print method that can be used to print whole symbol table :
[11]:
symv.visit_program(modast)
table = modast.get_symbol_table()
table_s = str(table)
print(table_s)
--------------------------------------------------------------------------------------------------------------------------------------------
| NMODL_GLOBAL [Program IN None] POSITION : UNKNOWN SCOPE : GLOBAL |
--------------------------------------------------------------------------------------------------------------------------------------------
| NAME | # NODES | PROPERTIES | STATUS | LOCATION | VALUE | # READS | # WRITES |
--------------------------------------------------------------------------------------------------------------------------------------------
| ca | 1 | ion | | UNKNOWN | | 0 | 0 |
| ion_ica | 0 | codegen_var | | UNKNOWN | | 0 | 0 |
| ion_cai | 0 | codegen_var | | UNKNOWN | | 0 | 0 |
| ion_cao | 0 | codegen_var | | UNKNOWN | | 0 | 0 |
| ion_eca | 0 | codegen_var | | UNKNOWN | | 0 | 0 |
| ica | 2 | assigned_definition read_ion | | UNKNOWN | | 0 | 0 |
| cai | 2 | prime_name assigned_definition write_ion state_var | | UNKNOWN | | 0 | 0 |
| decay | 2 | range parameter | | UNKNOWN | 80.000000 | 0 | 0 |
| gamma | 2 | range parameter | | UNKNOWN | 0.050000 | 0 | 0 |
| minCai | 2 | range parameter | | UNKNOWN | 0.000100 | 0 | 0 |
| depth | 2 | range parameter | | UNKNOWN | 0.100000 | 0 | 0 |
| mV | 1 | unit_def | | UNKNOWN | | 0 | 0 |
| mA | 1 | unit_def | | UNKNOWN | | 0 | 0 |
| FARADAY | 1 | factor_def | | 11.5-11 | | 0 | 0 |
| molar | 1 | unit_def | | UNKNOWN | | 0 | 0 |
| mM | 1 | unit_def | | UNKNOWN | | 0 | 0 |
| um | 1 | unit_def | | UNKNOWN | | 0 | 0 |
| states | 1 | derivative_block to_solve | | 36.1-10 | | 0 | 0 |
| foo | 1 | function_block | | 40.1-8 | | 0 | 0 |
--------------------------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------
| StatementBlock4 [StatementBlock IN foo] POSITION : 40.16 SCOPE : LOCAL |
------------------------------------------------------------------------------------------------
| NAME | # NODES | PROPERTIES | STATUS | LOCATION | VALUE | # READS | # WRITES |
------------------------------------------------------------------------------------------------
| temp | 1 | local | | UNKNOWN | | 0 | 0 |
------------------------------------------------------------------------------------------------
Now we can query for variables in the symbol table based on name of variable:
[12]:
cai = table.lookup("cai")
print(cai)
cai [Properties : prime_name assigned_definition write_ion state_var]
When we print the symbol, all it’s properties get printed. For example, in above case the cai
variable is used in : * differential equation (prime) * state block * assigned block * use ion statement
We can also query based on the kind of variables. For example, to find out all range variables we can use get_variables_with_properties
method with symbol property as an argument:
[13]:
range_vars = table.get_variables_with_properties(symtab.NmodlType.range_var)
for var in range_vars:
print(var)
decay [Properties : range parameter]
gamma [Properties : range parameter]
minCai [Properties : range parameter]
depth [Properties : range parameter]
We can also query with multiple properties. For example, to find out read or write ion variables we can use (second argument False
indicates any one property):
[14]:
ions_var = table.get_variables_with_properties(
symtab.NmodlType.read_ion_var | symtab.NmodlType.write_ion_var, False
)
for var in ions_var:
print(var)
ica [Properties : assigned_definition read_ion]
cai [Properties : prime_name assigned_definition write_ion state_var]
Custom AST visitor
If predefined visitors are limited, we can implement new visitor using AstVisitor interface. Lets say we want to implement a visitor that will print every floating point number in MOD file. Here is how we can do this:
[15]:
from nmodl import ast, visitor
class DoubleVisitor(visitor.AstVisitor):
def visit_double(self, node):
print(node.eval()) # or, can use nmodl.to_nmodl(node)
d_visitor = DoubleVisitor()
modast.accept(d_visitor)
0.05
0.1
1e-4
10000
2
1.0
The AstVisitor
base class provides all necessary methods to traverse different ast nodes. New visitors will inherit from AstVisitor
and implement only those method where we want different behaviour. For example, in the above case we want to visit ast nodes of type Double
and print their value. To achieve this we implemented associated method of Double
node i.e. visit_double
. When we call accept
method on the ast object, the entire AST tree will be visited (by
AstVisitor
). But whenever double node type will encounter in AST, the control will be handed back to DoubleVisitor.visit_double
method.
Lets implement the example of lookup visitor to print parameters with values :
[16]:
class ParameterVisitor(visitor.AstVisitor):
def __init__(self):
visitor.AstVisitor.__init__(self)
self.in_parameter = False
def visit_param_block(self, node):
self.in_parameter = True
node.visit_children(self)
self.in_parameter = False
def visit_name(self, node):
if self.in_parameter:
print(nmodl.to_nmodl(node))
def visit_double(self, node):
if self.in_parameter:
print(node.eval())
def visit_integer(self, node):
if self.in_parameter:
print(node.eval())
param_visitor = ParameterVisitor()
modast.accept(param_visitor)
gamma
0.05
decay
80
depth
0.1
minCai
1e-4
Easy code generation using AST visitors
With a little more code we can even create a code generator for python using a the visitor pattern.
[17]:
mfunc_src = """FUNCTION myfunc(x, y) {
if (x < y) {
myfunc = x + y
} else {
myfunc = y
}
}
"""
import nmodl.dsl as nmodl
from nmodl.dsl import ast
driver = nmodl.NmodlDriver()
mfunc_ast = driver.parse_string(mfunc_src)
[18]:
from nmodl.dsl import ast, visitor
class PyGenerator(visitor.AstVisitor):
def __init__(self):
visitor.AstVisitor.__init__(self)
self.pycode = ""
self.indent = 0
self.func_name = ""
def visit_function_block(self, node):
params = []
self.func_name = node.get_node_name()
for p in node.parameters:
params.append(p.get_node_name())
params_str = ", ".join(params)
self.pycode += f"def {node.get_node_name()}({params_str}):\n"
node.visit_children(self)
def visit_statement_block(self, node):
self.indent += 1
node.visit_children(self)
self.indent -= 1
def visit_expression_statement(self, node):
self.pycode += " " * 4 * self.indent
expr = node.expression
if type(expr) is ast.BinaryExpression and expr.op.eval() == "=":
rhs = expr.rhs
lhsn = expr.lhs.name.get_node_name()
if lhsn == self.func_name:
self.pycode += "return "
rhs.accept(self)
else:
node.visit_children(self)
else:
node.visit_children(self)
self.pycode += "\n"
def visit_if_statement(self, node):
self.pycode += " " * 4 * self.indent + "if "
node.condition.accept(self)
self.pycode += ":\n"
node.get_statement_block().accept(self)
for n in node.elseifs:
n.accept(self)
if node.elses:
node.elses.accept(self)
def visit_else_statement(self, node):
self.pycode += " " * 4 * self.indent + "else:\n"
node.get_statement_block().accept(self)
def visit_wrapped_expression(self, node):
self.pycode += "("
node.visit_children(self)
self.pycode += ")"
def visit_binary_expression(self, node):
lhs = node.lhs
rhs = node.rhs
op = node.op.eval()
if op == "^":
self.pycode += "pow("
lhs.accept(self)
self.pycode += ", "
rhs.accept(self)
self.pycode += ")"
else:
lhs.accept(self)
self.pycode += f" {op} "
rhs.accept(self)
def visit_var_name(self, node):
self.pycode += node.name.get_node_name()
def visit_integer(self, node):
self.pycode += nmodl.to_nmodl(node)
def visit_double(self, node):
self.pycode += nmodl.to_nmodl(node)
[19]:
pygen = PyGenerator()
pygen.visit_program(mfunc_ast)
print(pygen.pycode)
def myfunc(x, y):
if x < y:
return (x + y)
else:
return y
Example of CURIE information parsing
In this example we show how ontology information from NEURON block can be parsed.
[20]:
import nmodl.dsl as nmodl
from nmodl.dsl import ast, visitor
driver = nmodl.NmodlDriver()
mod_string = """
NEURON {
SUFFIX hx
REPRESENTS NCIT:C17145 : sodium channel
REPRESENTS NCIT:C17008 : potassium channel
USEION na READ ena WRITE ina REPRESENTS CHEBI:29101
USEION ca READ eca WRITE ina
RANGE gnabar, gkbar, gl, el, gna, gk
}
"""
modast = driver.parse_string(mod_string)
lookup_visitor = visitor.AstLookupVisitor()
ont_statements = lookup_visitor.lookup(modast, ast.AstNodeType.ONTOLOGY_STATEMENT)
ions = lookup_visitor.lookup(modast, ast.AstNodeType.USEION)
for statement in ont_statements:
print(
" %s (%s)" % (nmodl.to_nmodl(statement), nmodl.to_nmodl(statement.ontology_id))
)
for ion in ions:
o_id = nmodl.to_nmodl(ion.ontology_id) if ion.ontology_id else ""
print(" %s (%s)" % (nmodl.to_nmodl(ion), o_id))
REPRESENTS NCIT:C17145 (NCIT:C17145)
REPRESENTS NCIT:C17008 (NCIT:C17008)
USEION na READ ena WRITE ina REPRESENTS CHEBI:29101 (CHEBI:29101)
USEION ca READ eca WRITE ina ()
[ ]: