diff --git a/changes.d/907.removal b/changes.d/907.removal new file mode 100644 index 000000000..f0c5bedbd --- /dev/null +++ b/changes.d/907.removal @@ -0,0 +1,2 @@ +The internal structure of `qupulse.program` changed. `Program` and `ProgramBuilder` moved to `qupulse.program.protocol` +with a backwards compatible import. `SimpleExpression` was renamed to `DynamicLinearValue` and lives now in `qupulse.program.values`. diff --git a/qupulse/program/__init__.py b/qupulse/program/__init__.py index 69f6a5af2..3825baebf 100644 --- a/qupulse/program/__init__.py +++ b/qupulse/program/__init__.py @@ -23,279 +23,16 @@ used/required/possible with (purified) silicon samples. """ -from abc import abstractmethod -from dataclasses import dataclass -from typing import Optional, Union, Sequence, ContextManager, Mapping, Tuple, Generic, TypeVar, Iterable, Dict -from numbers import Real - +from qupulse.program.protocol import Program, ProgramBuilder +from qupulse.program.values import DynamicLinearValue, HardwareTime, HardwareVoltage from qupulse.program.waveforms import Waveform from qupulse.program.transformation import Transformation from qupulse.program.volatile import VolatileRepetitionCount -from qupulse.utils.types import MeasurementWindow, TimeType -from qupulse.parameter_scope import Scope -from qupulse.expressions import sympy as sym_expr -from qupulse.utils.sympy import _lambdify_modules - -from typing import Protocol, runtime_checkable - - -NumVal = TypeVar('NumVal', bound=Real) - - -@dataclass -class SimpleExpression(Generic[NumVal]): - """This is a potential hardware evaluable expression of the form - - C + C1*R1 + C2*R2 + ... - where R1, R2, ... are potential runtime parameters. - - The main use case is the expression of for loop dependent variables where the Rs are loop indices. There the - expressions can be calculated via simple increments. - - This class tries to pass a number and a :py:class:`sympy.expr.Expr` on - best effort basis. - - Attributes: - base: The part of this expression which is not runtime parameter dependent - offsets: Factors would have been a better name in hindsight. A mapping of inner parameter names to the factor - with which they contribute to the final value. - """ - - base: NumVal - offsets: Mapping[str, NumVal] - - def __post_init__(self): - assert isinstance(self.offsets, Mapping) - - def value(self, scope: Mapping[str, NumVal]) -> NumVal: - """Numeric value of the expression with the given scope. - Args: - scope: Scope in which the expression is evaluated. - Returns: - The numeric value. - """ - value = self.base - for name, factor in self.offsets: - value += scope[name] * factor - return value - - def __add__(self, other): - if isinstance(other, (float, int, TimeType)): - return SimpleExpression(self.base + other, self.offsets) - - if type(other) == type(self): - offsets = dict(self.offsets) - for name, value in other.offsets.items(): - offsets[name] = value + offsets.get(name, 0) - return SimpleExpression(self.base + other.base, offsets) - - # this defers evaluation when other is still a symbolic expression - return NotImplemented - - def __radd__(self, other): - return self.__add__(other) - - def __sub__(self, other): - return self.__add__(-other) - - def __rsub__(self, other): - return (-self).__add__(other) - - def __neg__(self): - return SimpleExpression(-self.base, {name: -value for name, value in self.offsets.items()}) - - def __mul__(self, other: NumVal): - if isinstance(other, (float, int, TimeType)): - return SimpleExpression(self.base * other, {name: other * value for name, value in self.offsets.items()}) - - # this defers evaluation when other is still a symbolic expression - return NotImplemented - - def __rmul__(self, other): - return self.__mul__(other) - - def __truediv__(self, other): - inv = 1 / other - return self.__mul__(inv) - - @property - def free_symbols(self): - """This is required for the :py:class:`sympy.expr.Expr` interface compliance. Since the keys of - :py:attr:`.offsets` are internal parameters we do not have free symbols. - - Returns: - An empty tuple - """ - return () - - def _sympy_(self): - """This method is used by :py:`sympy.sympify`. This class tries to "just work" in the sympy evaluation pipelines. - - Returns: - self - """ - return self - - def replace(self, r, s): - """We mock :class:`sympy.Expr.replace` here. This class does not support inner parameters so there is nothing - to replace. Importantly, the keys of the offsets are no runtime variables! - - Returns: - self - """ - return self - - -# this keeps the simple expression in lambdified results -_lambdify_modules.append({'SimpleExpression': SimpleExpression}) - - -RepetitionCount = Union[int, VolatileRepetitionCount, SimpleExpression[int]] -HardwareTime = Union[TimeType, SimpleExpression[TimeType]] -HardwareVoltage = Union[float, SimpleExpression[float]] -@runtime_checkable -class Program(Protocol): - """This protocol is used to inspect and or manipulate programs. As you can see the functionality is very limited - because most of a program class' capability are specific to the implementation.""" - - @property - @abstractmethod - def duration(self) -> TimeType: - """The duration of the program in nanoseconds.""" - - -@runtime_checkable -class ProgramBuilder(Protocol): - """This protocol is used by :py:meth:`.PulseTemplate.create_program` to build a program via a variation of the - visitor pattern. - - The pulse templates call the methods that correspond to their functionality on the program builder. For example, - :py:class:`.ConstantPulseTemplate` translates itself into a simple :py:meth:`.ProgramBuilder.hold_voltage` call while - :class:`SequencePulseTemplate` uses :py:meth:`.ProgramBuilder.with_sequence` to signify a logical unit with - attached measurements and passes the resulting object to the sequenced sub-templates. - - Due to backward compatibility the handling of measurements is a bit weird since they have to be omitted in certain - cases. However, this is not relevant for HDAWG specific implementations because these are expected to ignore - :py:meth:`.ProgramBuilder.measure` calls. - - This interface makes heavy use of context managers and generators/iterators which allows for flexible iteration - and repetition implementation. - """ - - @abstractmethod - def inner_scope(self, scope: Scope) -> Scope: - """This function is part of the iteration protocol and necessary to inject program builder specific parameter - implementations into the build process. :py:meth:`.ProgramBuilder.with_iteration` and - `.ProgramBuilder.with_iteration` callers *must* call this function inside the iteration. - - Args: - scope: The parameter scope outside the iteration. - - Returns: - The parameter scope inside the iteration. - """ - - @abstractmethod - def hold_voltage(self, duration: HardwareTime, voltages: Mapping[str, HardwareVoltage]): - """Hold the specified voltage for a given time. Advances the current time by ``duration``. The values are - hardware dependent type which are inserted into the parameter scope via :py:meth:`.ProgramBuilder.with_iteration`. - - Args: - duration: Duration of voltage hold - voltages: Voltages for each channel - """ - - # further specialized commandos like play_harmonic might be added here - - @abstractmethod - def play_arbitrary_waveform(self, waveform: Waveform): - """Insert the playback of an arbitrary waveform. If possible pulse templates should use more specific commands - like :py:meth:`.ProgramBuilder.hold_voltage` (the only more specific command at the time of this writing). - - Args: - waveform: The waveform to play - """ - - @abstractmethod - def measure(self, measurements: Optional[Sequence[MeasurementWindow]]): - """Unconditionally add given measurements relative to the current position. - - Args: - measurements: Measurements to add. - """ - - @abstractmethod - def with_repetition(self, repetition_count: RepetitionCount, - measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']: - """Start a new repetition context with given repetition count. The caller has to iterate over the return value - and call `:py:meth:`.ProgramBuilder.inner_scope` inside the iteration context. - - Args: - repetition_count: Repetition count - measurements: These measurements are added relative to the position at the start of the iteration iff the - iteration is not empty. - - Returns: - An iterable of :py:class:`ProgramBuilder` instances. - """ - - @abstractmethod - def with_sequence(self, - measurements: Optional[Sequence[MeasurementWindow]] = None) -> ContextManager['ProgramBuilder']: - """Start a new sequence context. The caller has to enter the returned context manager and add the sequenced - elements there. - - Measurements that are added in to the returned program builder are discarded if the sequence is empty on exit. - - Args: - measurements: These measurements are added relative to the position at the start of the sequence iff the - sequence is not empty. - - Returns: - A context manager that returns a :py:class:`ProgramBuilder` on entering. - """ - - @abstractmethod - def new_subprogram(self, global_transformation: 'Transformation' = None) -> ContextManager['ProgramBuilder']: - """Create a context managed program builder whose contents are translated into a single waveform upon exit if - it is not empty. - - Args: - global_transformation: This transformation is applied to the waveform - - Returns: - A context manager that returns a :py:class:`ProgramBuilder` on entering. - """ - - @abstractmethod - def with_iteration(self, index_name: str, rng: range, - measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']: - """Create an iterable that represent the body of the iteration. This can be an iterable with an element for each - step in the iteration or a single object that represents the complete iteration. - - Args: - index_name: The name of index - rng: The range if the index - measurements: Measurements to add iff the iteration body is not empty. - """ - - @abstractmethod - def time_reversed(self) -> ContextManager['ProgramBuilder']: - """This returns a new context manager that will reverse everything added to it in time upon exit. - - Returns: - A context manager that returns a :py:class:`ProgramBuilder` on entering. - """ - - @abstractmethod - def to_program(self) -> Optional[Program]: - """Generate the final program. This is allowed to invalidate the program builder. - - Returns: - A program implementation. None if nothing was added to this program builder. - """ +# backwards compatibility +# DEPRECATED but writing warning code for this is too complex +SimpleExpression = DynamicLinearValue def default_program_builder() -> ProgramBuilder: @@ -307,7 +44,3 @@ def default_program_builder() -> ProgramBuilder: """ from qupulse.program.loop import LoopBuilder return LoopBuilder() - - -# TODO: hackedy, hackedy -sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES = sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES + (SimpleExpression,) diff --git a/qupulse/program/linspace.py b/qupulse/program/linspace.py index 57983bf4c..a52aa91ec 100644 --- a/qupulse/program/linspace.py +++ b/qupulse/program/linspace.py @@ -16,8 +16,8 @@ from qupulse import ChannelID, MeasurementWindow from qupulse.parameter_scope import Scope, MappedScope, FrozenDict -from qupulse.program import (ProgramBuilder, HardwareTime, HardwareVoltage, Waveform, RepetitionCount, TimeType, - SimpleExpression) +from qupulse.program.protocol import (ProgramBuilder, Waveform, ) +from qupulse.program.values import RepetitionCount, HardwareTime, HardwareVoltage, DynamicLinearValue, TimeType from qupulse.program.volatile import VolatileRepetitionCount, InefficientVolatility # this resolution is used to unify increments @@ -199,7 +199,7 @@ def inner_scope(self, scope: Scope) -> Scope: process.""" if self._ranges: name, _ = self._ranges[-1] - return scope.overwrite({name: SimpleExpression(base=0, offsets={name: 1})}) + return scope.overwrite({name: DynamicLinearValue(base=0, factors={name: 1})}) else: return scope @@ -218,7 +218,7 @@ def hold_voltage(self, duration: HardwareTime, voltages: Mapping[ChannelID, Hard bases.append(value) factors.append(None) continue - offsets = value.offsets + offsets = value.factors base = value.base incs = [] for rng_name, rng in ranges.items(): @@ -233,8 +233,8 @@ def hold_voltage(self, duration: HardwareTime, voltages: Mapping[ChannelID, Hard factors.append(tuple(incs)) bases.append(base) - if isinstance(duration, SimpleExpression): - duration_factors = duration.offsets + if isinstance(duration, DynamicLinearValue): + duration_factors = duration.factors duration_base = duration.base else: duration_base = duration diff --git a/qupulse/program/loop.py b/qupulse/program/loop.py index eeff50153..105c82e88 100644 --- a/qupulse/program/loop.py +++ b/qupulse/program/loop.py @@ -13,7 +13,8 @@ import numpy as np from qupulse.parameter_scope import Scope -from qupulse.program import ProgramBuilder, RepetitionCount, HardwareTime, HardwareVoltage +from qupulse.program import ProgramBuilder +from qupulse.program.values import RepetitionCount, HardwareTime, HardwareVoltage from qupulse.program.transformation import Transformation from qupulse.program.volatile import VolatileRepetitionCount, VolatileProperty, VolatileModificationWarning from qupulse.program.waveforms import SequenceWaveform, RepetitionWaveform diff --git a/qupulse/program/protocol.py b/qupulse/program/protocol.py new file mode 100644 index 000000000..19a363ae9 --- /dev/null +++ b/qupulse/program/protocol.py @@ -0,0 +1,153 @@ +from abc import abstractmethod +from typing import runtime_checkable, Protocol, Mapping, Optional, Sequence, Iterable, ContextManager + +from qupulse import MeasurementWindow +from qupulse.parameter_scope import Scope +from qupulse.program.waveforms import Waveform +from qupulse.program.transformation import Transformation +from qupulse.program.values import RepetitionCount, HardwareTime, HardwareVoltage + +from qupulse.utils.types import TimeType + + +@runtime_checkable +class Program(Protocol): + """This protocol is used to inspect and or manipulate programs. As you can see the functionality is very limited + because most of a program class' capability are specific to the implementation.""" + + @property + @abstractmethod + def duration(self) -> TimeType: + """The duration of the program in nanoseconds.""" + + +@runtime_checkable +class ProgramBuilder(Protocol): + """This protocol is used by :py:meth:`.PulseTemplate.create_program` to build a program via a variation of the + visitor pattern. + + The pulse templates call the methods that correspond to their functionality on the program builder. For example, + :py:class:`.ConstantPulseTemplate` translates itself into a simple :py:meth:`.ProgramBuilder.hold_voltage` call while + :class:`SequencePulseTemplate` uses :py:meth:`.ProgramBuilder.with_sequence` to signify a logical unit with + attached measurements and passes the resulting object to the sequenced sub-templates. + + Due to backward compatibility the handling of measurements is a bit weird since they have to be omitted in certain + cases. However, this is not relevant for HDAWG specific implementations because these are expected to ignore + :py:meth:`.ProgramBuilder.measure` calls. + + This interface makes heavy use of context managers and generators/iterators which allows for flexible iteration + and repetition implementation. + """ + + @abstractmethod + def inner_scope(self, scope: Scope) -> Scope: + """This function is part of the iteration protocol and necessary to inject program builder specific parameter + implementations into the build process. :py:meth:`.ProgramBuilder.with_iteration` and + `.ProgramBuilder.with_iteration` callers *must* call this function inside the iteration. + + Args: + scope: The parameter scope outside the iteration. + + Returns: + The parameter scope inside the iteration. + """ + + @abstractmethod + def hold_voltage(self, duration: HardwareTime, voltages: Mapping[str, HardwareVoltage]): + """Hold the specified voltage for a given time. Advances the current time by ``duration``. The values are + hardware dependent type which are inserted into the parameter scope via :py:meth:`.ProgramBuilder.with_iteration`. + + Args: + duration: Duration of voltage hold + voltages: Voltages for each channel + """ + + # further specialized commandos like play_harmonic might be added here + + @abstractmethod + def play_arbitrary_waveform(self, waveform: Waveform): + """Insert the playback of an arbitrary waveform. If possible pulse templates should use more specific commands + like :py:meth:`.ProgramBuilder.hold_voltage` (the only more specific command at the time of this writing). + + Args: + waveform: The waveform to play + """ + + @abstractmethod + def measure(self, measurements: Optional[Sequence[MeasurementWindow]]): + """Unconditionally add given measurements relative to the current position. + + Args: + measurements: Measurements to add. + """ + + @abstractmethod + def with_repetition(self, repetition_count: RepetitionCount, + measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']: + """Start a new repetition context with given repetition count. The caller has to iterate over the return value + and call `:py:meth:`.ProgramBuilder.inner_scope` inside the iteration context. + + Args: + repetition_count: Repetition count + measurements: These measurements are added relative to the position at the start of the iteration iff the + iteration is not empty. + + Returns: + An iterable of :py:class:`ProgramBuilder` instances. + """ + + @abstractmethod + def with_sequence(self, + measurements: Optional[Sequence[MeasurementWindow]] = None) -> ContextManager['ProgramBuilder']: + """Start a new sequence context. The caller has to enter the returned context manager and add the sequenced + elements there. + + Measurements that are added in to the returned program builder are discarded if the sequence is empty on exit. + + Args: + measurements: These measurements are added relative to the position at the start of the sequence iff the + sequence is not empty. + + Returns: + A context manager that returns a :py:class:`ProgramBuilder` on entering. + """ + + @abstractmethod + def new_subprogram(self, global_transformation: 'Transformation' = None) -> ContextManager['ProgramBuilder']: + """Create a context managed program builder whose contents are translated into a single waveform upon exit if + it is not empty. + + Args: + global_transformation: This transformation is applied to the waveform + + Returns: + A context manager that returns a :py:class:`ProgramBuilder` on entering. + """ + + @abstractmethod + def with_iteration(self, index_name: str, rng: range, + measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']: + """Create an iterable that represent the body of the iteration. This can be an iterable with an element for each + step in the iteration or a single object that represents the complete iteration. + + Args: + index_name: The name of index + rng: The range if the index + measurements: Measurements to add iff the iteration body is not empty. + """ + + @abstractmethod + def time_reversed(self) -> ContextManager['ProgramBuilder']: + """This returns a new context manager that will reverse everything added to it in time upon exit. + + Returns: + A context manager that returns a :py:class:`ProgramBuilder` on entering. + """ + + @abstractmethod + def to_program(self) -> Optional[Program]: + """Generate the final program. This is allowed to invalidate the program builder. + + Returns: + A program implementation. None if nothing was added to this program builder. + """ diff --git a/qupulse/program/values.py b/qupulse/program/values.py new file mode 100644 index 000000000..07404bcbd --- /dev/null +++ b/qupulse/program/values.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass +from numbers import Real +from typing import TypeVar, Generic, Mapping, Union + +from qupulse.program.volatile import VolatileRepetitionCount +from qupulse.utils.types import TimeType + +from qupulse.expressions import sympy as sym_expr +from qupulse.utils.sympy import _lambdify_modules + + +NumVal = TypeVar('NumVal', bound=Real) + + +@dataclass +class DynamicLinearValue(Generic[NumVal]): + """This is a potential runtime-evaluable expression of the form + + C + C1*R1 + C2*R2 + ... + where R1, R2, ... are potential runtime parameters. + + The main use case is the expression of for loop-dependent variables where the Rs are loop indices. There the + expressions can be calculated via simple increments. + + This class tries to pass a number and a :py:class:`sympy.expr.Expr` on best effort basis. + + Attributes: + base: The part of this expression which is not runtime parameter-dependent + factors: A mapping of inner parameter names to the factor with which they contribute to the final value. + """ + + base: NumVal + factors: Mapping[str, NumVal] + + def __post_init__(self): + assert isinstance(self.factors, Mapping) + + def value(self, scope: Mapping[str, NumVal]) -> NumVal: + """Numeric value of the expression with the given scope. + Args: + scope: Scope in which the expression is evaluated. + Returns: + The numeric value. + """ + value = self.base + for name, factor in self.factors: + value += scope[name] * factor + return value + + def __add__(self, other): + if isinstance(other, (float, int, TimeType)): + return DynamicLinearValue(self.base + other, self.factors) + + if type(other) == type(self): + offsets = dict(self.factors) + for name, value in other.factors.items(): + offsets[name] = value + offsets.get(name, 0) + return DynamicLinearValue(self.base + other.base, offsets) + + # this defers evaluation when other is still a symbolic expression + return NotImplemented + + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + return self.__add__(-other) + + def __rsub__(self, other): + return (-self).__add__(other) + + def __neg__(self): + return DynamicLinearValue(-self.base, {name: -value for name, value in self.factors.items()}) + + def __mul__(self, other: NumVal): + if isinstance(other, (float, int, TimeType)): + return DynamicLinearValue(self.base * other, {name: other * value for name, value in self.factors.items()}) + + # this defers evaluation when other is still a symbolic expression + return NotImplemented + + def __rmul__(self, other): + return self.__mul__(other) + + def __truediv__(self, other): + inv = 1 / other + return self.__mul__(inv) + + @property + def free_symbols(self): + """This is required for the :py:class:`sympy.expr.Expr` interface compliance. Since the keys of + :py:attr:`.offsets` are internal parameters we do not have free symbols. + + Returns: + An empty tuple + """ + return () + + def _sympy_(self): + """This method is used by :py:`sympy.sympify`. This class tries to "just work" in the sympy evaluation pipelines. + + Returns: + self + """ + return self + + def replace(self, r, s): + """We mock :class:`sympy.Expr.replace` here. This class does not support inner parameters so there is nothing + to replace. Importantly, the keys of the offsets are no runtime variables! + + Returns: + self + """ + return self + + +# TODO: hackedy, hackedy +sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES = sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES + (DynamicLinearValue,) + +# this keeps the simple expression in lambdified results +_lambdify_modules.append({'DynamicLinearValue': DynamicLinearValue}) + +RepetitionCount = Union[int, VolatileRepetitionCount, DynamicLinearValue[int]] +HardwareTime = Union[TimeType, DynamicLinearValue[TimeType]] +HardwareVoltage = Union[float, DynamicLinearValue[float]]