Skip to content

System

FunctionHandler

Base class handing the creation and storage of system-wide utility functions and parameters.

add_system_parameter(name, function, signature=None)

A system parameter is a system-wide function which, if not constant, may only depend on the independent system variable time or existing system parameters.

PARAMETER DESCRIPTION
name

the string identifier of the system parameter.

TYPE: str

function

a callable function or a string representation of its output.

TYPE: Callable | str | int | float

signature

the function signature. See Notes below for more details.

TYPE: tuple DEFAULT: None

RAISES DESCRIPTION
TypeError

if the input function is not of a parsable type.

Notes

If function is callable, the signature should be provided if the function arguments names are not system parameters or time. The following are acceptable calls:

>>> system.add_system_parameter("T_avg", lambda T_min, T_max: (T_min + T_max)/2)
>>> system.add_system_parameter("T_avg", lambda a, b: (a+b)/2, signature=("T_min", "T_max"))

While the following call will fail because "a" and "b" are not recognised as system parameters.

>>> system.add_system_paramter("T_avg", lambda a, b: (a+b)/2)

If the function is symbolic, a signature only needs to be provided to control the display order of the function arguments. If not provided, the generation of the signature does not preserve the order in which the symbols appear. This does not affect the computation of the system parameter. For example,

>>> system.add_system_parameter("T_ratio", "T_max / T_min")

will always compute T_ratio = T_max / T_min, but may display as T_ratio (T_max, T_min) or T_ratio (T_min, T_max).

The provided signature must be a list or tuple containing exactly the symbols in the expression in the required order, for example:

>>> system.add_system_parameter("T_ratio", "T_max / T_min", ("T_max", "T_min"))

Then whenever T_ratio is written in an assignment definition, it will be interpreted and displayed as the function T_ratio(T_max, T_min).

Source code in psymple/build/system.py
def add_system_parameter(
    self, name: str, function: Callable | str | int | float, signature: tuple = None
):
    """
    A system parameter is a system-wide function which, if not constant, may only depend on the independent 
    system variable `time` or existing system parameters.

    Args:
        name: the string identifier of the system parameter.
        function: a callable function or a string representation of its output.
        signature: the function signature. See `Notes` below for more details.

    Raises:
        TypeError: if the input `function` is not of a parsable type.

    Notes:
        If function is callable, the signature should be provided if the function arguments names are not system
        parameters or time. The following are acceptable calls:

        ```
        >>> system.add_system_parameter("T_avg", lambda T_min, T_max: (T_min + T_max)/2)
        >>> system.add_system_parameter("T_avg", lambda a, b: (a+b)/2, signature=("T_min", "T_max"))
        ```

        While the following call will fail because `"a"` and `"b"` are not recognised as system parameters.
        ```
        >>> system.add_system_paramter("T_avg", lambda a, b: (a+b)/2)
        ```

        If the function is symbolic, a signature only needs to be provided to control the display order of the
        function arguments. If not provided, the generation of the signature does not preserve the order in
        which the symbols appear. This does not affect the computation of the system parameter. For example,

        ```
        >>> system.add_system_parameter("T_ratio", "T_max / T_min")
        ```

        will always compute `T_ratio = T_max / T_min`, but may display as `T_ratio (T_max, T_min)` or
        `T_ratio (T_min, T_max)`.

        The provided signature must be a list or tuple containing exactly the symbols in the expression in the
        required order, for example:

        ```
        >>> system.add_system_parameter("T_ratio", "T_max / T_min", ("T_max", "T_min"))
        ```

        Then whenever `T_ratio` is written in an assignment definition, it will be interpreted and displayed
        as the function `T_ratio(T_max, T_min)`.
    """
    if name in self.system_parameters:
        warnings.warn(
            f"The system parameter {name} has already been defined. It will be overwritten."
        )
    if callable(function):
        args, nargs = self._inspect_signature(function)
        if signature:
            if signature != args:
                if len(signature) not in nargs:
                    raise ValueError(
                        f"Signature validation failed. The provided signature {signature}"
                        f"is not a length accepted by the provided function: {nargs}."
                    )
        else:
            signature = args
        self._check_are_system_parameters(*signature)
        sym_func = self._add_callable_function(name, function, nargs=nargs)
    elif isinstance(function, (str, int, float)):
        function = str(function)
        sym_signature = self._generate_signature(function)
        if signature:
            assert all([isinstance(s, str) for s in signature])
            assert set(signature) == set(sym_signature)
        else:
            try:
                value = float(function)
            except:
                warnings.warn(
                    f"A signature for function {name} was not provided. The appearance of "
                    f"the function in displayed expressions may not be as expected."
                )
            signature = sym_signature
        self._check_are_system_parameters(*signature)
        sym_func = self._add_symbolic_function(name, function, signature)
    else:
        raise TypeError(f"Function {function} of type {type(function)} cannot be parsed.")
    # The signature needs to take into account sub-dependencies of existing system parameters
    sig = tuple(parse_expr(s, local_dict=self.system_parameters) for s in signature)
    if sig:
        self.system_parameters.update({name: sym_func(*sig)})
    else:
        self.system_parameters.update({name: sym_func()})

