Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cleanup expression usage #750

Merged
merged 16 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changes.d/750.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Promote ``qupulse.expression`` to a subpackage and create ``qupulse.expression.protocol`` with protocol classes that define the expression interface that is supposed to be used by qupulse.
The ```sympy`` based implementation is moved to ``qupulse.expressions.sympy`` and imported in ``qupulse.expressions``.

The intended use is to be able to use less powerful but faster implementations of the ``Expression`` protocol where appropriate.
In this first iteration, qupulse still relies on internals of the ``sympy`` based implementation in many places which is to be removed in the future.
2 changes: 1 addition & 1 deletion doc/source/concepts/pulsetemplates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Parameters

As mentioned above, all pulse templates may depend on parameters. During pulse template initialization the parameters simply are the free variables of expressions that occur in the pulse template. For example the :class:`.FunctionPulseTemplate` has expressions for its duration and the voltage time dependency i.e. the underlying function. Some pulse templates provided means to constrain parameters by accepting a list of :class:`.ParameterConstraint` which encapsulate comparative expressions that must evaluate to true for a given parameter set to successfully instantiate a pulse from the pulse template. This can be used to encode physical or logical parameter boundaries at pulse level.

The mathematical expressions (for parameter transformation or as the function of the :class:`.FunctionPulseTemplate`) are encapsulated into an :class:`.Expression` class which wraps `sympy <http://www.sympy.org/en/index.html>`_ for string evaluation.
The mathematical expressions (for parameter transformation or as the function of the :class:`.FunctionPulseTemplate`) are encapsulated into an :class:`.sympy.Expression` class which wraps `sympy <http://www.sympy.org/en/index.html>`_ for string evaluation by default. Other more performant or secure backends can potentially be implemented by conforming to the :class:`.protocol.Expression`.

Parameters can be mapped to arbitrary expressions via :class:`.mapping_pulse_template.MappingPulseTemplate`. One use case can be deriving pulse parameters from physical quantities.

Expand Down
76 changes: 76 additions & 0 deletions qupulse/expressions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""This subpackage contains qupulse's expression logic. The submodule :py:mod:`.expressions.protocol` defines the :py:class:`typing.Protocol`
that expression functionality providers must implement. This allows to substitute the powerful and expressive but slow
default implementation with a faster less expressive backend.

The default implementation is in :py:mod:`.expressions.sympy`.

There is are wrapper classes for finding non-protocol uses of expression in :py:mod:`.expressions.wrapper`. Define
``QUPULSE_EXPRESSION_WRAPPER`` environment variable when running python to wrap all expression usages.
"""

from typing import Type, TypeVar
from numbers import Real
import os

import numpy as np
import sympy as sp

from . import sympy, protocol, wrapper


__all__ = ["Expression", "ExpressionVector", "ExpressionScalar",
"NonNumericEvaluation", "ExpressionVariableMissingException"]


Expression: Type[protocol.Expression] = sympy.Expression
ExpressionScalar: Type[protocol.ExpressionScalar] = sympy.ExpressionScalar
ExpressionVector: Type[protocol.ExpressionVector] = sympy.ExpressionVector


if os.environ.get('QUPULSE_EXPRESSION_WRAPPER', None): # pragma: no cover
Expression, ExpressionScalar, ExpressionVector = wrapper.make_wrappers(sympy.Expression,
sympy.ExpressionScalar,
sympy.ExpressionVector)


ExpressionLike = TypeVar('ExpressionLike', str, Real, sp.Expr, ExpressionScalar)


class ExpressionVariableMissingException(Exception):
"""An exception indicating that a variable value was not provided during expression evaluation.

See also:
qupulse.expressions.Expression
"""

def __init__(self, variable: str, expression: Expression) -> None:
super().__init__()
self.variable = variable
self.expression = expression

def __str__(self) -> str:
return f"Could not evaluate <{self.expression}>: A value for variable <{self.variable}> is missing!"


class NonNumericEvaluation(Exception):
"""An exception that is raised if the result of evaluate_numeric is not a number.

