####################################################################################
#
# STEPS - STochastic Engine for Pathway Simulation
# Copyright (C) 2007-2023 Okinawa Institute of Science and Technology, Japan.
# Copyright (C) 2003-2006 University of Antwerp, Belgium.
#
# See the file AUTHORS for details.
# This file is part of STEPS.
#
# STEPS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3,
# as published by the Free Software Foundation.
#
# STEPS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#################################################################################
###
import steps
import collections
import copy
from enum import Enum
import functools
import inspect
import itertools
import linecache
import numbers
import numpy
import os
import re
import shutil
import subprocess
import sys
import tempfile
import types
import warnings
VERBOSITY = 1
__all__ = ['Parameter', 'ExportParameters', 'NamedObject', 'Params', 'SetVerbosity']
###################################################################################################
# Parameter and related utility classes
class Units:
"""Represent a physical unit along with a scale
An object of this class basically consists of a vector of integers of length 7. Each value corresponds
to a physical dimension in the SI unit system.
A unit object is also associated with a scale, representing a multiplying factor.
"""
_SI_PREFIXES = {
'Y' : (24 , 'yotta-'),
'Z' : (21 , 'zetta-'),
'E' : (18 , 'exa-' ),
'P' : (15 , 'peta-' ),
'T' : (12 , 'tera-' ),
'G' : (9 , 'giga-' ),
'M' : (6 , 'mega-' ),
'k' : (3 , 'kilo-' ),
'h' : (2 , 'hecto-'),
'da': (1 , 'deca-' ),
'' : (0 , '' ),
'd' : (-1 , 'deci-' ),
'c' : (-2 , 'centi-'),
'm' : (-3 , 'milli-'),
'u' : (-6 , 'micro-'),
'n' : (-9 , 'nano-' ),
'p' : (-12, 'pico-' ),
'f' : (-15, 'femto-'),
'a' : (-18, 'atto-' ),
'z' : (-21, 'zepto-'),
'y' : (-24, 'yocto-'),
}
_SI_UNITS = {
# Base units
's': (
numpy.array([1, 0, 0, 0, 0, 0, 0], numpy.intc), # Exponent
0, # Scale in the form 10^x so 0 means 1, 1 means 10, etc
'second',
'time',
),
'm': (
numpy.array([0, 1, 0, 0, 0, 0, 0], numpy.intc),
0,
'meter',
'length',
),
'g': (
numpy.array([0, 0, 1, 0, 0, 0, 0], numpy.intc),
-3, # Should be kg but 'k' is already part of the prefixes
'gram',
'mass',
),
'A': (
numpy.array([0, 0, 0, 1, 0, 0, 0], numpy.intc),
0,
'ampere',
'electric current',
),
'K': (
numpy.array([0, 0, 0, 0, 1, 0, 0], numpy.intc),
0,
'kelvin',
'thermodynamic temperature',
),
'mol': (
numpy.array([0, 0, 0, 0, 0, 1, 0], numpy.intc),
0,
'mole',
'amount of substance',
),
'cd': (
numpy.array([0, 0, 0, 0, 0, 0, 1], numpy.intc),
0,
'candela',
'luminous intensity',
),
# Derived units
'V': (
numpy.array([-3, 2, 1, -1, 0, 0, 0], numpy.intc),
0,
'volt',
'electrical potential difference',
),
'F': (
numpy.array([4, -2, -1, 2, 0, 0, 0], numpy.intc),
0,
'farad',
'capacitance',
),
'ohm': (
numpy.array([-3, 2, 1, -2, 0, 0, 0], numpy.intc),
0,
'ohm',
'electrical resistance',
),
'S': (
numpy.array([3, -2, -1, 2, 0, 0, 0], numpy.intc),
0,
'siemens',
'electrical conductance'
),
'L': (
numpy.array([0, 3, 0, 0, 0, 0, 0], numpy.intc),
-3,
'litre',
'volume',
),
'M': (
numpy.array([0, -3, 0, 0, 0, 1, 0], numpy.intc),
3,
'molar',
r'concentration (mol L\ :sup:`-1`)',
),
'C': (
numpy.array([1, 0, 0, 1, 0, 0, 0], numpy.intc),
0,
'coulomb',
'electric charge'
),
'J': (
numpy.array([-2, 2, 1, 0, 0, 0, 0], numpy.intc),
0,
'joule',
'energy, work, heat'
)
}
_SPACER_RE = re.compile(r'\s+')
_SCALED_UNIT_RE = re.compile(
r'({pref})({dim})([\s\)\^]|$)'.format(
pref='|'.join(sorted(_SI_PREFIXES.keys(), key=lambda x:-len(x))),
dim='|'.join(sorted(_SI_UNITS.keys(), key=lambda x:-len(x))),
)
)
_SCALED_GROUP_RE = re.compile(
r'({pref})\('.format(
pref='|'.join(sorted(_SI_PREFIXES.keys(), key=lambda x:-len(x))),
)
)
_EXPONENT_RE = re.compile(r'\^(-?\d+)')
_SPACE_SIMPLIF_1 = re.compile(r'\s+')
_SPACE_SIMPLIF_2 = re.compile(r'\(\s+')
_SPACE_SIMPLIF_3 = re.compile(r'\s+\)')
__slots__ = ['_str', '_scale', '_exponents']
@staticmethod
def _parseUnitString(s):
exponents = numpy.array([0] * 7, numpy.intc)
scale = 0
i = 0
while i < len(s):
# Match one of the three tokens
m_spacer = Units._SPACER_RE.match(s[i:])
m_unit = Units._SCALED_UNIT_RE.match(s[i:])
m_group = Units._SCALED_GROUP_RE.match(s[i:])
if m_spacer is not None:
# Spaces
i += m_spacer.end()
continue
elif m_unit is not None:
# SI Unit with prefix
pref, unit, _ = m_unit.groups()
tmp_exp, tmp_scale = Units._SI_UNITS[unit][:2]
tmp_scale += Units._SI_PREFIXES[pref][0]
i += m_unit.end(2)
elif m_group is not None:
# Parentheses group with prefix
cnt = 1
for j in range(i + m_group.end(), len(s)):
cnt += 1 if s[j] == '(' else (-1 if s[j] == ')' else 0)
if cnt == 0:
break
if cnt > 0:
raise Exception(f'Unmatched parenthesis in {s}')
tmp_exp, tmp_scale = Units._parseUnitString(s[i + m_group.end():j])
tmp_scale += Units._SI_PREFIXES[m_group.group(1)][0]
i = j+1
else:
raise Exception(f'Could not parse "{s[i:]}" in "{s}".')
# Check for exponents
expo = 1
if i < len(s):
m_expo = Units._EXPONENT_RE.match(s[i:])
if m_expo is not None:
expo = int(m_expo.group(1))
i+= m_expo.end()
# Add to current values
exponents += tmp_exp * expo
scale += tmp_scale * expo
return exponents, scale
def __init__(self, units):
"""Create the Units from a formatted string."""
if not isinstance(units, str):
raise TypeError(f'Expected a string, got {units} instead.')
self._str = units.strip()
if len(self._str) > 0:
# Remove extra spaces
self._str = Units._SPACE_SIMPLIF_1.sub(' ', self._str)
self._str = Units._SPACE_SIMPLIF_2.sub('(', self._str)
self._str = Units._SPACE_SIMPLIF_3.sub(')', self._str)
self._exponents, self._scale = Units._parseUnitString(self._str)
else:
self._exponents = numpy.array([0] * 7, numpy.intc)
self._scale = 0
def _multiplyWith(self, other):
"""Combine self with an other Units object by multiplication"""
res = Units('')
res._exponents += self._exponents
res._exponents += other._exponents
res._scale = self._scale + other._scale
res._str = f'{self._str} {other._str}'.strip()
return res
def _raiseToPower(self, other):
"""Raise self to an integer power"""
res = Units('')
res._exponents = self._exponents * other
res._scale = self._scale * other
res._str = Units._strRaiseToPower(self._str, other)
return res
def _divideWith(self, other):
"""Combine self with an other Units object by division"""
res = Units('')
res._exponents += self._exponents
res._exponents -= other._exponents
res._scale = self._scale - other._scale
res._str = f'{self._str} {Units._strRaiseToPower(other._str, -1)}'.strip()
return res
@staticmethod
def _strRaiseToPower(sstr, power):
if len(sstr) > 0:
if ' ' in sstr or '(' in sstr:
return f'({sstr})^{power}'
else:
if '^' in sstr:
powind = sstr.index('^')
newpow = int(sstr[powind+1:]) * power
return f'{sstr[:powind]}^{newpow}'
else:
return f'{sstr}^{power}'
else:
return ''
def __eq__(self, other):
return (
isinstance(other, Units) and
(self._exponents == other._exponents).all() and
self._scale == other._scale
)
def __hash__(self):
return hash((self._str, self._scale, tuple(self._exponents)))
def __repr__(self):
return self._str
def _compatibleWith(self, other):
if not isinstance(other, Units):
raise TypeError(f'Expected a Units object, got {other} instead.')
return (self._exponents == other._exponents).all()
def _isDimensionless(self):
return (self._exponents == 0).all() and self._scale == 0
def _toUnicode(self):
"""Return a unicode string representing the unit."""
_SUPER_CHAR_MAP = {str(i): c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}
_SUPER_CHAR_MAP['-'] = '⁻'
def prefix(val):
if val == 'u':
return 'μ'
else:
return val
res = Units._SCALED_UNIT_RE.sub(
lambda m: prefix(m.group(1)) + m.group(2) + m.group(3),
self._str,
)
res = Units._EXPONENT_RE.sub(
lambda m: ''.join(_SUPER_CHAR_MAP[c] for c in m.group(0)[1:]),
res,
)
return res
def _toLatex(self):
"""Return a LaTeX formated string representing the unit.
Uses the siunitx latex package"""
def prefix(val):
if val == 'u':
return r'\micro '
else:
return val
res = Units._EXPONENT_RE.sub(
lambda m: '^{' + m.group(1) + '}',
self._str,
)
res = Units._SCALED_UNIT_RE.sub(
lambda m: prefix(m.group(1)) + m.group(2) + m.group(3),
res,
)
res = r'\si{' + res + '}'
res = res.replace(' ', '.')
res = res.replace(r'\micro.', r'\micro ')
return res
class ParameterizedObject:
"""Base class for all objects holding Parameter objects
Classes that inherit from ParameterizedObject can declare parameters that will then be used to
construct parameter tables. The simplest way to declare parameters is to decorate properties getter
and setter with RegisterGetter and RegisterSetter respectively (see examples in geom.Compartment
e.g.). This allows custom code to be executed during the calls to getter and setters.
It is also possible to declare parameters that are not associated to custom code by using
cls._registerParameter(...). It will simply add the corresponding property along with its getter
and setter.
Note that if the setter is never called, the parameter is never added to self._parameters and will
thus not be part of parameter tables. This is the desired behavior as we do not want to consider that
e.g. 'Vol' is a parameter for tetrahedral compartments. Since it is never set, it will not be treated as
a parameter but simply as a property.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Simple static parameters
self._parameters = {}
def _getAllParams(self):
"""Return all parameters"""
return list(self._parameters.values())
def _getSubParameterizedObjects(self):
"""Return all subobjects that can hold parameters."""
return []
def _getParameter(self, key):
"""Get a parameter"""
return self._parameters[key] if key in self._parameters else None
def _setParameter(self, key, param, units=None):
"""Set a parameter"""
if not isinstance(param, Parameter):
param = Parameter(param, units, name='')
self._parameters[key] = param
def _includeinParamTables(self):
"""Whether the object should be included in parameter tables"""
return True
@classmethod
def _getDisplayName(cls):
"""Name that will be used as column title during parameter export
Defaults to class name.
"""
return cls.__name__
@staticmethod
def RegisterGetter(units=Units('')):
"""
Decorator for wrapping the getter of a property so that the correct parameter value is
returned. Since parameters are kept on the python side, this means that the cython bindings
do not need to implement the corresponding getters.
:meta private:
"""
def wrapper(func, name=None):
def getter(self):
propName = func.__name__ if name is None else name
param = self._getParameter(propName)
if param is not None:
# Get expected units
# It needs to be done here because the unit can depend on the object.
if hasattr(units, '__call__'):
_units = units(self)
else:
_units = units
if _units is None:
return param.value
else:
return param.valueIn(_units)
else:
# If the property was not set, call the getter. This means that properties
# that were not explicitely set will not be listed as parameters in parameter
# tables.
return func(self)
getter.__doc__ = func.__doc__
getter._units = units
return getter
return wrapper
@staticmethod
def RegisterSetter(units=Units('')):
"""
Decorator for wrapping the setter of a property so that parameter objects are
automatically added to self._parameters dict.
:meta private:
"""
def wrapper(func, name=None):
def setter(self, val):
# Get expected units
# It needs to be done here because the unit can depend on the object.
if hasattr(units, '__call__'):
_units = units(self)
else:
_units = units
# Handle parameter
if not isinstance(val, Parameter):
val = Parameter(val, name='', units=_units)
elif val._units is None:
val._units = _units
if val._units is not None and not val._units._compatibleWith(_units):
raise Exception(
f'Expected a value in "{_units}", got a value in "{val._units}" instead'
)
# Call the actual setter (if RegisterSetter was used as decorator)
if func is not None:
func(self, val.valueIn(_units))
# Only add the property if the setter did not raise an exception
propName = func.__name__ if name is None else name
self._setParameter(propName, val)
setter.__doc__ = func.__doc__
setter._units = units
return setter
return wrapper
@classmethod
def RegisterParameter(cls, name, units, defVal=None, addSetter=True):
"""
Register a simple parameter that does not require custom code execution for setting or getting.
If custom code execution is needed, RegisterGetter and RegisterSetter should instead be used as
decorators of properties.
Optionally supply a default value that will be returned if the parameter is not set.
:meta private:
"""
fget = ParameterizedObject.RegisterGetter(units=units)(lambda x: defVal, name=name)
fset = ParameterizedObject.RegisterSetter(units=units)(None, name=name) if addSetter else None
# Add the parameter as a property
setattr(cls, name, property(fget, fset))
@staticmethod
def SpecifyUnits(*units, **kwunits):
"""
Wrapper decorator for specifying units of parameters.
:meta private:
"""
def wrapper(func):
def newFunc(*args, **kwargs):
newArgs = []
for arg, unit in itertools.zip_longest(args, units):
if isinstance(arg, Parameter):
if unit is not None:
newArgs.append(arg.valueIn(unit))
elif arg._units is None or arg._units._isDimensionless():
newArgs.append(arg._value)
else:
raise Exception(
f'Expected a non-dimensional value, got a value in {arg.units} '
f'instead.'
)
else:
newArgs.append(arg)
newKwargs = {}
for key, arg in kwargs.items():
unit = kwunits.get(key, None)
if isinstance(arg, Parameter):
if unit is not None:
newKwargs[key] = arg.valueIn(units)
elif arg._units is None or arg._units._isDimensionless():
newKwargs[key] = arg._value
else:
raise Exception(
f'Expected a non-dimensional value, got a value in {arg.units} '
f'instead.'
)
else:
newKwargs[key] = arg
return func(*newArgs, **newKwargs)
return newFunc
return wrapper
class AdvancedParameterizedObject(ParameterizedObject):
"""Base class for parameterized objects with more complex key for parameters
This is usefull for objects that have parameter values that can change several times during
a single run, e.g. simulation path values.
Parameters held in this class have a description that is more complex than just a string name
The key in the self._parameters dict should be a tuple with the following format:
(('prop1', val1), ('prop2', 'val2'), ...)
"""
_ADV_PARAMS_DEFAULT_NAME = 'Name'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _getParameter(self, name):
"""Get a parameter"""
return super()._getParameter(self._getCurrentParamKey() + ((self._ADV_PARAMS_DEFAULT_NAME, name),))
def _setParameter(self, name, param, units=None):
"""Set a parameter"""
key = self._getCurrentParamKey()
super()._setParameter(key + ((self._ADV_PARAMS_DEFAULT_NAME, name),), param, units=units)
def _getCurrentParamKey(self):
"""Return a key representing the current state of the object"""
raise NotImplementedError()
@classmethod
def _getGroupedAdvParams(cls):
"""Return a list of advanced parameter property that should be grouped
"""
return []
[docs]class Parameter:
r"""Class to describe a parameter in a STEPS script
This class allows the user to declare values as parameters of the script. Parameter objects
can have units (see example below) and are used to create STEPS objects or to set some of
their properties. Declaring Parameter objects makes it possible for the user to do automatic
unit conversions and to give a name to a parameter that is used with several STEPS objects.
When parameter tables are automatically generated using :py:func:`ExportParameters`, the
informations that the user supplied to the ``Parameter`` objects will be displayed in the tables.
:param value: The value of the parameter
:type value: Any but will mostly be used with float
:param units: A string describing the unit of the parameter (see below for more explanations).
If it is not supplied, the parameter will infer its units when used with a STEPS object.
:type units: str
:param name: A user-supplied name for the parameter. If it is not given, the Parameter object
will try to take its name from the name of the variable it is assigned to.
:type name: str
:param \*\*kwargs: Keyword arguments that will be displayed in parameter tables.
Usage::
k_on = Parameter(2, 'uM^-1 s^-1')
k_off = Parameter(1, 's^-1', comment='Comment about the k_off parameter')
...
with mdl, vsys:
SA + SB <r[1]> SC
r[1].K = k_on, k_off
In the above example, ``k_on`` will internally be converted to the appropriate STEPS unit (M^-1 s^-1)
and both ``k_on`` and ``k_off`` will appear in the parameter tables generated from
:py:func:`ExportParameters` with their name and associated units. In addition, any other keyword
arguments (``comment=...`` here) will also be shown in the parameter tables.
When given to a STEPS object, if a Parameter is declared with a unit, a test will be performed to
check whether the given unit is of the expected physical dimension. If not, an exception will be
raised. It is thus good practise to specify the units of parameters as a sanity check.
Note that Parameters can be combined using standard arithmetic operators ``+``, ``-``, ``*``, ``/``,
``**`` (see :py:func:`Parameter.__add__`, etc.) to create new Parameters. These operations will then
be visible in the exported parameter tables (see :py:func:`ExportParameters`).
.. warning::
Converting between units should be done by calling the :py:func:`convertTo` method. For example,
to convert a Parameter in V to mV, one should use::
pot = Parameter(-0.07, 'V')
potInmV = pot.convertTo('mV')
One should never use arithmetic operations like ``pot * 1e3`` which would yield a parameter of
-70 V instead.
Finally, note that when a Parameter is explicitely converted to a number, it will yield its value in SI
units::
>>> pot = Parameter(-70, 'mV')
>>> float(pot)
-0.07
Units specification:
The string that specifies the units should be composed of physical units (see available units below)
optionally raised to some integer power and optionally prefixed with a scale (``'u'`` for micro,
``'k'`` for kilo, etc. see list below).
Examples::
'mm^2' # Square millimeters
'uM^-1 s^-1' # Per micromolar per second
'(mol m^-2)^-1 s^-1' # Per (moles per square meters) per second
'mV' # Millivolts
Note that groups of units can be wrapped in parentheses and raised to a power. This enhances
the readability of some units. Reaction rates for surface-surface reactions of order 2 thus have
units of ``'(mol m^-2)^-1 s^-1'`` which is equivalent to ``'mol^-1 m^2 s^-1'`` but the former
makes it clear that a surface 'concentration' is involved.
Available prefixes:
{prefixTable}
Available units:
{unitTable}
"""
_NAME_EXTRACT_RE = re.compile(r'(?:(?:^\s*(\w+)\s*=\s*)|.*)Parameter(\(.*$)')
__slots__ = ['_defLoc', '_name', '_fullname', '_value', '_units', '_kwargs', '_composedPrio', '_dependencies']
_PARAMETER_USAGE_RECORD = None
def __init__(self, value, units=None, name=None, _composedPrio=0, _dependencies=[], **kwargs):
self._defLoc = None
self._fullname = None
if name is None:
# Call from user, try to infer name from variable name
frameInfo = inspect.stack()[1]
fname, lineno = frameInfo.filename, frameInfo.lineno
fullLine = ''
line = linecache.getline(fname, lineno)
match = None
# Discard lines that correspond only to arguments to Parameter()
while lineno >= 0 and (line.endswith('\\\n') or match is None):
fullLine = line.rstrip('\\\n') + fullLine
match = Parameter._NAME_EXTRACT_RE.match(fullLine)
lineno -= 1
line = linecache.getline(fname, lineno)
if match is not None and match.group(1) is not None:
# Check that there is only one parameter object on the rhs
pcount = 0
ind = 0
parStr = match.group(2)
for i, c in enumerate(parStr):
if c == '(':
pcount += 1
elif c == ')':
pcount -= 1
if pcount == 0:
ind = i
break
if re.match(r'^(\s*[;#].*|\s*)$', parStr[ind+1:]) is not None:
# If there is indeed only one parameter on the rhs
self._name = match.group(1)
self._defLoc = (fname, lineno + 1)
else:
self._name = None
else:
# If it fails, keep None
self._name = None
elif isinstance(name, str):
self._name = name if len(name) > 0 else None
else:
raise TypeError(f'Expected a string, got {name} instead')
if isinstance(units, str):
units = Units(units)
elif units is not None and not isinstance(units, Units):
raise TypeError(f'Expected a string or None, got {units} instead.')
if isinstance(value, Parameter):
# If we are creating a new parameter from some combination of parameters
self._fullname = value._name
_dependencies = _dependencies + [value]
if units is not None:
value = value.valueIn(units)
else:
units = value._units
value = value._value
self._value = value
self._units = units
self._kwargs = {}
for key in kwargs.keys():
if hasattr(self, key):
raise Exception(f'Cannot use a keyword parameter named {key}.')
self._kwargs = kwargs
self._composedPrio = _composedPrio
self._dependencies = _dependencies
@property
def name(self):
"""Name of the parameter
:type: str, read-only
"""
return self._name
@property
def value(self):
"""Value of the parameter (in its units)
:type: Any (usually float), read-only
"""
return self._value
@property
def units(self):
"""Units of the parameter
:type: Union[str, None], read-only
"""
return str(self._units) if self._units is not None else None
[docs] def convertTo(self, unit):
"""Convert a parameter to the specified unit
:param unit: The unit to be converted to, it needs to be compatible with the units
of the parameter.
:type unit: str
:returns: The converted parameter.
:rtype: :py:class:`Parameter`
"""
return self._convertTo(Units(unit))
def valueIn(self, unit):
"""Return the value of the parameter in the specified unit.
If the parameter is itself a parameterized object, do not attempt any conversion.
:meta private:
"""
if self.value is None:
return None
elif isinstance(self.value, ParameterizedObject):
return self.value
elif self._units is not None:
if not self._units._compatibleWith(unit):
raise Exception(f'Expected "{unit}", got "{self._units}" instead.')
multiplier = 10**int(self._units._scale - unit._scale)
# Avoid extending lists and tuples, apply the multiplication to each element
if isinstance(self.value, (list, tuple)):
return self.value.__class__(v * multiplier for v in self.value)
else:
return self.value * multiplier
elif unit is None:
return self.value
else:
raise Exception(f'Expected a nondimensional unit, got {unit} instead.')
def _valueInSI(self):
"""Return the value of the parameter in SI unit system
Beware that STEPS uses M (molar) for concentration while SI uses mol m^-3
"""
if self._units is not None:
return self._value * 10**self._units._scale
return self._value
def _convertTo(self, unit):
"""Return a new parameter with the specified unit
Take a Unit object instead of a string.
"""
return Parameter(
self.valueIn(unit),
unit,
name=self._name if self._isNamed() else '',
_composedPrio=self._composedPrio,
_dependencies=self._dependencies,
)
def _isNamed(self):
return self._name is not None
def _isUserDefined(self):
return self._composedPrio == 0
def _checkUsableInOp(self):
"""Return whether self can be used in an arithmetic operation"""
if self._units is None:
raise Exception(f'Cannot use a parameter with undefined units in arithmetic operations.')
# Record parameter usage, useful for knowing which parameters are used in e.g. VDepRate.
if Parameter._PARAMETER_USAGE_RECORD is not None:
lst, depth = Parameter._PARAMETER_USAGE_RECORD
lst[depth].append(self)
def _getAllDependencies(self, alreadyUsed=None):
"""Recursively return all Parameters dependencies"""
if alreadyUsed is None:
alreadyUsed = set()
for param in self._dependencies:
if param not in alreadyUsed:
alreadyUsed.add(param)
yield param
yield from param._getAllDependencies(alreadyUsed=alreadyUsed)
[docs] def __getattr__(self, name):
"""Access properties of the parameter as if they were attributes
Keywords arguments passed to the constructor of Parameter can then be accessed as if they were
attributes::
>>> k_on = Parameter(2, 'uM^-1 s^-1', comment='Comment about k_on')
>>> k_on.comment
'Comment about k_on'
Note that the properties can only be defined at object creation and are all read-only.
:meta public:
"""
if not name.startswith('__') and name in self._kwargs:
return self._kwargs[name]
else:
raise AttributeError(f'Attribute "{name}" was not declared for the Parameter.')
def __eq__(self, other):
return (
isinstance(other, Parameter) and
(self._name, self._value, self._units, tuple(self._kwargs.items())) ==
(other._name, other._value, other._units, tuple(other._kwargs.items()))
)
def __hash__(self):
return hash((self._name, self._value, self._units, tuple(self._kwargs.items())))
[docs] def __add__(self, other, _inv=False):
"""Add Parameters with the ``+`` operator
:param other: The other Parameter or a number
:type other: Union[:py:class:`Parameter`, float]
:returns: The Parameter resulting from the addition of both operands. If both
operands are Parameters, the units of the result matches the units of the leftmost
named parameter. If one operand is a number, it implicitely takes the units of the other
operand. The name of the returned Parameter describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(1, 'uM s^-1')
>>> param2 = Parameter(2, 'nM ms^-1')
>>> param3 = param1 + param2
>>> param3.units
'uM s^-1'
>>> param3.value
3
>>> param3.name
'param1 + param2'
:meta public:
"""
if isinstance(other, numbers.Number):
other = Parameter(other, self._units, name='')
elif not isinstance(other, Parameter):
raise TypeError(f'Expected a Parameter or a number, got {other} instead.')
self._checkUsableInOp()
other._checkUsableInOp()
deps = [obj for obj in [self, other] if obj._isNamed()]
if not self._isNamed() and other._isNamed():
units = other._units
self = self._convertTo(units)
else:
units = self._units
other = other._convertTo(units)
val = self._value + other._value
if not self._isNamed() and not other._isNamed():
name = ''
else:
name = f'{other} + {self}' if _inv else f'{self} + {other}'
return Parameter(val, units, name=name, _composedPrio=3, _dependencies=deps)
[docs] def __sub__(self, other, _inv=False):
"""Subtract Parameters with the ``-`` operator
:param other: The other Parameter or a number
:type other: Union[:py:class:`Parameter`, float]
:returns: The Parameter resulting from the subtraction of both operands. If both
operands are Parameters, the units of the result matches the units of the leftmost
named parameter. If one operand is a number, it implicitely takes the units of the other
operand. The name of the returned Parameter describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(1, 'uM')
>>> param2 = Parameter(100, 'nM')
>>> param3 = param1 - param2
>>> param3.units
'uM'
>>> param3.value
0.9
>>> param3.name
'param1 - param2'
:meta public:
"""
if isinstance(other, numbers.Number):
other = Parameter(other, self._units, name='')
elif not isinstance(other, Parameter):
raise TypeError(f'Expected a Parameter or a number, got {other} instead.')
self._checkUsableInOp()
other._checkUsableInOp()
deps = [obj for obj in [self, other] if obj._isNamed()]
if not self._isNamed() and other._isNamed():
units = other._units
self = self._convertTo(units)
else:
units = self._units
other = other._convertTo(units)
val = (other._value - self._value) if _inv else (self._value - other._value)
if not self._isNamed() and not other._isNamed():
name = ''
else:
selfStr = f'({self})' if self._composedPrio >= 3 else str(self)
otherStr = f'({other})' if other._composedPrio >= 3 else str(other)
name = f'{other} - {selfStr}' if _inv else f'{self} - {otherStr}'
return Parameter(val, units, name=name, _composedPrio=3, _dependencies=deps)
[docs] def __mul__(self, other, _inv=False):
"""Multiply Parameters with the ``*`` operator
:param other: The other Parameter or a number
:type other: Union[:py:class:`Parameter`, float]
:returns: The Parameter resulting from the multiplication of both operands. If both
operands are Parameters, the units of the result consists in the product of the units
of the operands. If one operand is a number, it is implicitely treated as dimensionless.
The name of the returned Parameter describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(1, 'uM')
>>> param2 = Parameter(5, 's^-1')
>>> param3 = param1 * param2
>>> param3.units
'uM s^-1'
>>> param3.value
5
>>> param3.name
'param1 * param2'
:meta public:
"""
if isinstance(other, numbers.Number):
other = Parameter(other, '', name='')
elif not isinstance(other, Parameter):
raise TypeError(f'Expected a Parameter or a number, got {other} instead.')
self._checkUsableInOp()
other._checkUsableInOp()
deps = [obj for obj in [self, other] if obj._isNamed()]
if _inv:
units = other._units._multiplyWith(self._units)
else:
units = self._units._multiplyWith(other._units)
val = self._value * other.value
if not self._isNamed() and not other._isNamed():
name = ''
else:
selfStr = f'({self})' if self._composedPrio > 2 else str(self)
if not self._isNamed() and self._units is not None and len(self.units) > 0:
selfStr = f'({selfStr} {self.units})'
otherStr = f'({other})' if other._composedPrio > 2 else str(other)
if not other._isNamed() and other._units is not None and len(other.units) > 0:
otherStr = f'({otherStr} {other.units})'
name = f'{otherStr} * {selfStr}' if _inv else f'{selfStr} * {otherStr}'
return Parameter(val, units, name=name, _composedPrio=2, _dependencies=deps)
[docs] def __truediv__(self, other, _inv=False):
"""Divide Parameters with the ``/`` operator
:param other: The other Parameter or a number
:type other: Union[:py:class:`Parameter`, float]
:returns: The Parameter resulting from the division of both operands. If both
operands are Parameters, the units of the result consists in the division of the units
of the operands. If one operand is a number, it is implicitely treated as dimensionless.
The name of the returned Parameter describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(1, 'uM')
>>> param2 = Parameter(5, 's')
>>> param3 = param1 / param2
>>> param3.units
'uM s^-1'
>>> param3.value
0.2
>>> param3.name
'param1 / param2'
:meta public:
"""
if isinstance(other, numbers.Number):
other = Parameter(other, '', name='')
elif not isinstance(other, Parameter):
raise TypeError(f'Expected a Parameter or a number, got {other} instead.')
self._checkUsableInOp()
other._checkUsableInOp()
deps = [obj for obj in [self, other] if obj._isNamed()]
if _inv:
units = other._units._divideWith(self._units)
val = other._value / self.value
else:
units = self._units._divideWith(other._units)
val = self._value / other.value
if not self._isNamed() and not other._isNamed():
name = ''
else:
selfStr = f'({self})' if self._composedPrio > 2 else str(self)
if not self._isNamed() and self._units is not None and len(self.units) > 0:
selfStr = f'({selfStr} {self.units})'
otherStr = f'({other})' if other._composedPrio > 2 else str(other)
if not other._isNamed() and other._units is not None and len(other.units) > 0:
otherStr = f'({otherStr} {other.units})'
name = f'{otherStr} / {selfStr}' if _inv else f'{selfStr} / {otherStr}'
return Parameter(val, units, name=name, _composedPrio=2, _dependencies=deps)
[docs] def __pow__(self, power):
"""Raise to an integer power with the ``**`` operator
:param power: The integer power
:type power: int
:returns: The Parameter resulting from raising self to the given power. The units of the
result are the units of self raised to power. The name of the returned Parameter
describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(2, 'um')
>>> param2 = param1 ** 3
>>> param2.units
'um^3'
>>> param2.value
8
>>> param2.name
'param1 ** 3'
:meta public:
"""
if not isinstance(power, int):
raise TypeError(f'Expected an integer, got {power} instead.')
self._checkUsableInOp()
deps = [self] if self._isNamed() else []
units = self._units._raiseToPower(power)
val = self._value ** power
if self._isNamed():
name = f'({self}) ** {power}' if self._composedPrio > 1 else f'{self} ** {power}'
else:
name = ''
return Parameter(val, units, name=name, _composedPrio=1, _dependencies=deps)
def __radd__(self, other):
return self.__add__(other, _inv=True)
def __rsub__(self, other):
return self.__sub__(other, _inv=True)
def __rmul__(self, other):
return self.__mul__(other, _inv=True)
def __rtruediv__(self, other):
return self.__truediv__(other, _inv=True)
[docs] def __round__(self, ndigits=None):
"""Round parameter value in SI
:param ndigits: Optional, number of digits after the decimal point, defaults to None (i.e.
rounds to nearest integer).
:type ndigits: int
:returns: The Parameter resulting from the rounding to ndigits digits. When rounding a parameter,
its value is first converted to SI and the SI value is rounded. The returned parameter keeps
the same units. The name of the returned Parameter describes the operation.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(12.7, 'dm')
>>> param2 = round(param1)
>>> param2.units # The units are unchanged
'dm'
>>> param2.value # 12.7 dm is first converted to 1.27 m and rounded to 1 m = 10 dm
10
>>> param2.name
'round(param1)'
>>> round(param1, 1).value # Keep more digits: 1.27 m rounded to 1.3 m = 13 dm
13.0
:meta public:
"""
self._checkUsableInOp()
deps = [self] if self._isNamed() else []
units = self._units
if self._units is not None:
scl = self._units._scale
val = round(self._value * 10 ** scl, ndigits) * 10 ** -scl
else:
val = round(self._value, ndigits)
if self._isNamed():
name = f'round({self}' + (f', {ndigits}' if ndigits is not None else '') + ')'
else:
name = ''
return Parameter(val, units, name=name, _composedPrio=0, _dependencies=deps)
[docs] def __neg__(self):
"""Unary negative operator
:returns: The Parameter resulting from multiplication of its value by -1.
:rtype: :py:class:`Parameter`
Usage::
>>> param1 = Parameter(2, 'mV')
>>> param2 = -param1
>>> param2.units
'mV'
>>> param2.value
-2
>>> param2.name
'-param1'
:meta public:
"""
self._checkUsableInOp()
deps = [self] if self._isNamed() else []
units = self._units
val = -1 * self.value
if self._isNamed():
name = f'-({self})' if self._composedPrio > 2 else f'-{self}'
else:
name = ''
return Parameter(val, units, name=name, _composedPrio=2, _dependencies=deps)
def __float__(self):
return float(self._valueInSI())
def __int__(self):
return int(self._valueInSI())
def __bool__(self):
return bool(self._value)
def __repr__(self):
if self._isNamed():
return self._name
else:
return f'{self.value}'
@classmethod
def _startRecording(cls):
if cls._PARAMETER_USAGE_RECORD is None:
cls._PARAMETER_USAGE_RECORD = [[[]], 0]
else:
cls._PARAMETER_USAGE_RECORD[0].append([])
cls._PARAMETER_USAGE_RECORD[1] += 1
@classmethod
def _endRecordingAndGetUsage(cls):
lst, depth = cls._PARAMETER_USAGE_RECORD
res = []
for i in range(depth, len(lst)):
res += lst[i]
if depth == 0:
cls._PARAMETER_USAGE_RECORD = None
else:
cls._PARAMETER_USAGE_RECORD[1] -= 1
return res
def _extractParameterized(obj, pre=tuple(), exploredObjs=None):
"""Yield tuples that describe a path to parameterized objects."""
if exploredObjs is None:
exploredObjs = set()
if isinstance(obj, ParameterizedObject):
# Only yield obj if it should be included in param tables
if obj._includeinParamTables():
yield pre + (obj,)
if obj not in exploredObjs:
# Check subobjects
for subObj in obj._getSubParameterizedObjects():
yield from _extractParameterized(subObj, pre, exploredObjs)
# Check Parameters that are themselves Parameterized
for param in obj._getAllParams():
if isinstance(param, Parameter) and isinstance(param._value, ParameterizedObject):
yield from _extractParameterized(param._value, pre, exploredObjs)
if isinstance(obj, NamedObject) and obj not in exploredObjs:
# Handle children
for subObj in obj._getChildrenOfType(ParameterizedObject, NamedObject):
yield from _extractParameterized(subObj, pre + (obj,), exploredObjs)
exploredObjs.add(obj)
def _dictRowsToTable(rowList):
"""
Take a list of rows in which each row is a list of key-value pair and output a
list of column name and a list of rows in which rows just contain the value associated
with the column or None if it is not defined for this row.
Discard columns that only contain None.
"""
colNames = {}
ind = 0
for row in rowList:
for cname, val in row.items():
if cname not in colNames and val is not None:
colNames[cname] = ind
ind += 1
table = []
for row in rowList:
values = [None] * len(colNames)
for cname, val in row.items():
if val is not None:
values[colNames[cname]] = val
table.append(tuple(values))
return colNames, table
def _tableToCSV(rowList, filename, sep='\t', **kwargs):
"""Export a single table to a CSV file
Return the path to the exported file.
"""
colNames, table = _dictRowsToTable(rowList)
filePath = f'{filename}.csv'
with open(filePath, 'w') as f:
f.write(sep.join(_formatValue(cn, bold=True, **kwargs) for cn in colNames) + '\n')
for row in table:
f.write(sep.join(map(str, [val if val is not None else '' for val in row])) + '\n')
return filePath
_TEXT_FORMAT_UNICODE = 'unicode'
_TEXT_FORMAT_LATEX = 'latex'
def _formatValue(value, **kwargs):
"""Return a formated string to be used in parameter tables"""
textFormat = kwargs.get('textFormat', _TEXT_FORMAT_UNICODE)
numPrecision = kwargs.get('numPrecision', 10)
isComputation = kwargs.get('isComputation', False)
funcRelFilePath = kwargs.get('funcRelFilePath', True)
latexPrefix = kwargs.get('latexPrefix', None)
bold = kwargs.get('bold', False)
if isinstance(value, Parameter):
# Do not modify Parameter objects, they will be expanded later
return value
elif hasattr(value, '__code__'):
filename = value.__code__.co_filename
m = re.match(r'<ipython-input-(\d)+-', filename)
if m is not None:
filename = f'ipython-{m.group(1)}'
if textFormat == _TEXT_FORMAT_LATEX:
qualName = _formatValue(value.__qualname__, **kwargs)
if funcRelFilePath:
filename = os.path.relpath(filename)
filename = _formatValue(filename, **kwargs)
return f'\\texttt{{{qualName}(...)}} at \\texttt{{{filename}}}:{value.__code__.co_firstlineno}'
else:
return f'{value.__qualname__}(...) at {filename}:{value.__code__.co_firstlineno}'
elif isinstance(value, _ParamList):
# Try to match simple patterns
if len(value) > 2:
if isinstance(value[0], numbers.Number):
srtList = sorted(value)
step = srtList[1] - srtList[0]
if srtList == list(numpy.arange(srtList[0], srtList[-1] + step, step)):
start = _formatValue(srtList[0], **kwargs)
finish = _formatValue(srtList[-1], **kwargs)
stp = _formatValue(step, **kwargs)
return f'{start} to {finish}' + (f' step {stp}' if step != 1 else '')
fvalues = [_formatValue(v, **kwargs) for v in value]
return ', '.join(fv for fv in fvalues if fv is not None)
elif isinstance(value, steps.API_2.model._SubReactionList):
if textFormat == _TEXT_FORMAT_LATEX:
if value._isFwd:
return f'\\detokenize{{{value._parent.lhs}}} $\\rightarrow$~\\detokenize{{{value._parent.rhs}}}'
else:
return f'\\detokenize{{{value._parent.rhs}}} $\\rightarrow$~\\detokenize{{{value._parent.lhs}}}'
else:
return str(value)
elif isinstance(value, Units):
if textFormat == _TEXT_FORMAT_UNICODE:
return value._toUnicode()
elif textFormat == _TEXT_FORMAT_LATEX:
return value._toLatex()
else:
return value._str
elif isinstance(value, numbers.Number):
if textFormat == _TEXT_FORMAT_LATEX:
return f'\\num{{{value:.{numPrecision}g}}}'
else:
return f'{value:.{numPrecision}g}'
elif value is None:
return None
else:
if textFormat == _TEXT_FORMAT_LATEX:
ret = str(value)
if isComputation:
ret = re.sub(r' \*\* ([0-9]+)', lambda m: f'^{{{m.group(1)}}}', ret)
ret = re.sub(r'([a-zA-Z]\w+)', lambda m: f'\\mathrm{{\\detokenize{{{m.group(1)}}}}}', ret)
ret = re.sub(r'([0-9]+\.[0-9]+)', lambda m: f'{float(m.group(1)):{numPrecision}g}', ret)
ret = re.sub(r'([^\s]+) / ([^\s]+)', lambda m: f'\\frac{{{m.group(1)}}}{{{m.group(2)}}}', ret)
ret = ret.replace('*', '\\times')
ret = f'${ret}$'
elif latexPrefix is None or not ret.startswith(latexPrefix):
toEscape = '#_^&%${}'
for c in toEscape:
ret = ret.replace(c, f'\\{c}')
else:
ret = ret[len(latexPrefix):]
if bold:
ret = f'\\textbf{{{ret}}}'
return ret
else:
return str(value)
class _ParamList:
"""Utility class for parameter grouping"""
def __init__(self, lst):
self.lst = lst
def __iter__(self):
return iter(self.lst)
def __getitem__(self, i):
return self.lst[i]
def __hash__(self):
return hash(frozenset(self.lst))
def __eq__(self, other):
return isinstance(other, _ParamList) and frozenset(self.lst) == frozenset(other.lst)
def __repr__(self):
return 'PL' + str(self.lst)
def __len__(self):
return len(self.lst)
def _groupParams(params, groupingInds=None):
"""
Take a list of parameter tuples as input and outputs a list of tuples of _ParamLists
whose cartesian product yields the orginal parameter tuples.
"""
n = len(params[0])
if groupingInds is None:
groupingInds = list(range(n))
groupings = [params]
for i in range(n):
if i in groupingInds:
# group by n-1 column
val2Lst = {}
grouped = False
for row in params:
val = (row[:i], row[i + 1 :])
val2Lst.setdefault(val, []).append(row[i])
if len(val2Lst[val]) > 1:
grouped = True
if grouped:
newParams = [val[0] + (_ParamList(lst),) + val[1] for val, lst in val2Lst.items()]
groupings.append(_groupParams(newParams))
return min(groupings, key=lambda x: len(x))
def _exportToCSV(cls2Tables, cls2NamedParams, filename, **kwargs):
"""Export parameter data to a series of CSV files
One table per file, separate file for named parameters.
Return a list of paths to exported files
"""
allPaths = []
for cls, rowList in cls2Tables.items():
allPaths.append(_tableToCSV(rowList, f'{filename}_{cls._getDisplayName()}', **kwargs))
rowList = []
names = set()
for cls, rows in cls2NamedParams.items():
for row in rows:
if row['Name'] not in names:
rowList.append(row)
names.add(row['Name'])
if len(rowList) > 0:
allPaths.append(_tableToCSV(rowList, f'{filename}_Parameters', **kwargs))
return allPaths
def _exportToTEX(
cls2Tables, cls2NamedParams, filename,
csv2latexArgs = '-l 99999 -s t -x -r 2 -z -c 0.9 -e', **kwargs
):
"""Export parameter data to a series of TEX files
One table per file, separate file for named parameters.
Requires csv2latex to be installed.
Return a list of paths to exported files.
"""
allPaths = []
tmpPath = os.path.join(tempfile.gettempdir(), os.path.basename(filename))
csvPaths = _exportToCSV(cls2Tables, cls2NamedParams, tmpPath, **kwargs)
for path in csvPaths:
out = subprocess.check_output(
f'csv2latex {csv2latexArgs} {path}',
shell=True, universal_newlines=True
)
texPath = filename + path[len(tmpPath):-4] + '.tex'
with open(texPath, 'w') as f:
for line in out.split('\n'):
if 'documentclass' in line:
f.write('\\documentclass{standalone}\n')
continue
if line == '\\begin{document}':
f.write('\\usepackage{siunitx}\n')
f.write(line+'\n')
allPaths.append(texPath)
os.remove(path)
return allPaths
def _exportToPDF(
cls2Tables, cls2NamedParams, filename,
**kwargs
):
"""Export parameter data to a series of PDF files
One table per file, separate file for named parameters.
Requires csv2latex and pdflatex to be installed.
Return a list of paths to exported files.
"""
allPaths = []
tmpPath = os.path.join(tempfile.gettempdir(), os.path.basename(filename))
texPaths = _exportToTEX(cls2Tables, cls2NamedParams, tmpPath, **kwargs)
for path in texPaths:
tmpDirPath = os.path.dirname(os.path.abspath(path))
outDir = os.path.dirname(os.path.abspath(filename))
subprocess.call(f'pdflatex {path}', shell=True, cwd=tmpDirPath)
shutil.copy(path[:-4] + '.pdf', outDir)
allPaths.append(os.path.join(outDir, os.path.basename(path[:-4]) + '.pdf'))
os.remove(path)
return allPaths
[docs]def ExportParameters(container, filename, method='csv', hideComputations=False, hideColumns=[], unitsToSimplify=[], **kwargs):
r"""Export container parameters to tables
Extracts all parameters that are used in the container and its contained objects. Parameter
tables are then created for each object type.
:param container: The container whose parameters are going to be exported. Most of the time,
this will be the :py:class:`steps.API_2.sim.Simulation` object.
:type container: Any STEPS object
:param filename: Path and prefix for the exported table files (e.g. './parameter/Sim1' would
yield files like './parameter/Sim1_Parameters.csv', etc.).
:type filename: str
:param method: Specify which file format should be used. Available options are 'csv', 'tex',
and 'pdf'.
:type method: str
:param hideComputations: If true, arithmetic combinations of parameters will not be displayed
in the tables.
:type hideComputations: bool
:param hideColumns: List of column names that should not be displayed in the tables.
:type hideColumns: List[str]
:param unitsToSimplify: List of units that should always be displayed in this specific way if
equivalent units appear in the table. For example, some surface reaction rate constants are
better expressed as '(mol m^-2)^-1 s^-1' so we would give this string in the list to avoid
seeing e.g. 'mol^-1 m^2 s^-1' (which is equivalent but harder to read) in the parameter tables.
:type unitsToSimplify: List[str]
:param \*\*kwargs: Additional keyword arguments specified below.
Keyword arguements depend on the `method` used but some of them are common to all methods.
Common additional keyword arguments:
:param numPrecision: Number of significant digits to be displayed for floating point numbers.
:type numPrecision: int
:param funcRelFilePath: Use relative file path for specifying the source file of a function that
is used in a parameter. Defaults to `True`.
:type funcRelFilePath: bool
The 'csv' export method will export parameters as tab-separated values in a text file. Since some
cells can contain commas, the separator has been chosen to be tabs instead of the more common comma.
Additional keyword arguments for the 'csv' format:
:param textFormat: Either 'unicode' or 'latex', defaults to 'unicode'. 'latex' will introduce
latex formating in the cells, in case other csv to latex conversion programs need to be used.
:type textFormat: str
Both the 'tex' and 'pdf' export methods are experimental. The 'tex' method will export the parameters
to independent .tex files each containing one table. It requires the installation of the `csv2latex`
conversion program. The 'pdf' method will simply call `pdflatex` on the tex files resulting from the
'tex' method.
Additional keyword arguments for the 'tex' and 'pdf' formats:
:param csv2latexArgs: A string of command line arguments to be passed to `csv2latex`, see the
`manual page <http://manpages.ubuntu.com/manpages/bionic/man1/csv2latex.1.html>`_ for details.
Defaults to ``-l 99999 -s t -x -r 2 -z -c 0.9 -e``
:type csv2latexArgs: str
:param latexPrefix: If provided, special latex character will not be escaped for strings that are
prefixed with `latexPrefix`. For example, if we specified a `Source = '\cite{ArticleRef}'`
keyword argument to a `Parameter` object, the `{` and `}` characters will be escaped by default.
This can be prevented by using e.g. `latexPrefix='__'` and `Source = '__\cite{ArticleRef}'`.
:type latexPrefix: str
"""
# TODO improvement: Add param to restrict value setting to t=0 or maybe a function that takes time
# and returns whether it should be considered ?
# Force latex text format if needed
if method in ['tex', 'pdf']:
kwargs['textFormat'] = _TEXT_FORMAT_LATEX
# Insert the simplification of non-dimensional units
unitsToSimplify = [Units('')] + [Units(unitstr) for unitstr in unitsToSimplify]
# Extract all parameterized objects
allParameterizedPaths = list(_extractParameterized(container))
# If several paths end on the same object, only keep the longest
# If several paths have the same maximum length, keep track of all of them
uniquePaths = {}
for path in allParameterizedPaths:
key = id(path[-1])
if key in uniquePaths:
currLen = len(uniquePaths[key][0])
if len(path) < currLen:
continue
if len(path) > currLen:
del uniquePaths[key]
uniquePaths.setdefault(key, []).append(path)
# Group paths by class to establish tables
cls2Path = {}
for _, paths in uniquePaths.items():
cls2Path.setdefault(paths[0][-1].__class__, []).append(paths)
# Generate tables per class
cls2Tables = {}
cls2NamedParams = {}
for cls, allPathLists in cls2Path.items():
rowList = cls2Tables.setdefault(cls, [])
for pathsList in allPathLists:
# Get the corresponding object, they all end on the same
*_, obj = pathsList[0]
row = {}
row[cls._getDisplayName()] = _formatValue(obj, **kwargs)
if any(len(path) > 0 for *path, _ in pathsList):
defList = _ParamList(
[path[-2] for path in pathsList if len(path) > 1 and path[-2] is not container]
)
row['Defined in'] = _formatValue(defList, **kwargs)
if isinstance(obj, AdvancedParameterizedObject):
param2Tuples = {}
for tuples, param in obj._parameters.items():
param2Tuples.setdefault(param, []).append(tuples)
newRows = []
for param, tuples in param2Tuples.items():
propNames, propTable = _dictRowsToTable([{key:val for key, val in tples} for tples in tuples])
groupingInds = [i for i, pn in enumerate(propNames) if pn in cls._getGroupedAdvParams()]
groups = _groupParams(propTable, groupingInds=groupingInds)
for prod in groups:
newProd = {**row}
for lst, prop in zip(prod, propNames.keys()):
if not isinstance(lst, _ParamList):
lst = _ParamList([lst])
newProd[prop] = _formatValue(lst, **kwargs)
newRows.append({**newProd, 'Value': _formatValue(param, **kwargs)})
rowList += newRows
else:
rowList.append({
**row,
**{key: _formatValue(val, **kwargs) for key, val in obj._parameters.items()},
})
# Expand Parameters into more columns and keep track of named parameters
namedParams = []
for i, row in enumerate(rowList):
newRow = {}
for name, param in row.items():
if isinstance(param, Parameter):
if param._isNamed():
namedParams.append(param)
# Also add named parameters in dependency tree
namedParams += [dep for dep in param._getAllDependencies() if dep._isNamed()]
if not hideComputations or (param._isNamed() and param._isUserDefined()):
newRow[f'{name} Parameter'] = _formatValue(param._name, isComputation=True, **kwargs)
# Convert to standard units if available
value = param._value
units = param._units
if param._units is not None:
for u in unitsToSimplify:
if param._units._compatibleWith(u):
value = param.valueIn(u)
units = u
break
if value is not None:
newRow[f'{name}'] = _formatValue(value, **kwargs)
if units is not None:
newRow[f'{name} Units'] = _formatValue(units, **kwargs)
if not param._isNamed():
for pname, pval in param._kwargs.items():
newRow[f'{name} {pname}'] = _formatValue(pval, **kwargs)
else:
newRow[name] = param
rowList[i] = newRow
# Only keep named params that are not dependent on other params
# and add the missing params
expand = True
dontExpand = []
while expand:
newNamedParams = []
expand = False
for param in namedParams:
if len(param._dependencies) > 0 and param not in dontExpand:
for dep in param._dependencies:
if dep not in newNamedParams:
newNamedParams.append(dep)
if param._fullname is not None:
# Add the parameter itself if it was declared as a parameter explicitely
newNamedParams.append(param)
# But prevent it from being expanded in subsequent iterations
dontExpand.append(param)
expand = True
elif param not in newNamedParams:
newNamedParams.append(param)
namedParams = newNamedParams
cls2NamedParams[cls] = [
{
'Name':_formatValue(np._name, **kwargs),
'Value':_formatValue(np._value, **kwargs),
'Units':_formatValue(np._units, **kwargs) if np._units is not None else '',
'Computed From':_formatValue(np._fullname, isComputation=True, **kwargs),
**{key: _formatValue(val, **kwargs) for key, val in np._kwargs.items()}
} for np in namedParams
]
# Hide columns
for hc in hideColumns:
for clsmap in [cls2Tables, cls2NamedParams]:
for cls, table in cls2Tables.items():
for row in table:
try:
del row[hc]
except:
continue
if method == 'csv':
return _exportToCSV(cls2Tables, cls2NamedParams, filename, **kwargs)
elif method == 'tex':
return _exportToTEX(cls2Tables, cls2NamedParams, filename, **kwargs)
elif method == 'pdf':
return _exportToPDF(cls2Tables, cls2NamedParams, filename, **kwargs)
else:
raise NotImplementedError(f'Unknown export method "{method}".')
###################################################################################################
# The prefix used for naming classes in the cython bindings
_CYTHON_PREFIX = '_py_'
###################################################################################################
# STEPS objects utility classes
class SolverPathObject:
"""Base class for all objects susceptible to be part of a SimPath."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _solverStr(self):
"""Return the string that is used as part of method names for this specific object."""
return self.__class__.__name__
def _solverId(self):
"""Return the id that is used to identify an object in the solver."""
try:
return (self.name,)
except AttributeError:
return tuple()
def _solverKeywordParams(self):
"""Return the additional keyword parameters that should be passed to the solver"""
return {}
def _solverModifier(self):
"""Return None or a function that will modify the output from the solver."""
return None
def _solverSetValue(self, valName, v):
"""
Return the value that should actually be set in the solver when value 'v' is given by
the user.
"""
return v
def _simPathWalkExpand(self):
"""Return an iterable of the elements that should be part of ResultSelector paths."""
return [self]
def _simPathCombinerClass(self):
"""Return the class that needs to be used to combine expanded elements."""
return None
def _simPathAutoMetaData(self):
"""Return a dictionary with string keys and string or numbers values."""
return {}
def _simPathCheckParent(self):
"""
Determines whether the object needs to be in the parent's children in order to be added to
a simulation path
"""
return True
def __hash__(self):
return id(self)
class SolverRunTimeObject:
"""Base class for object whose value will only be evaluated at runtime"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _runTimeFunc(self, opType, sim, prefix, suffix, descriptor):
"""Return a function that will be called during runtime evaluation of the path."""
return None
class StepsWrapperObject:
"""Base class for all objects that are wrappers for steps objects."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _getStepsObjects(self):
"""Return a list of the steps objects that this named object holds. Should be overloaded."""
return []
@classmethod
def _FromStepsObject(cls, obj, *args, **kwargs):
"""Create the interface object from a STEPS object."""
raise NotImplementedError()
class KeyOrderedDict:
"""Utility class: dict in which iteration is ordered based on key values"""
def __init__(self):
self._dict = {}
self._keys = []
self._sorted = True
def keys(self):
return iter(self)
def items(self):
for key in self:
yield (key, self._dict[key])
def __contains__(self, key):
return key in self._dict
def __getitem__(self, key):
return self._dict[key]
def __setitem__(self, key, v):
if key not in self._dict:
self._keys.append(key)
self._sorted = False
self._dict[key] = v
def __delitem__(self, key):
del self._dict[key]
self._keys.remove(key)
def __iter__(self):
if not self._sorted:
self._keys.sort()
self._sorted = True
return iter(self._keys)
[docs]class NamedObject(SolverPathObject):
"""Base class for all objects that are named and can have children
:param name: Name of the object. If no name is provided, the object gets an automatically
generated name based on its class.
:type name: str
All classes that inherit from :py:class:`NamedObject` can be built with a ``name`` keyword
parameter. For steps objects, it corresponds to the identifiers used in STEPS. Note that some
names are forbidden because they correspond to names of attributes or methods of classes
defined in this interface. Since most objects implement ``__getattr__`` attribute style access
to children, the names of these methods / attributes could clash with object names. It is thus
possible that the contructor of :py:class:`NamedObject` raises an exception when trying to
name an object with one of these forbidden names.
In addition to a name, this class holds a list of children objects that can be accessed with
:py:func:`__getattr__` and :py:func:`ALL`.
.. note::
This class should not be instantiated by the user, it is only documented for clarity
since a lot of other classes inherit from it.
"""
_nameInds = {} # TODO Not urgent: use threading local?
_forbiddenNames = set()
_allowForbNames = False
_children = {} # Use default empty value to avoid infinite recursion in __getattr__
def __init__(self, *args, name=None, **kwargs):
super().__init__(*args, **kwargs)
if name is None:
self.name = self.__class__._GetDefaultName()
self._autoNamed = True
else:
self.name = name
self._autoNamed = False
if len(NamedObject._forbiddenNames) == 0:
NamedObject._loadForbiddenNames()
if self.name in NamedObject._forbiddenNames:
if NamedObject._allowForbNames:
warnings.warn(
f"'{self.name}' is a reserved name, SimPath functionnalities might "
f"be impacted because of this."
)
else:
raise Exception(f"Cannot call an element '{self.name}', this name is reserved")
self._children = KeyOrderedDict()
self._parents = {}
@classmethod
def _loadForbiddenNames(cls):
"""Load names that will not be allowed as NamedObject names.
This prevents cases in which a STEPS object would be inaccessible through SimPath, Simulation, or
ResultSelector, because its name is the same as the name of an attribute, a method or a property of
these classes (they have priority over __getattr__).
"""
from steps.API_2 import sim as nsim
from steps.API_2 import saving as nsaving
classes = [nsim.SimPath, nsim.Simulation, nsaving.ResultSelector, nsaving._ResultPath,
nsaving._ResultList, nsaving._ResultCombiner]
for cl in classes:
cls._forbiddenNames |= set(dir(cl))
def _addChildren(self, e):
if isinstance(e, NamedObject):
if e.name not in self.children:
self.children[e.name] = e
elif e is not self.children[e.name]:
raise Exception(f'An object is already named {e.name}')
else:
raise Exception('Only named objects can be added to UsableObjects.')
[docs] def __getattr__(self, name):
"""Access children of the objects as if they were attributes
See :py:class:`NamedObject` and :py:func:`Create` for details on object naming.
Assuming the object has a children named 'child1', one can access it as if it was an
attribute of the object::
obj.child1
:meta public:
"""
# TODO later release: revert this temporary change for split meshes
# if name.startswith('__'):
if name.startswith('__') and not name.startswith('__MESH'):
raise AttributeError
elif name in self.children:
return self.children[name]
raise AttributeError(f'{self} does not have an attribute named {name}')
def _getReferenceObject(self):
"""
Return the object this object was derived from. Useful for getting the complex associated
with a complex selector, etc.
"""
return self
@property
def children(self):
"""
Redirect the children to the reference object.
:meta private:
"""
return self._getReferenceObject()._children
def _getChildrenOfType(self, *cls):
"""Return all children who are instances of cls."""
for name, c in self.children.items():
if isinstance(c, cls):
yield c
[docs] def ALL(self, *cls):
r"""Return all children of the object, optionally filtered by class
Takes a variable number of parameters, if no parameters are given, it returns all children
of the object. Otherwise, if types are given, it returns the children that match at least
one of the given types.
:param \*cls: Variable number of classes
:returns: A generator that iterates over all children that match the class criteria.
:rtype: Generator[NamedObject]
Usage::
obj.ALL() # Return all children
obj.ALL(Species) # Return all children Species
obj.ALL(Reaction, Diffusion) # Return all children that are either Reaction or
# Diffusion
"""
return self._getChildrenOfType(*(cls if len(cls) > 0 else [object]))
@classmethod
def _GetDefaultName(cls):
"""Return an automatically generated name based on cls."""
if cls not in NamedObject._nameInds:
NamedObject._nameInds[cls] = 1
else:
NamedObject._nameInds[cls] += 1
return '{}{}'.format(cls.__name__, NamedObject._nameInds[cls])
[docs] @classmethod
def Create(cls, *args, **kwargs):
"""Auto naming syntax for simplifying the creation of named objects
Create one or several objects of class cls with a list of arguments and name them
automatically based on the name of the variables they are going to be assigned to.
Usage without arguments::
a, b, c = Class.Create()
# Equivalent to:
a, b, c = Class(name = 'a'), Class(name = 'b'), Class(name = 'c')
Usage with single arguments::
a, b, c = Class.Create(argA, argB, argC)
# Equivalent to:
a, b, c = Class(argA, name = 'a'), Class(argB, name = 'b'), Class(argC, name = 'c')
Usage with variable numbers of arguments::
a, b, c = Class.Create(argA, Params(argB1, argB2), Params(argC1, nargC2 = val))
# Equivalent to:
a, b, c = Class(argA, name = 'a'),\\
Class(argB1, argB2, name = 'b'),\\
Class(argC1, nargC2 = val, name = 'c')
Note that if a `name` keyword argument is provided to `Params(...)`, it will override the
name inferred from the destination variable.
Usage with global keyword argument::
a, b, c = Class.Create(argA, argB, argC, gkwarg=val)
# Equivalent to:
a, b, c = Class(argA, gkwarg=val, name = 'a'),\\
Class(argB, gkwarg=val, name = 'b'),\\
Class(argC, gkwarg=val, name = 'c')
.. warning::
This automatic naming syntax works by reading the source file to extract the name of
the variables. Because of this, modifying the source of a script while it is running
is highly discouraged if the automatic naming syntax is used. Notably, this synatx
WILL NOT work in an interactive python shell, in which there is no source file to
parse. It WILL work as expected in a jupyter notebook.
"""
# Automatic extraction of variable names
frameInfo1 = inspect.stack()[0]
frameInfo2 = inspect.stack()[1]
fname, lineno = frameInfo2.filename, frameInfo2.lineno
callLine = linecache.getline(fname, lineno).strip(' \t\n\\')
# Discard lines that correspond only to arguments to Create()
while lineno > 0 and '.Create(' not in callLine:
lineno -= 1
callLine = linecache.getline(fname, lineno)
lineno -= 1
currLine = linecache.getline(fname, lineno)
# Get the full line of variable names, in case line continuation is used
# We do not need to worry about comments since line continuation does not allow the use of
# comments before or after it.
while lineno > 1 and currLine.endswith('\\\n'):
callLine = currLine.strip(' \t\n\\') + callLine
lineno -= 1
currLine = linecache.getline(fname, lineno)
varNameExpr = '[_a-zA-Z][_a-zA-Z0-9]*'
p = re.compile(
rf'^\s*({varNameExpr}(,\s*{varNameExpr})*)\s*=\s*(?:{varNameExpr}\.)*'
rf'{cls.__name__}\.{frameInfo1.function}\(.*$'
)
m = p.match(callLine)
if m is not None:
names = [s.strip() for s in m.group(1).split(',')]
if len(args) == 0:
if len(names) > 1:
res = [cls(name=name, **kwargs) for name in names]
else:
res = cls(name=names[0], **kwargs)
elif len(names) == 1:
res = cls(*args, **kwargs, name=names[0])
elif len(args) == len(names):
res = []
for name, arg in zip(names, args):
if isinstance(arg, Params):
if 'name' in arg.kwargs:
res.append(cls(*arg.args, **arg.kwargs, **kwargs))
else:
res.append(cls(*arg.args, **arg.kwargs, **kwargs, name=name))
else:
res.append(cls(arg, **kwargs, name=name))
else:
raise Exception(
f'The number of arguments ({len(args)}) does not match the number '
f'of objects to be created ({len(names)}).'
)
return res
else:
raise Exception(
f'The line {callLine} does not match the expected format for automatic assignment.'
)
def __repr__(self):
return self.name if self._children is not NamedObject._children else super().__repr__()
[docs]class Params:
"""Container class for grouping arguments"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class UsableObject(NamedObject):
"""Base class for steps objects that can be used as context managers ('with' keyword)."""
_currUsed = {} # TODO Not urgent: use threading local?
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.__class__ not in UsableObject._currUsed:
UsableObject._currUsed[self.__class__] = self._getDefaultCurrUsedVal()
self._currentUsers = []
def _getDefaultCurrUsedVal(self):
return None
def _registerUser(self, cls, user, addAsElement):
self._currentUsers.append(user)
if addAsElement:
self._addChildren(user)
user._parents[cls] = self
for chld in user._getAdditionalChildren():
self._addChildren(chld)
chld._parents[cls] = self
def __enter__(self):
if UsableObject._currUsed[self.__class__] is None:
UsableObject._currUsed[self.__class__] = self
else:
raise Exception(f'Cannot use two {self.__class__.__name__} objects simultaneously.')
self._currentUsers = []
return self
def __exit__(self, exc_type, exc_val, exc_tb):
UsableObject._currUsed[self.__class__] = None
if (exc_type, exc_val, exc_tb) == (None, None, None):
for user in self._currentUsers:
user._exiting(self)
@staticmethod
def _getUsedObjectOfClass(cls):
isMulti = MultiUsable in cls.__mro__
allCls = collections.deque([cls])
while len(allCls) > 0:
uc = allCls.popleft()
allCls.extend(uc.__subclasses__())
if uc in UsableObject._currUsed and UsableObject._currUsed[uc] is not None:
if isMulti:
# Return a copy of the list, it would otherwise create problems
# when objects keep track of the values returned by _getUsedObjects()
return copy.copy(UsableObject._currUsed[uc])
else:
return UsableObject._currUsed[uc]
return [] if isMulti else None
class MultiUsable(UsableObject):
"""Base class for steps objects that can be used several times simultaneously."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _getDefaultCurrUsedVal(self):
return []
def __enter__(self):
UsableObject._currUsed[self.__class__].append(self)
self._currentUsers = []
return self
def __exit__(self, exc_type, exc_val, exc_tb):
UsableObject._currUsed[self.__class__].remove(self)
if (exc_type, exc_val, exc_tb) == (None, None, None):
for user in self._currentUsers:
user._exiting(self)
class Optional:
"""Wrapper to tag objects as optional."""
def __init__(self, elem):
self.elem = elem
def UsingObjects(*usedCls):
"""
Return a base class for steps objects that depends on other steps objects for their creation.
Take a list of UsableObject subclasses as argument.
For example, Species requires a reference to a Model object to be created, so Species will
inherit from UsingObjects(Model) and can then only be created inside a 'with mdl:' block.
Usage:
- StepsObj requires an A:
Interface code:
class StepsObj(UsingObjects(A)):
def __init__(...):
a, = self._getUsedObjects()
User code:
a = A()
with a:
s = StepsObj()
- StepsObj requires an A and either a B or a C:
Interface code:
class StepsObj(UsingObjects(A, (B, C))):
def __init__(...):
a, b, c = self._getUsedObjects()
User code:
a = A()
b = B()
with a:
with b:
s = StepsObj() # c will be set to None
"""
# Check that the used classes inherit from UsableObject
allCls = []
for uc in usedCls:
if isinstance(uc, Optional):
uc = uc.elem
if isinstance(uc, tuple):
allCls += list(uc)
else:
allCls.append(uc)
for uc in allCls:
if UsableObject not in uc.__mro__:
raise TypeError(f'{uc} is not a {UsableObject}.')
class ObjectUser(NamedObject):
def __init__(self, *args, addAsElement=True, **kwargs):
super().__init__(*args, **kwargs)
self._exited = set()
# Register the object as child of the used objects
list(self._getUsedObjects(addAsElement=addAsElement, **kwargs))
def _getObjOfClass(self, cls, addAsElement=True):
obj = UsableObject._getUsedObjectOfClass(cls)
if obj is not None and not isinstance(obj, list):
obj._registerUser(cls, self, addAsElement)
return obj
def _getUsedObjects(self, addAsElement=True, **kwargs):
"""
Return a generator that gives the used object instances, in the same order as given
to UsingObjects.
"""
for uc in usedCls:
if isinstance(uc, Optional):
uc = uc.elem
opt = True
else:
opt = False
if isinstance(uc, tuple):
ok = False
for uc2 in uc:
obj = self._getObjOfClass(uc2, addAsElement)
ok |= obj is not None and obj != []
yield obj
if not ok and not opt:
ucNames = [uc2.__name__ for uc2 in uc]
clsName = self.__class__.__name__
ucNamesStr = ' or a '.join(ucNames)
raise Exception(f'Cannot declare a {clsName} out of a {ucNamesStr}.')
else:
obj = self._getObjOfClass(uc, addAsElement)
if obj is None and not opt:
raise Exception(f'Cannot declare a {self.__class__.__name__} out of a {uc.__name__}.')
yield obj
def _getAdditionalChildren(self):
"""
Return a list of NamedObjects that should be added as children of the used object
along with the current object.
"""
return []
def _getParentOfType(self, cls):
return self._parents[cls]
def _exiting(self, parent):
"""The callback should only be called once"""
if parent not in self._exited:
self._exited.add(parent)
self._exitCallback(parent)
def _exitCallback(self, parent):
"""
Method to be called the first time we get out of a context manager in which the object as been
declared.
"""
pass
def _decl(self):
"""
Return a string that describes the declaration of the object. It returns its name by default
but it can contain e.g. the file and line at which it was declared.
"""
return self.name
return ObjectUser
class SimPathCombiner:
"""
Grouping class for combining the values of subpaths.
"""
def __init__(self, *paths):
self.paths = paths
def __iter__(self):
return iter(self.paths)
def func(self, valgen):
"""Combining function, takes an iterable as argument."""
pass
class SumSimPathComb(SimPathCombiner):
"""A Simpath value combiner that simply sums the values."""
def __init__(self, *paths):
super().__init__(*paths)
def func(self, valgen):
"""Combining function, takes an iterable as argument."""
return sum(valgen)
class classproperty:
"""Like property but for classes instead of objects"""
def __init__(self, func):
self.func = func
self.__doc__ = func.__doc__
def __get__(self, obj, objtype):
return self.func(objtype)
def FreezeAfterInit(cls):
"""Class decorator for preventing dynamical attribute creation outside __init__"""
def _setattr(self, name, val):
if self.__freezeCounter > 0 or name in self.__dict__ or hasattr(self.__class__, name):
object.__setattr__(self, name, val)
else:
raise AttributeError(f'There is no attribute named {name} in {self}.')
oldInit = cls.__init__
def newInit(self, *args, **kwargs):
self.__freezeCounter += 1
oldInit(self, *args, **kwargs)
self.__freezeCounter -= 1
cls.__freezeCounter = 0
# Wrap existing __setattr__, if any
if '__setattr__' in cls.__dict__:
oldSetAttr = cls.__setattr__
def _newSetAttr(self, name, val):
try:
_setattr(self, name, val)
except AttributeError:
oldSetAttr(self, name, val)
cls.__setattr__ = _newSetAttr
else:
cls.__setattr__ = _setattr
cls.__init__ = functools.wraps(cls.__init__)(newInit)
return cls
def IgnoresGhostElements(func):
"""Method decorator to raise a warning if the method is used with non-owned elements"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
if self._local and not self._owned:
warnings.warn(f'Local {func.__name__} accesses do not take non-owned elements into account.')
if 'owned' in self._callKwargs:
owned = self._callKwargs['owned']
del self._callKwargs['owned']
try:
val = func(self, *args, **kwargs)
finally:
self._callKwargs['owned'] = owned
return val
else:
return func(self, *args, **kwargs)
return wrapper
###################################################################################################
# File version management utilities
class Versioned:
"""Base class for objects that can come in more than one version
The main use case for this is when behavior changes with different STEPS versions but we still need
to have code that is compatible with previous STEPS versions.
Several methods sharing the same name can be defined and the `_versionRange` decorator should be
used to associate a version range to each of them. Once the `_setVersion` method is called on an
object of this class, the methods corresponding to the supplied version will be used by the object.
The `_setVersion` method is called at construction with STEPS current version.
"""
_methods = {}
def __init__(self, *args, version=steps.__version__, **kwargs):
super().__init__(*args, **kwargs)
self._setVersion(version)
@staticmethod
def _versionRange(above=None, belowOrEq=None):
"""
Decorator that helps selecting the correct version of a method
When a method is decorated with this, it will only be used if the version passed to
`_setVersion` method is inside the range specified here (above < version <= belowOrEq).
"""
minVersion = Versioned._parseVersion(above)
maxVersion = Versioned._parseVersion(belowOrEq)
class decorator:
def __init__(self, func):
Versioned._methods.setdefault(func.__name__, []).append((minVersion, maxVersion, func))
self.func = func
def __set_name__(self, owner, name):
methods = {}
for cls in owner.__mro__:
for _name, st in cls.__dict__.get('_versionedMethods', {}).items():
methods.setdefault(_name, set())
methods[_name] |= st
setattr(owner, '_versionedMethods', methods)
if name in Versioned._methods:
for funcTuple in Versioned._methods[name]:
owner._versionedMethods.setdefault(name, set()).add(funcTuple)
del Versioned._methods[name]
setattr(owner, name, self.func)
return decorator
def _setVersion(self, version):
"""Set the version of a versioned object
It will check all methods that were registered with the `_versionRange` decorator.
If the version parameter is inside the version range of a specific method, this method will be
monkey patched to the object.
"""
version = Versioned._parseVersion(version)
self._version = version
cls = self.__class__
if hasattr(cls, '_versionedMethods'):
for name, versions in cls._versionedMethods.items():
res = None
for minv, maxv, func in versions:
if (minv is None or minv < version) and (maxv is None or version <= maxv):
if res is not None:
raise Exception(
f'Several methods could be used for method {name} with version {version}.'
)
res = func
if res is None:
raise Exception(f'No methods were found for {name} with version {version}.')
setattr(self, name, types.MethodType(res, self))
@staticmethod
def _parseVersion(versionStr):
"""Parse a version string to a tuple of integers
`packaging.version.parse` would be preferable but in order to not add another dependency, we will use
this simple parsing method.
"""
if versionStr is None:
return None
if isinstance(versionStr, tuple):
return versionStr
try:
return tuple(map(int, versionStr.split('.')))
except:
raise ValueError(f'Invalid version string "{versionStr}".')
###################################################################################################
# Various utilities
class ReadOnlyDictInterface:
"""Base class for objects that should behave like read-only dicts
__getitem__() and keys() should be implemented.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __setitem__(self, key, value):
raise NotImplementedError('Cannot write data, the dictionary is read-only.')
def __getitem__(self, key):
raise NotImplementedError()
def keys(self):
raise NotImplementedError()
def __iter__(self):
for key in self.keys():
yield key
def items(self):
for key in self.keys():
yield key, self[key]
def values(self):
for key in self.keys():
yield self[key]
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def __contains__(self, key):
return key in self.keys()
def __eq__(self, val):
return sorted(self.items()) == sorted(val.items())
class MutableDictInterface(ReadOnlyDictInterface):
"""Base class for objects that should behave like dicts
__getitem__(), __setitem__() and keys() should be implemented.
"""
def __setitem__(self, key, value):
raise NotImplementedError()
def limitReprLength(func):
"""Decorator for limiting the length of __repr__ methods."""
REPR_MAX_LENGTH = 200
ellips = '...'
def repr(self):
res = func(self)
if len(res) > REPR_MAX_LENGTH:
return res[0 : min(len(res), REPR_MAX_LENGTH - len(ellips))] + ellips
else:
return res
return repr
def formatKey(key, sz, forceSz=False):
"""
Format a __getitem__ key into a tuple of minimum size sz, transforming '...' into the
appropriate number of ':'.
"""
if not isinstance(key, tuple):
key = (key,)
# If ellipsis is used, expand it first
if any(s is Ellipsis for s in key):
if key.count(Ellipsis) > 1:
raise KeyError('Cannot use the ellipsis operator ("...") more than once.')
# If the ellipsis operator was used correctly, fill the missing slots with ':'
i = key.index(Ellipsis)
m = max(0, len(key) - sz + 1)
key = key[:i] + (slice(None),) * m + key[i + 1 :]
if forceSz:
key += (slice(None),) * (sz - len(key))
if len(key) > sz:
raise Exception(f'Too many dimensions in key indexing.')
return key
def getSliceIds(s, sz):
"""Return the indices coresponding to slice s of a structure that has length sz."""
if isinstance(s, list):
return s
if isinstance(s, numbers.Integral):
if s >= sz:
raise IndexError()
if s >= 0:
s = slice(s, s + 1, None)
else:
s = slice(s % sz, (s % sz) + 1, None)
if s.start is not None and s.start >= sz:
raise IndexError()
return range(*s.indices(sz))
def nparray(data):
"""Return a numpy array with the appropriate dtype"""
try:
return numpy.array(data)
except ValueError:
return numpy.array(data, dtype=object)
def getValueIfAllIdentical(lst):
"""If all values in lst are identical, return a list with just this value;
otherwise return None"""
try:
val = next(iter(lst))
except StopIteration:
return None
return None if any(v != val for v in lst) else [val]
def key2str(key):
"""Return a string describing the key."""
if not isinstance(key, tuple):
key = (key,)
res = []
for k in key:
if k is Ellipsis:
res.append('...')
elif isinstance(k, slice):
st = k.start if k.start is not None else ''
st += f':{k.stop}' if k.stop is not None else ':'
st += f':{k.step}' if k.step is not None else ''
res.append(st)
else:
res.append(str(k))
return ', '.join(res)
def args2str(*args, **kwargs):
"""Return a string representation of the arguments."""
lst = []
for arg in args:
if inspect.isclass(arg):
lst.append(arg.__name__)
else:
lst.append(str(arg))
lst += [f'{name}={val}' for name, val in kwargs.items()]
return ', '.join(lst)
def extractArgs(args, kwargs, lst, raiseIfMoreArgs=True):
"""
Extract arguments from args and kwargs based on name and type.
This function also removes arguments from args and kwargs if they got extracted.
:param args: Tuple of arguments
:type args: Tuple[Any, ...]
:param kwargs: Keyword arguments
:type kwargs: Dict[str, Any]
:param lst: The list of name, type and default value
:type lst: List[Tuple[str, Union[Type, Tuple[Type, ...]], Any]]
:param raiseIfMoreArgs: If True, raise an exception if some positional arguments were not extracted
:type raiseIfMoreArgs: bool
:return: Tuple of values of size n + 2 with n the length of the `lst` parameter. The two last
elements are the updated `args` and `kwargs` after extraction
:rtype: Tuple[Any, ..., Tuple[Any, ...], Dict[str, Any]]
"""
args = list(args)
kwargs = dict(kwargs)
res = []
for name, tpe, default in lst:
val = default
if name in kwargs:
val = kwargs[name]
del kwargs[name]
else:
for i, arg in enumerate(args):
if isinstance(arg, tpe) or arg == default:
val = arg
del args[i]
break
res.append(val)
if raiseIfMoreArgs and len(args) > 0:
raise Exception(f'Unused arguments: {args}')
return tuple(res) + (args, kwargs)
[docs]def SetVerbosity(v):
"""
Set verbosity to the specified level.
:param v: Verbosity level
:type v: int
The higher the vebosity, the more messages are displayed on standard output.
"""
global VERBOSITY
VERBOSITY = v
# Compatibility with API_1
steps._suppress_greet = (v == 0)
steps._quiet = (v == 0)
class MessagesTypes(str, Enum):
WARNING = '\033[93m'
ERROR = '\033[91m'
SUCCESS = '\033[92m'
UNDERLINE = '\033[4m'
BOLD = '\033[1m'
HEADER = BOLD + UNDERLINE
_END_COLOR = '\033[0m'
def _print(msg, prio, tpe=None, indent=0):
"""
Print a message if its priority permits it and if we are in rank one in case of an MPI
simulation.
"""
from steps.API_2 import sim as nsim
if prio <= VERBOSITY and nsim.MPI._shouldWrite:
msg, *sublines = msg.split('\n')
if tpe is not None and hasattr(sys.stdout, 'isatty') and sys.stdout.isatty():
msg = tpe + msg + MessagesTypes._END_COLOR
if indent > 0:
msg = ' ' * indent + msg
print(msg)
for sl in sublines:
_print(sl, prio, tpe=tpe, indent=indent + 1)
###################################################################################################
# Docstrings utilities
def _paddedStringsfromRow(row, colLengths):
return ' '.join(val + ' '*(cl - len(val)) for val, cl in zip(row, colLengths))
def GetDocstringTable(colNames, rows, indentStr=''):
lines = []
colLengths = []
for c, cname in enumerate(colNames):
colLengths.append(max(len(cname), max(len(row[c]) for row in rows)))
sepLine = ' '.join('='*cl for cl in colLengths)
lines.append(sepLine)
lines.append(indentStr + _paddedStringsfromRow(colNames, colLengths))
lines.append(indentStr + sepLine)
for row in rows:
lines.append(indentStr + _paddedStringsfromRow(row, colLengths))
lines.append(indentStr + sepLine)
return '\n'.join(lines)
class Facade:
"""Base class for classes that are exposed to the user but can instantiate objects from their
subclasses. For example, ``Compartment`` is a facade to _DistCompartment, etc.
Subclasses can define the ``_FACADE_TITLE_STR`` class attribute to give a section title in the
documentation.
"""
_FACADE_ATTR_NAME = '_FACADE_TITLE_STR'
###################################################################################################
# Docstring editing
# Insert Prefix and Units information into the Parameter docstring
prefixTable = GetDocstringTable(
['Prefix', 'Name', 'Base 10'],
[(f"``'{pref}'``", name, fr'10\ :sup:`{exp}`') for pref, (exp, name) in Units._SI_PREFIXES.items()],
' '
)
unitTable = GetDocstringTable(
['Unit', 'Name', 'Quantity'],
[(f"``'{unit}'``", name, quant) for unit, (_, _, name, quant) in Units._SI_UNITS.items()],
' '
)
Parameter.__doc__ = Parameter.__doc__.format(
prefixTable=prefixTable,
unitTable=unitTable,
)