add_utility_function(name, function, signature=None)

A utility function is a system-wide function which can depend on any further created variable or parameter. They expand functions available to the user when defining assignments.

PARAMETER DESCRIPTION
name

the string identifier of the system parameter.

TYPE: str

function

a callable function or a string representation of its output.

TYPE: Callable | str

signature

the function signature. See Notes below for more details.

TYPE: tuple DEFAULT: None

RAISES DESCRIPTION
TypeError

if the input function is not of a parsable type.

Notes

If function is callable, its signature will be inspected to determine the range of acceptable number of inputs. This is used to validate function entry in the creation of assignments.

>>> from numpy import sin
>>> system.add_utility_function("new_sin", sin)

Entering new_sin(a,b) in an assignment will raise an exception because numpy.sin accepts exactly one argument. A signature can be provided to restrict the number of inputs of a function. This is currently not recommended, and no signature argument should be provided.

If function is symbolic, a signature should be provided if the order of function arguments matters. If not provided, the function may not behave as expected. The provided signature must be a list or tuple containing exactly the symbols in the expression in the required order. For example,

>>> system.add_utility_function("exp", "a**b")

may evaluate as exp(x,y) = x**y or exp(x,y) = y**x. While,

>>> system.add_utility_function("exp", "a**b", ("a", "b"))

will always evaluate as exp(x,y) = x**y.

Source code in psymple/build/system.py
def add_utility_function(self, name: str, function: Callable | str, signature: tuple = None):
    """
    A utility function is a system-wide function which can depend on any further created variable or parameter.
    They expand functions available to the user when defining assignments.

    Args:
        name: the string identifier of the system parameter.
        function: a callable function or a string representation of its output.
        signature: the function signature. See `Notes` below for more details.

    Raises:
        TypeError: if the input `function` is not of a parsable type.

    Notes:
        If function is callable, its signature will be inspected to determine the range of acceptable number of
        inputs. This is used to validate function entry in the creation of assignments.

        ```
        >>> from numpy import sin
        >>> system.add_utility_function("new_sin", sin)
        ```

        Entering `new_sin(a,b)` in an assignment will raise an exception because `numpy.sin` accepts exactly one
        argument. A signature can be provided to restrict the number of inputs of a function. _This is currently
        **not** recommended, and no signature argument should be provided_.

        If function is symbolic, a signature should be provided if the order of function arguments matters.
        If not provided, the function may not behave as expected. The provided signature must be a list or
        tuple containing exactly the symbols in the expression in the required order. For example,

        ```
        >>> system.add_utility_function("exp", "a**b")
        ```

        may evaluate as `exp(x,y) = x**y` or `exp(x,y) = y**x`. While,

        ```
        >>> system.add_utility_function("exp", "a**b", ("a", "b"))
        ```

        will always evaluate as `exp(x,y) = x**y`.
    """
    if name in self.utility_functions:
        warnings.warn(
            f"The utility function {name} has already been defined. It will be overwritten."
        )
    if callable(function):
        _, nargs = self._inspect_signature(function)
        if signature:
            assert set(signature).issubset(set(nargs))
            nargs = signature
        sym_func = self._add_callable_function(name, function, nargs=nargs)
    elif isinstance(function, str):
        sym_signature = self._generate_signature(function)
        if signature:
            assert set(signature) == set(sym_signature)
        else:
            warnings.warn(
                f"A signature for function {name} was not provided. The behaviour of "
                "the function may not be as expected."
            )
            signature = sym_signature
        sym_func = self._add_symbolic_function(name, function, signature)
    else:
        raise TypeError(f"Function {function} of type {type(function)} cannot be parsed.")
    self.utility_functions.update({name: sym_func})

