Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
7d4c374
rationalising unit tests
skeating Dec 5, 2019
3cc4771
Merge branch 'develop' into 77-unit-conversion
skeating Dec 5, 2019
0bbec40
beginning to work through converting units within a model
skeating Dec 6, 2019
136dab1
Merge branch '159-get_data_from_equations' into 77-unit-conversion
skeating Dec 10, 2019
400d226
Merge branch 'develop' into 77-unit-conversion
skeating Dec 10, 2019
b6f3699
convert initial value for new variable with converted units
skeating Dec 10, 2019
f2c966c
tests for adding an input that it is a state variable
skeating Dec 11, 2019
63df5ca
Merge remote-tracking branch 'origin/develop' into 77-unit-conversion
skeating Dec 11, 2019
8c0582c
isort
skeating Dec 11, 2019
28adf49
isort still wasnt happy
skeating Dec 11, 2019
916042e
Merge branch 'develop' into 77-unit-conversion
MauriceHendrix Dec 13, 2019
3d98238
Merge branch 'develop' into 77-unit-conversion
skeating Dec 13, 2019
ef3bd65
changing units for the free variable
skeating Dec 13, 2019
150b9b0
trying to make isort happy
skeating Dec 13, 2019
4a04008
refactoring to make code easier to read
skeating Dec 13, 2019
ba355e6
converted a literal constant
skeating Dec 14, 2019
3604ddf
flake8
skeating Dec 14, 2019
c3e8bde
refactoring to remove duplication of code
skeating Dec 14, 2019
d15d242
isort maybe
skeating Dec 14, 2019
da0eaa4
sort units imports
skeating Dec 14, 2019
68d886a
isort again !
skeating Dec 14, 2019
789cfd2
the imports are unchanged from last time it passed isort so ?????
skeating Dec 14, 2019
1df8bd2
replace any derivative on rhs where units have changed
skeating Dec 17, 2019
6f5d938
all aspects of add_input
skeating Dec 17, 2019
d25abe7
add output
skeating Dec 17, 2019
17f7adc
slight change in order of code
skeating Dec 19, 2019
b7c0ca2
Revert "slight change in order of code"
skeating Dec 19, 2019
4c6df83
Auto stash before revert of "slight change in order of code"
skeating Dec 19, 2019
3f7eef8
sorted most differences between input/output
skeating Dec 19, 2019
c200c42
add test for free variable as output
skeating Dec 19, 2019
f6265ef
remove unused test file
skeating Dec 28, 2019
41148fe
refactor add_input/add_output into convert_variable(variable, unit, d…
skeating Dec 28, 2019
424d65b
flake8
skeating Dec 28, 2019
07b7cbe
update docstrings
skeating Dec 28, 2019
3100a21
tests input arguments are correct types
skeating Dec 28, 2019
05f9fb1
unit conversion not possible
skeating Dec 28, 2019
c327e21
finally fixed my isort issue
skeating Dec 28, 2019
ad764aa
use an ontology term to find the variable whose units need changing
skeating Dec 30, 2019
fcd4016
create helper function for unique names
skeating Dec 30, 2019
5f1cd2d
some doc string tidying
skeating Dec 30, 2019
ddba7a0
tweaks to docstrings
skeating Dec 30, 2019
78e9a81
Remove using ontology term as argument
skeating Jan 6, 2020
1691e5f
tweaks to docstrings
skeating Jan 6, 2020
8c6690f
change name of check args function to specify which function it check…
skeating Jan 6, 2020
72c9105
moved test that was about units but not conversion
skeating Jan 6, 2020
fbd950c
if original var replaced by equation remove initial value
skeating Jan 6, 2020
ca9ce01
remove missing unit tests - to be sorted by fc
skeating Jan 6, 2020
7d9589d
pull repeated get initial state functions into locals
skeating Jan 6, 2020
4cea9dd
add test for unchanged initial value and remove redundant test
skeating Jan 6, 2020
15f5861
fix indent
skeating Jan 6, 2020
b51f3f0
tidy up logic of adding and replacing equations
skeating Jan 6, 2020
4d95504
Fix typos
jonc125 Jan 8, 2020
bd8411f
New var doesn't get initial_value in OUTPUT case
jonc125 Jan 8, 2020
6cc0e0c
Merge branch 'develop' into 77-unit-conversion
jonc125 Jan 8, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 280 additions & 3 deletions cellmlmanip/model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Classes to represent a flattened CellML model and metadata about its variables."""
import logging
from enum import Enum
from io import StringIO

import networkx as nx
Expand All @@ -12,11 +13,16 @@

logger = logging.getLogger(__name__)


# Delimiter for variables name in Sympy expressions: <component><delimiter><name>
SYMPY_SYMBOL_DELIMITER = '$'


class DataDirectionFlow(Enum):
Comment thread
skeating marked this conversation as resolved.
""" Direction of data flow for converting units"""
INPUT = 1
OUTPUT = 2


class Model(object):
"""
A componentless representation of a CellML model, containing a list of equations, units, and RDF metadata about
Expand All @@ -34,6 +40,7 @@ class Model(object):
:param name: the name of the model e.g. from ``<model name="">``.
:param cmeta_id: An optional cmeta id, e.g. from ``<model cmeta:id="">``.
"""

def __init__(self, name, cmeta_id=None):

self.name = name
Expand Down Expand Up @@ -617,6 +624,276 @@ def variables(self):
""" Returns an iterator over this model's variable symbols. """
return self._name_to_symbol.values()

def convert_variable(self, original_variable, units, direction):
"""
Add a new linked version of the given variable in the desired units.

If the variable already has the requested units, no changes are made and the original variable is returned.
Otherwise ``direction`` specifies how information flows between the new variable and the original, and
hence what new equation(s) are added to the model to perform the conversion.
If ``INPUT`` then the original variable takes its value from the newly added variable;
if ``OUTPUT`` then the opposite happens.

Any ``cmeta:id`` attribute on the original variable is moved to the new one,
so ontology annotations will refer to the new variable.

Similarly if the direction is ``INPUT`` then any initial value will be moved to the new variable
(and converted appropriately).

For example::

Original model
var{time} time: ms {pub: in};
var{sv11} sv1: mV {init: 2};

ode(sv1, time) = 1{mV_per_ms};

convert_variable(time, second, DataDirectionFlow.INPUT)

becomes
var time: ms;
var{time} time_converted: s;
var{sv11} sv1: mV {init: 2};
var sv1_orig_deriv mV_per_ms

time = 1000 * time_converted;
sv1_orig_deriv = 1{mV_per_ms}
ode(sv1, time_converted) = 1000 * sv1_orig_deriv


convert_variable(time, second, DataDirectionFlow.OUTPUT)

creates model
var{time} time: ms {pub: in};
var{sv11} sv1: mV {init: 2};
var{time} time_converted: s

ode(sv1, time) = 1{mV_per_ms};
time_converted = 0.001 * time


:param original_variable: the VariableDummy object representing the variable in the model to be converted
:param units: a Pint unit object representing the units to convert variable to (note if variable is already
in these units, model remains unchanged and the original variable is returned
:param direction: either DataDirectionFlow.INPUT; the variable to be changed is an input and all affected
equations will be adjusted
or DataDirectionFlow.OUTPUT; the variable to be changed is an output, equations
are unaffected apart from converting the actual output
:return: new variable with desired units, or original unchanged if conversion was not necessary
:throws: AssertionError if the arguments are of incorrect type
or the variable does not exist in the model
DimensionalityError if the unit conversion is impossible
"""
# assertion errors will be thrown here if arguments are incorrect type
self._check_arguments_for_convert_variables(original_variable, units, direction)

original_units = original_variable.units
# no conversion necessary
if original_units == units:
return original_variable

# conversion_factor for old units to new
# throws DimensionalityError if unit conversion is not possible
cf = self.units.get_conversion_factor(from_unit=original_units, to_unit=units)

state_symbols = self.get_state_symbols()
free_symbol = self.get_free_variable_symbol()
# create new variable and relevant equations
new_variable = self._convert_variable_instance(original_variable, cf, units, direction)

# if is output do not need to do additional changes for state/free symbols
if direction == DataDirectionFlow.OUTPUT:
return new_variable

new_derivatives = []
# if state variable
if original_variable in state_symbols:
new_derivatives.append(self._convert_state_variable_deriv(original_variable, new_variable, cf))

# if free variable
if original_variable == free_symbol:
Comment thread
skeating marked this conversation as resolved.
# for each derivative wrt to free variable add necessary variables/equations
current_equations = self.equations.copy()
for equation in current_equations:
if equation.args[0].is_Derivative:
if equation.args[0].args[1].args[0] == original_variable:
new_derivatives.append(self._convert_free_variable_deriv(equation, new_variable, cf))

# replace any instances of derivative of rhs of other eqns with new derivative variable
for new_derivative in new_derivatives:
self._replace_derivatives(new_derivative)
Comment thread
skeating marked this conversation as resolved.

self._invalidate_cache()

return new_variable

def _check_arguments_for_convert_variables(self, variable, units, direction):
"""
Checks the arguments of the convert_variable function.
:param variable: variable must be a VariableDummy object present in the model
:param units: units must be a pint Unit object in this model
:param direction: must be part of DataDirectionFlow enum
:throws: AssertionError if the arguments are of incorrect type
or the variable does not exist in the model
"""
# variable should be a VariableDummy
assert isinstance(variable, VariableDummy)

# variable must be in model
assert variable.name in self._name_to_symbol

# units should be a pint Unit object in the registry for this model
assert isinstance(units, self.units.ureg.Unit)

# direction should be part of enum
assert isinstance(direction, DataDirectionFlow)

def _replace_derivatives(self, new_derivative):
"""
Function to replace an instance of a derivative that occurs on the RHS of any equation
:param new_derivative: new variable representing the derivative
"""
for equation in self.equations:
for argument in equation.rhs.args:
Comment thread
skeating marked this conversation as resolved.
if new_derivative['expression'] == argument:
# add new equation
new_eqn = equation.subs(new_derivative['expression'], new_derivative['variable'])
self.add_equation(new_eqn)
self.remove_equation(equation)
break

def _create_new_deriv_variable_and_equation(self, eqn, derivative_variable):
"""
Create a new variable and equation for the derivative.
:param eqn: the original derivative eqn
:param derivative_variable: the dependent variable
:return: new variable for the derivative
"""
# 1. create a new variable
deriv_name = self._get_unique_name(derivative_variable.name + '_orig_deriv')
deriv_units = self.units.calculator.traverse(eqn.args[0])
new_deriv_variable = self.add_variable(name=deriv_name,
units=deriv_units.units)

# 2. create new equation and remove original
expression = sympy.Eq(new_deriv_variable, eqn.args[1])
self.add_equation(expression)
self.remove_equation(eqn)

return new_deriv_variable

def _convert_free_variable_deriv(self, eqn, new_variable, cf):
"""
Create relevant variables/equations when converting a free variable within a derivative.
:param eqn: the derivative equation containing free variable
:param new_variable: the new variable representing the converted symbol [new_units]
:param cf: conversion factor for unit conversion [new units/old units]
"""
derivative_variable = eqn.args[0].args[0] # units [x]
# 1. create a new variable/equation for original derivative
# will have units [x/old units]
new_deriv_variable = self._create_new_deriv_variable_and_equation(eqn, derivative_variable)

# 2. create equation for derivative wrt new variable
# dx/dnewvar [x/new units] = new_deriv_var [x/old units] / cf [new units/old units]
expression = sympy.Eq(sympy.Derivative(derivative_variable, new_variable), new_deriv_variable / cf)
self.add_equation(expression)
return {'variable': new_deriv_variable, 'expression': eqn.args[0]}

def _convert_state_variable_deriv(self, original_variable, new_variable, cf):
"""
Create relevant variables/equations when converting a state variable.
:param original_variable: the variable to be converted [old units]
:param new_variable: the new variable representing the converted symbol [new units]
:param cf: conversion factor for unit conversion [new units/old units]
:return: a dictionary containing the 'variable' and 'expression' for new derivative
"""
# 1. find the derivative equation for this variable
# and get the free variable (wrt_variable)
eqn = None
for equation in self.equations:
if equation.args[0].is_Derivative:
if equation.args[0].args[0] == original_variable:
eqn = equation
break

# get free variable symbol
# units [x]
wrt_variable = eqn.args[0].args[1]

# 1. create a new variable/equation for original derivative
# will have units [old units/x]
new_deriv_variable = self._create_new_deriv_variable_and_equation(eqn, original_variable)

# 2. add a new derivative equation
# dnewvar/dx [new units/x] = new_deriv_var [old units/x] * cf [new units/old units]
expression = sympy.Eq(sympy.Derivative(new_variable, wrt_variable), new_deriv_variable * cf)
self.add_equation(expression)
return {'variable': new_deriv_variable, 'expression': eqn.args[0]}

def _convert_variable_instance(self, original_variable, cf, units, direction):
"""
Internal function to create new variable and an equation for it.
:param original_variable: VariableDummy object to be converted [old units]
:param cf: conversion factor [new units/old units]
:param units: Unit object for new units
:param direction: enumeration value specifying input or output
:return: the new variable created [new units]
"""
# 1. get unique name for new variable
new_name = self._get_unique_name(original_variable.name + '_converted')

# 2. if original has initial_value calculate new initial value (only needed for INPUT case)
new_value = None
if direction == DataDirectionFlow.INPUT and original_variable.initial_value:
new_value = original_variable.initial_value * cf

# 3. copy cmeta_id from original and remove from original
new_variable = self.add_variable(name=new_name,
units=units,
initial_value=new_value,
Comment thread
MichaelClerx marked this conversation as resolved.
cmeta_id=original_variable.cmeta_id)
original_variable.cmeta_id = ''

# 4 add/remove/replace equations
if direction == DataDirectionFlow.INPUT:
# if direction is input; original var will be replaced by equation so do not need to store initial value
original_variable.initial_value = None

# find the equation for the original variable: orig_var = rhs
# remove equation from model
# add eqn for new variabale in terms of rhs of equation
# new_var [new units] = rhs [old units] * cf [new units/old units]
for equation in self.equations:
if equation.is_Equality and equation.args[0] == original_variable:
expression = sympy.Eq(new_variable, equation.args[1] * cf)
self.add_equation(expression)
self.remove_equation(equation)
break

# add eqn for original variable in terms of new variable
# orig_var [old units] = new var [new units] / cf [new units/old units]
expression = sympy.Eq(original_variable, new_variable / cf)
self.add_equation(expression)
else:
# if direction is output add eqn for new variable in terms of original variable
# new_var [new units] = orig_var [old units] * cf [new units/old units]
expression = sympy.Eq(new_variable, original_variable * cf)
self.add_equation(expression)

return new_variable

def _get_unique_name(self, name):
""" Function to create a unique name within the model.
:param name: String Suggested unique name
:return: String unique name
"""
for var in list(self.variables()):
if name == var.name:
name = self._get_unique_name(name + '_a')
break
return name


class NumberDummy(sympy.Dummy):
"""
Expand All @@ -626,6 +903,7 @@ class NumberDummy(sympy.Dummy):

Number dummies should never be created directly, but always via :meth:`Model.add_number()`.
"""

# Sympy annoyingly overwrites __new__
def __new__(cls, value, *args, **kwargs):
return super().__new__(cls, str(value))
Expand All @@ -649,6 +927,7 @@ class VariableDummy(sympy.Dummy):

For the constructor arguments, see :meth:`Model.add_variable()`.
"""

# Sympy annoyingly overwrites __new__
def __new__(cls, name, *args, **kwargs):
return super().__new__(cls, name)
Expand All @@ -661,7 +940,6 @@ def __init__(self,
private_interface=None,
order_added=None,
cmeta_id=None):

self.name = name
self.units = units
self.initial_value = None if initial_value is None else float(initial_value)
Expand Down Expand Up @@ -690,4 +968,3 @@ def __init__(self,

def __str__(self):
return self.name

Loading