See also:
qupulse.expressions.Expression.evaluate_numeric
"""

def __init__(self, expression: Expression, non_numeric_result, call_arguments):
self.expression = expression
self.non_numeric_result = non_numeric_result
self.call_arguments = call_arguments

def __str__(self) -> str:
if isinstance(self.non_numeric_result, np.ndarray):
dtype = self.non_numeric_result.dtype

if dtype == np.dtype('O'):
dtypes = set(map(type, self.non_numeric_result.flat))
return f"The result of evaluate_numeric is an array with the types {dtypes} which is not purely numeric"
else:
dtype = type(self.non_numeric_result)
return f"The result of evaluate_numeric is of type {dtype} which is not a number"
105 changes: 105 additions & 0 deletions qupulse/expressions/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""This module contains the interface / protocol descriptions of ``Expression``, ``ExpressionScalar`` and
``ExpressionVector``."""

from typing import Mapping, Union, Sequence, Hashable, Any, Protocol
from numbers import Real

import numpy as np


class Ordered(Protocol):
def __lt__(self, other):
pass

def __le__(self, other):
pass

def __gt__(self, other):
pass

def __ge__(self, other):
pass


class Scalar(Protocol):
def __add__(self, other):
pass

def __sub__(self, other):
pass

def __mul__(self, other):
pass

def __truediv__(self, other):
pass

def __floordiv__(self, other):
pass

def __ceil__(self):
pass

def __floor__(self):
pass

def __float__(self):
pass

def __int__(self):
pass

def __abs__(self):
pass


class Expression(Hashable, Protocol):
"""This protocol defines how Expressions are allowed to be used in qupulse."""

def evaluate_in_scope(self, scope: Mapping) -> Union[Real, np.ndarray]:
"""Evaluate the expression by taking the variables from the given scope (typically of type Scope, but it can be
any mapping.)
Args:
scope:

Returns:

"""

def evaluate_symbolic(self, substitutions: Mapping[str, Any]) -> 'Expression':
"""Substitute a part of the expression for another"""

def evaluate_time_dependent(self, scope: Mapping) -> Union['Expression', Real, np.ndarray]:
"""Evaluate to a time dependent expression or a constant."""

@property
def variables(self) -> Sequence[str]:
""" Get all free variables in the expression.

Returns:
A collection of all free variables occurring in the expression.
"""
raise NotImplementedError()

@classmethod
def make(cls,
expression_or_dict,
numpy_evaluation=None) -> 'Expression':
"""Backward compatible expression generation to allow creation from dict."""
raise NotImplementedError()

@property
def underlying_expression(self) -> Any:
"""Return some internal unspecified representation"""
raise NotImplementedError()

def get_serialization_data(self):
raise NotImplementedError()


class ExpressionScalar(Expression, Scalar, Ordered, Protocol):
pass


class ExpressionVector(Expression, Protocol):
pass
64 changes: 10 additions & 54 deletions qupulse/expressions.py → qupulse/expressions/sympy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
get_most_simple_representation, get_variables, evaluate_lamdified_exact_rational
from qupulse.utils.types import TimeType

__all__ = ["Expression", "ExpressionVariableMissingException", "ExpressionScalar", "ExpressionVector", "ExpressionLike"]
import qupulse.expressions

__all__ = ["Expression", "ExpressionScalar", "ExpressionVector"]