System(ported_object=None, utility_functions=[], system_parameters=[], time_symbol='T', compile=False)

Bases: FunctionHandler, SetterObject

A System is a three-way interface between:

  1. A PortedObject instance defining a model;
  2. Collections of data providing context to symbols and functions of Assignment instances attached to the PortedObject instance. The three main data sources are:

    • time: the independent system variable which is simulated over;
    • utility functions: which provide a system-wide definition of a function call, and;
    • system parameters: which provide a system-wide definition of a symbol;
  3. A Simulation instance which allows a model defined by the PortedObject instance to be simulated.

PARAMETER DESCRIPTION
ported_object

instance of PortedObject or `PortedObjectData defining the system model.

TYPE: PortedObject | PortedObjectData DEFAULT: None

utility_functions

list of the utility functions available in the system. See documentation for add_utility_function for acceptable values.

TYPE: list[dict | tuple] DEFAULT: []

system_parameters

list of the system parameters available in the system. See documentation for add_system_paramter for acceptable values.

TYPE: list[dict | tuple] DEFAULT: []

time_symbol

The symbol used for the independent variable time in the system.

TYPE: str DEFAULT: 'T'

compile

If True and ported_object is provided, then system will be compiled automatically.

TYPE: bool DEFAULT: False

Warning

Overriding the time_symbol from "T" is not currently supported.

Source code in psymple/build/system.py
def __init__(
    self,
    ported_object: PortedObject | PortedObjectData = None,
    utility_functions: list[dict | tuple] = [],
    system_parameters: list[dict | tuple] = [],
    time_symbol: str = "T",
    compile: bool = False,
):
    """
    Create a System instance.

    Args:
        ported_object: instance of `PortedObject` or `PortedObjectData defining the system model.
        utility_functions: list of the utility functions available in the system. See documentation
            for [`add_utility_function`][psymple.build.System.add_utility_function] for acceptable values.
        system_parameters: list of the system parameters available in the system. See documentation
            for [`add_system_paramter`][psymple.build.System.add_system_parameter] for acceptable values.
        time_symbol: The symbol used for the independent variable time in the system.
        compile: If `True` and `ported_object` is provided, then system will be compiled automatically.

    warning: Warning
        Overriding the time_symbol from `"T"` is not currently supported.
    """
    self.lambdify_ns = ["numpy", "scipy"]
    self.system_parameters = {}
    self.utility_functions = {}
    self.compiled = False
    self.simulations = {}
    self.ported_object = None

    if time_symbol != "T":
        warnings.warn(
            f"time symbol {time_symbol} has not been tested. Reverting back to T."
        )
        time_symbol = "T"
    self._create_time_variable(time_symbol)

    self._add_function("utility_function", *utility_functions)
    self._add_function("system_parameter", *system_parameters)

    if ported_object:
        self.set_object(ported_object, compile=compile)

compile(child=None)

Compile the system at the specified ported object. This will compile the specified ported object, and then create the necessary variables and parameters for simulation.

PARAMETER DESCRIPTION
child

a string identifying a child ported object from self.ported_object. If not provided, self.ported_object will be used.

TYPE: str DEFAULT: None

Source code in psymple/build/system.py
def compile(self, child: str = None):
    """
    Compile the system at the specified ported object. This will compile the specified ported object,
    and then create the necessary variables and parameters for simulation.

    Args:
        child: a string identifying a child ported object from `self.ported_object`. If not provided,
            `self.ported_object` will be used.
    """
    self.simulations = {}
    self.required_inputs = []

    if not self.ported_object:
        raise SystemError(
            "No ported object specified in system. Use set_object() to set one first."
        )

    ported_object = self._build_ported_object()

    if child:
        ported_object = ported_object._get_child(child)

    compiled = ported_object.compile()

    variable_assignments, parameter_assignments = compiled.get_assignments()
    # self.sub_system_parameters(variable_assignments, parameter_assignments)

    required_inputs = compiled.get_required_inputs()

    self._reset_variables_parameters()

    variables, parameters = self._get_symbols(
        variable_assignments, parameter_assignments, required_inputs
    )

    self._create_simulation_variables(
        variable_assignments, variables | {self.time.symbol}, parameters
    )
    self._create_simulation_parameters(
        parameter_assignments, variables | {self.time.symbol}, parameters
    )
    self._create_input_parameters(required_inputs)
    self.compiled = True

compute_parameter_update_order()

Computes the dependency tree of parameters in self.parameters.

By performing a topological sort, the correct substitution order of parameters is determined. For example if par_a = f(par_b) and par_b = g(par_c), the substitution par_b -> g(par_c) must be performed before par_a -> f(par_b).

If a topologial sort fails, there are cyclic dependencies in the parameter tree and an exception is raised.

RETURNS DESCRIPTION
nodes

the keys of self.parameters in sorted order.

TYPE: list[Symbol]

RAISES DESCRIPTION
SystemError

if there are cyclic dependencies.

Source code in psymple/build/system.py
def compute_parameter_update_order(self) -> list[Symbol]:
    """
    Computes the dependency tree of parameters in `self.parameters`.

    By performing a topological sort, the correct substitution order of parameters
    is determined. For example if `par_a = f(par_b)` and `par_b = g(par_c)`, the 
    substitution `par_b -> g(par_c)` must be performed before `par_a -> f(par_b)`.

    If a topologial sort fails, there are cyclic dependencies in the parameter tree
    and an exception is raised. 

    Returns:
        nodes: the keys of `self.parameters` in sorted order.

    Raises:
        SystemError: if there are cyclic dependencies. 
    """
    variable_symbols = set(self.variables.keys())
    parameter_symbols = self.parameters
    G = nx.DiGraph()
    G.add_nodes_from(parameter_symbols)
    for parameter in self.parameters.values():
        parsym = parameter.symbol
        if parsym != parameter.expression:
            # Skip identity parameters
            for dependency in parameter.dependent_parameters:
                if dependency in parameter_symbols:
                    G.add_edge(dependency, parsym)
                elif dependency not in (variable_symbols | {self.time.symbol}):
                    raise SystemError(
                        f"Parameter {parsym} references undefined symbol {dependency}"
                    )
    try:
        nodes = list(nx.topological_sort(G))
    except nx.exception.NetworkXUnfeasible:
        raise SystemError(f"System parameters contain cyclic dependencies")
    return nodes

create_simulation(name=None, solver='continuous', initial_values={}, input_parameters={})

Create a Simulation instance from the system.

PARAMETER DESCRIPTION
name

if provided, the simulation will be stored in self.simulations[name].

TYPE: str DEFAULT: None

solver

solver method to use.

TYPE: str DEFAULT: 'continuous'

initial_values

a dictionary of variable: value pairs where variable is the string identifier of a variable in self.variables and value is int or float.

TYPE: dict DEFAULT: {}

input_parameters

a dictionary of parameter: value pairs, where parameter is the string identifier of a parameter (an entry from self.parameters) and value is the value or function to assign.

TYPE: dict DEFAULT: {}

RETURNS DESCRIPTION
simulation

the Simulation class specified by the arguments provided.

TYPE: Simulation

RAISES DESCRIPTION
SystemError

if the system has not been compiled.

Source code in psymple/build/system.py
def create_simulation(
    self,
    name: str = None,
    solver: str = "continuous",
    initial_values: dict = {},
    input_parameters: dict = {},
) -> Simulation:
    """
    Create a Simulation instance from the system. 

    Args:
        name: if provided, the simulation will be stored in `self.simulations[name]`.
        solver: solver method to use.
        initial_values: a dictionary of `variable: value` pairs where `variable` is the string
            identifier of a variable in `self.variables` and `value` is `int` or `float`.
        input_parameters: a dictionary of `parameter: value` pairs, where `parameter` is the string
            identifier of a parameter (an entry from `self.parameters`) and `value` is the value
            or function to assign.

    Returns:
        simulation: the `Simulation` class specified by the arguments provided.

    Raises:
        SystemError: if the system has not been compiled.
    """
    if not self.compiled:
        raise SystemError(f"System has not been compiled.")
    if params := self._get_required_parameters() - input_parameters.keys():
        warnings.warn(
            f"The parameters {params} have no default value. This must be provided before a simulation run."
        )
    simulation = Simulation(self, solver, initial_values, input_parameters)
    if name:
        self.simulations.update({name: simulation})
    return simulation

get_readable_symbols(keep_surface_symbols=True, hash_symbols=False)

Generates short symbols for the variables and parameters in the system.

PARAMETER DESCRIPTION
keep_surface_symbols

if True, any symbol exposed at the surface ported object is preserved.

TYPE: bool DEFAULT: True

hash_symbols

if True, any non-preserved symbol is replaced with a hashable version by replacing HIERARCHY_SEPARATOR with "_", e.g. x.y.z -> x_y_z. Otherwise:

- Variables are mapped to `x_i`, where `i` is incremented for each variable.
- Parameters are mapped to `a_i`, where `i` is incremented for each parameter.

TYPE: bool DEFAULT: False

Warning

This is currently a very crude implementation. In the future, a lot more customisation will be offered.

RETURNS DESCRIPTION
vars_dict

a mapping of variable symbols to readable variable symbols

TYPE: dict

pars_dict

a mapping of parameter symbols to readable parameter symbols

TYPE: dict

Source code in psymple/build/system.py
def get_readable_symbols(self, keep_surface_symbols: bool = True, hash_symbols: bool = False) -> tuple[dict, dict]:
    """
    Generates short symbols for the variables and parameters in the system.

    Args:
        keep_surface_symbols: if True, any symbol exposed at the surface ported object is preserved.
        hash_symbols: if True, any non-preserved symbol is replaced with a hashable version by
            replacing `HIERARCHY_SEPARATOR` with `"_"`, e.g. `x.y.z -> x_y_z`. Otherwise:

                - Variables are mapped to `x_i`, where `i` is incremented for each variable.
                - Parameters are mapped to `a_i`, where `i` is incremented for each parameter.

    warning: Warning
        This is currently a very crude implementation. In the future, a lot more
        customisation will be offered.

    Returns:
        vars_dict: a mapping of variable symbols to readable variable symbols
        pars_dict: a mapping of parameter symbols to readable parameter symbols
    """
    vars_symbols = self.variables.keys()
    pars_symbols = self.parameters.keys()
    if keep_surface_symbols:
        surface_vars_symbols = {v for v in vars_symbols if HIERARCHY_SEPARATOR not in v.name}
        edit_vars_symbols = vars_symbols - surface_vars_symbols
        surface_pars_symbols = {p for p in pars_symbols if HIERARCHY_SEPARATOR not in p.name}
        edit_pars_symbols = pars_symbols - surface_pars_symbols
    else:
        edit_vars_symbols = vars_symbols
        edit_pars_symbols = pars_symbols
    if hash_symbols:
        vars_dict = {v: Symbol(v.name.replace(HIERARCHY_SEPARATOR, "_")) for v in edit_vars_symbols}
        pars_dict = {p: Symbol(p.name.replace(HIERARCHY_SEPARATOR, "_")) for p in edit_pars_symbols}
    else:
        vars_dict = {v: Symbol(f"x_{i}") for i, v in enumerate(edit_vars_symbols)}
        pars_dict = {p: Symbol(f"a_{i}") for i, p in enumerate(edit_pars_symbols)}
    vars_dict.update({self.time.symbol: Symbol("t")}) 
    if keep_surface_symbols: 
        vars_dict |= {v: v for v in surface_vars_symbols}
        pars_dict |= {p: p for p in surface_pars_symbols}
    return vars_dict, pars_dict

get_readout(vars_dict=None, pars_dict=None)

Get a LaTeX-readable summary of the system ODEs and functions.

Source code in psymple/build/system.py
def get_readout(self, vars_dict: dict = None, pars_dict: dict = None) -> str:
    """
    Get a LaTeX-readable summary of the system ODEs and functions.
    """
    if not vars_dict:
        vars_dict, _ = self.get_readable_symbols()
    if not pars_dict:
        _, pars_dict = self.get_readable_symbols()
    odes = [
        var.get_readout(self.time.symbol, vars_dict, pars_dict)
        for var in self.variables.values()
    ]
    functions = [
        par.get_readout(vars_dict, pars_dict) for par in self.parameters.values()
    ]
    print(f"system ODEs: \[{self._combine_latex(*odes)}\]")
    print(f"system functions: \[{self._combine_latex(*functions)}\]")
    print(f"variable mappings: {vars_dict}")
    print(f"parameter mappings: {pars_dict}")

set_initial_values(values)

Set initial values at the system level. System must first have an associated ported object and must be compiled.

Initial values must be int or float instances only.

PARAMETER DESCRIPTION
values

a dictionary of variable: value pairs where variable is the string identifier of a variable in self.variables and value is int or float.

TYPE: dict[str, int | float]

Source code in psymple/build/system.py
def set_initial_values(self, values: dict[str, int | float]):
    """
    Set initial values at the system level. System must first have an associated ported object and
    must be compiled.

    Initial values must be `int` or `float` instances only.

    Args:
        values: a dictionary of `variable: value` pairs where `variable` is the string
            identifier of a variable in `self.variables` and `value` is `int` or `float`.
    """
    if not self.compiled:
        raise SystemError(f"System has not been compiled.")
    super().set_initial_values(values)

set_object(ported_object, compile=True)

Set the ported object in the system. This will override any ported object currently set in the system.

PARAMETER DESCRIPTION
ported_object

instance of PortedObject or PortedObjectData defining the system model.

TYPE: PortedObject | PortedObjectData

compile

if True, compile will be called automatically.

TYPE: bool DEFAULT: True

Source code in psymple/build/system.py
def set_object(
    self, ported_object: PortedObject | PortedObjectData, compile: bool = True
):
    """
    Set the ported object in the system. This will override any ported object currently
    set in the system.

    Args:
        ported_object: instance of `PortedObject` or `PortedObjectData` defining the system model.
        compile: if `True`, [`compile`][psymple.build.System.compile] will be called automatically.
    """
    self.ported_object = self._process_ported_object(ported_object)
    self.compiled = False
    # Variables and parameters need to be reset for a new object
    self._reset_variables_parameters()
    if compile:
        self.compile()

set_parameters(parameter_values={})

Set input parameters at the system level. System must first have an associated ported object and must be compiled.

Parameters which can be set or overridden:

  • any parameter from an input port of the system object, whether it has a default value or not,
  • any parameter from an input port of a nested child of the system object (these must already have been given a default value, but this can be overridden here).

Parameter values must be constant, or functions of system variable time and/or existing system parameters.

PARAMETER DESCRIPTION
parameter_values

a dictionary of parameter: value pairs, where parameter is the string identifier of a parameter (an entry from self.parameters) and value is the value or function to assign.

TYPE: dict[str, str | int | float] DEFAULT: {}

RAISES DESCRIPTION
TypeError

if a value which is not of type str, int, float is entered.

ParsingError

if the value expression contains forbidden symbols.

TypeError

if the parameter is fixed and cannot be updated.

Source code in psymple/build/system.py
def set_parameters(self, parameter_values: dict[str, str | int | float] = {}):
    """
    Set input parameters at the system level. System must first have an associated ported object and
    must be compiled.

    Parameters which can be set or overridden:

    - any parameter from an input port of the system object, whether it has a default value or not,
    - any parameter from an input port of a nested child of the system object (these must already
        have been given a default value, but this can be overridden here).

    Parameter values must be constant, or functions of system variable `time` and/or existing system 
    parameters.

    Args:
        parameter_values: a dictionary of `parameter: value` pairs, where `parameter` is the string
            identifier of a parameter (an entry from `self.parameters`) and `value` is the value
            or function to assign.

    Raises:
        TypeError: if a value which is not of type `str`, `int`, `float` is entered.
        ParsingError: if the value expression contains forbidden symbols.
        TypeError: if the parameter is fixed and cannot be updated.
    """
    if not self.compiled:
        raise SystemError(f"System has not been compiled.")
    super().set_parameters(parameter_values)

to_data()

Map the system to a SystemData instance. Not currently used for anything.

RETURNS DESCRIPTION
data

a SystemData instance.

TYPE: SystemData

Source code in psymple/build/system.py
def to_data(self) -> SystemData:
    """
    Map the system to a `SystemData` instance. Not currently used for anything.

    Returns:
        data: a `SystemData` instance.
    """
    metadata = {"compiled": self.compiled}
    ported_object = self.ported_object_data
    return SystemData(metadata=metadata, ported_object=ported_object)