_ExpressionType = TypeVar('_ExpressionType', bound='Expression')
Expand Down Expand Up @@ -60,9 +62,9 @@ def _parse_evaluate_numeric_vector(vector_result: numpy.ndarray) -> numpy.ndarra
if not issubclass(vector_result.dtype.type, allowed_scalar):
obj_types = set(map(type, vector_result.flat))
if all(issubclass(obj_type, sympy.Integer) for obj_type in obj_types):
result = vector_result.astype(numpy.int64)
vector_result = vector_result.astype(numpy.int64)
elif all(issubclass(obj_type, (sympy.Integer, sympy.Float)) for obj_type in obj_types):
result = vector_result.astype(float)
vector_result = vector_result.astype(float)
else:
raise ValueError("Could not parse vector result", vector_result)
return vector_result
Expand Down Expand Up @@ -98,7 +100,7 @@ def _parse_evaluate_numeric_arguments(self, eval_args: Mapping[str, Number]) ->
# we forward qupulse errors, I down like this
raise
else:
raise ExpressionVariableMissingException(key_error.args[0], self) from key_error
raise qupulse.expressions.ExpressionVariableMissingException(key_error.args[0], self) from key_error

def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
"""Evaluate the expression by taking the variables from the given scope (typically of type Scope but it can be
Expand Down Expand Up @@ -129,7 +131,7 @@ def evaluate_symbolic(self, substitutions: Mapping[Any, Any]) -> 'Expression':
def _evaluate_to_time_dependent(self, scope: Mapping) -> Union['Expression', Number, numpy.ndarray]:
try:
return self.evaluate_numeric(**scope, t=sympy.symbols('t'))
except NonNumericEvaluation as non_num:
except qupulse.expressions.NonNumericEvaluation as non_num:
return ExpressionScalar(non_num.non_numeric_result)
except TypeError:
return self.evaluate_symbolic(scope)
Expand Down Expand Up @@ -212,7 +214,7 @@ def evaluate_in_scope(self, scope: Mapping) -> numpy.ndarray:
try:
return _parse_evaluate_numeric_vector(result)
except ValueError as err:
raise NonNumericEvaluation(self, result, scope) from err
raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err

def get_serialization_data(self) -> Sequence[str]:
serialized_items = list(map(get_most_simple_representation, self._expression_items))
Expand Down Expand Up @@ -444,7 +446,7 @@ def evaluate_with_exact_rationals(self, scope: Mapping) -> Union[Number, numpy.n
try:
return _parse_evaluate_numeric(result)
except ValueError as err:
raise NonNumericEvaluation(self, result, scope) from err
raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err

def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
parsed_kwargs = self._parse_evaluate_numeric_arguments(scope)
Expand All @@ -453,50 +455,4 @@ def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
try:
return _parse_evaluate_numeric(result)
except ValueError as err:
raise NonNumericEvaluation(self, result, scope) from err


class ExpressionVariableMissingException(Exception):
"""An exception indicating that a variable value was not provided during expression evaluation.

See also:
qupulse.expressions.Expression
"""

def __init__(self, variable: str, expression: Expression) -> None:
super().__init__()
self.variable = variable
self.expression = expression

def __str__(self) -> str:
return "Could not evaluate <{}>: A value for variable <{}> is missing!".format(
str(self.expression), self.variable)


class NonNumericEvaluation(Exception):
"""An exception that is raised if the result of evaluate_numeric is not a number.

See also:
qupulse.expressions.Expression.evaluate_numeric
"""

def __init__(self, expression: Expression, non_numeric_result: Any, call_arguments: Mapping):
self.expression = expression
self.non_numeric_result = non_numeric_result
self.call_arguments = call_arguments

def __str__(self) -> str:
if isinstance(self.non_numeric_result, numpy.ndarray):
dtype = self.non_numeric_result.dtype

if dtype == numpy.dtype('O'):
dtypes = set(map(type, self.non_numeric_result.flat))
"The result of evaluate_numeric is an array with the types {} " \
"which is not purely numeric".format(dtypes)
else:
dtype = type(self.non_numeric_result)
return "The result of evaluate_numeric is of type {} " \
"which is not a number".format(dtype)


ExpressionLike = TypeVar('ExpressionLike', str, Number, sympy.Expr, ExpressionScalar)
raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err
Loading
Loading