Repository: openqasm/oqpy

Issue:
Help user by converting floats to durations and vice versa where appropriate
In python, the user will likely be representing durations as floats with some canonical unit, probably seconds. It is inconvenient for the user to need to remember where a float is allowed and where a duration is required. We can help this along by detecting when a user is passing a float someplace where a duration is expected and insert `<expr> * 1s`. Or vice versa, we can detect if a user is passing a duration where a float is expected and pass `<expr> / 1s`.

I could imagine turning this behavior on and off with a configuration setting passed to an initializing Program.

Discussion:

Patch:
diff --git a/oqpy/base.py b/oqpy/base.py
index d69383b..c1d5ca2 100644
--- a/oqpy/base.py
+++ b/oqpy/base.py
@@ -58,10 +58,13 @@ class OQPyExpression:
 
     @staticmethod
     def _to_binary(
-        op_name: str, first: AstConvertible, second: AstConvertible
+        op_name: str,
+        first: AstConvertible,
+        second: AstConvertible,
+        result_type: ast.ClassicalType | None = None,
     ) -> OQPyBinaryExpression:
         """Helper method to produce a binary expression."""
-        return OQPyBinaryExpression(ast.BinaryOperator[op_name], first, second)
+        return OQPyBinaryExpression(ast.BinaryOperator[op_name], first, second, result_type)
 
     @staticmethod
     def _to_unary(op_name: str, exp: AstConvertible) -> OQPyUnaryExpression:
@@ -93,16 +96,20 @@ class OQPyExpression:
         return self._to_binary("%", other, self)
 
     def __mul__(self, other: AstConvertible) -> OQPyBinaryExpression:
-        return self._to_binary("*", self, other)
+        result_type = compute_product_types(self, other)
+        return self._to_binary("*", self, other, result_type)
 
     def __rmul__(self, other: AstConvertible) -> OQPyBinaryExpression:
-        return self._to_binary("*", other, self)
+        result_type = compute_product_types(other, self)
+        return self._to_binary("*", other, self, result_type)
 
     def __truediv__(self, other: AstConvertible) -> OQPyBinaryExpression:
-        return self._to_binary("/", self, other)
+        result_type = compute_quotient_types(self, other)
+        return self._to_binary("/", self, other, result_type)
 
     def __rtruediv__(self, other: AstConvertible) -> OQPyBinaryExpression:
-        return self._to_binary("/", other, self)
+        result_type = compute_quotient_types(other, self)
+        return self._to_binary("/", other, self, result_type)
 
     def __pow__(self, other: AstConvertible) -> OQPyBinaryExpression:
         return self._to_binary("**", self, other)
@@ -168,6 +175,128 @@ class OQPyExpression:
         )
 
 
+def _get_type(val: AstConvertible) -> ast.ClassicalType:
+    if isinstance(val, OQPyExpression):
+        return val.type
+    elif isinstance(val, int):
+        return ast.IntType()
+    elif isinstance(val, float):
+        return ast.FloatType()
+    elif isinstance(val, complex):
+        return ast.ComplexType(ast.FloatType())
+    else:
+        raise ValueError(f"Cannot multiply/divide oqpy expression with with {type(val)}")
+
+
+def compute_product_types(left: AstConvertible, right: AstConvertible) -> ast.ClassicalType:
+    """Find the result type for a product of two terms."""
+    left_type = _get_type(left)
+    right_type = _get_type(right)
+
+    types_map = {
+        (ast.FloatType, ast.FloatType): left_type,
+        (ast.FloatType, ast.IntType): left_type,
+        (ast.FloatType, ast.UintType): left_type,
+        (ast.FloatType, ast.DurationType): right_type,
+        (ast.FloatType, ast.AngleType): right_type,
+        (ast.FloatType, ast.ComplexType): right_type,
+        (ast.IntType, ast.FloatType): right_type,
+        (ast.IntType, ast.IntType): left_type,
+        (ast.IntType, ast.UintType): left_type,
+        (ast.IntType, ast.DurationType): right_type,
+        (ast.IntType, ast.AngleType): right_type,
+        (ast.IntType, ast.ComplexType): right_type,
+        (ast.UintType, ast.FloatType): right_type,
+        (ast.UintType, ast.IntType): right_type,
+        (ast.UintType, ast.UintType): left_type,
+        (ast.UintType, ast.DurationType): right_type,
+        (ast.UintType, ast.AngleType): right_type,
+        (ast.UintType, ast.ComplexType): right_type,
+        (ast.DurationType, ast.FloatType): left_type,
+        (ast.DurationType, ast.IntType): left_type,
+        (ast.DurationType, ast.UintType): left_type,
+        (ast.DurationType, ast.DurationType): TypeError(
+            "Cannot multiply two durations. You may need to re-group computations to eliminate this."
+        ),
+        (ast.DurationType, ast.AngleType): TypeError("Cannot multiply duration and angle"),
+        (ast.DurationType, ast.ComplexType): TypeError("Cannot multiply duration and complex"),
+        (ast.AngleType, ast.FloatType): left_type,
+        (ast.AngleType, ast.IntType): left_type,
+        (ast.AngleType, ast.UintType): left_type,
+        (ast.AngleType, ast.DurationType): TypeError("Cannot multiply angle and duration"),
+        (ast.AngleType, ast.AngleType): TypeError("Cannot multiply two angles"),
+        (ast.AngleType, ast.ComplexType): TypeError("Cannot multiply angle and complex"),
+        (ast.ComplexType, ast.FloatType): left_type,
+        (ast.ComplexType, ast.IntType): left_type,
+        (ast.ComplexType, ast.UintType): left_type,
+        (ast.ComplexType, ast.DurationType): TypeError("Cannot multiply complex and duration"),
+        (ast.ComplexType, ast.AngleType): TypeError("Cannot multiply complex and angle"),
+        (ast.ComplexType, ast.ComplexType): left_type,
+    }
+
+    try:
+        result_type = types_map[type(left_type), type(right_type)]
+    except KeyError as e:
+        raise TypeError(f"Could not identify types for product {left} and {right}") from e
+    if isinstance(result_type, Exception):
+        raise result_type
+    return result_type
+
+
+def compute_quotient_types(left: AstConvertible, right: AstConvertible) -> ast.ClassicalType:
+    """Find the result type for a quotient of two terms."""
+    left_type = _get_type(left)
+    right_type = _get_type(right)
+    float_type = ast.FloatType()
+
+    types_map = {
+        (ast.FloatType, ast.FloatType): left_type,
+        (ast.FloatType, ast.IntType): left_type,
+        (ast.FloatType, ast.UintType): left_type,
+        (ast.FloatType, ast.DurationType): TypeError("Cannot divide float by duration"),
+        (ast.FloatType, ast.AngleType): TypeError("Cannot divide float by angle"),
+        (ast.FloatType, ast.ComplexType): right_type,
+        (ast.IntType, ast.FloatType): right_type,
+        (ast.IntType, ast.IntType): float_type,
+        (ast.IntType, ast.UintType): float_type,
+        (ast.IntType, ast.DurationType): TypeError("Cannot divide int by duration"),
+        (ast.IntType, ast.AngleType): TypeError("Cannot divide int by angle"),
+        (ast.IntType, ast.ComplexType): right_type,
+        (ast.UintType, ast.FloatType): right_type,
+        (ast.UintType, ast.IntType): float_type,
+        (ast.UintType, ast.UintType): float_type,
+        (ast.UintType, ast.DurationType): TypeError("Cannot divide uint by duration"),
+        (ast.UintType, ast.AngleType): TypeError("Cannot divide uint by angle"),
+        (ast.UintType, ast.ComplexType): right_type,
+        (ast.DurationType, ast.FloatType): left_type,
+        (ast.DurationType, ast.IntType): left_type,
+        (ast.DurationType, ast.UintType): left_type,
+        (ast.DurationType, ast.DurationType): ast.FloatType(),
+        (ast.DurationType, ast.AngleType): TypeError("Cannot divide duration by angle"),
+        (ast.DurationType, ast.ComplexType): TypeError("Cannot divide duration by complex"),
+        (ast.AngleType, ast.FloatType): left_type,
+        (ast.AngleType, ast.IntType): left_type,
+        (ast.AngleType, ast.UintType): left_type,
+        (ast.AngleType, ast.DurationType): TypeError("Cannot divide by duration"),
+        (ast.AngleType, ast.AngleType): float_type,
+        (ast.AngleType, ast.ComplexType): TypeError("Cannot divide by angle by complex"),
+        (ast.ComplexType, ast.FloatType): left_type,
+        (ast.ComplexType, ast.IntType): left_type,
+        (ast.ComplexType, ast.UintType): left_type,
+        (ast.ComplexType, ast.DurationType): TypeError("Cannot divide by duration"),
+        (ast.ComplexType, ast.AngleType): TypeError("Cannot divide by angle"),
+        (ast.ComplexType, ast.ComplexType): left_type,
+    }
+
+    try:
+        result_type = types_map[type(left_type), type(right_type)]
+    except KeyError as e:
+        raise TypeError(f"Could not identify types for quotient {left} and {right}") from e
+    if isinstance(result_type, Exception):
+        raise result_type
+    return result_type
+
+
 def logical_and(first: AstConvertible, second: AstConvertible) -> OQPyBinaryExpression:
     """Logical AND."""
     return OQPyBinaryExpression(ast.BinaryOperator["&&"], first, second)
@@ -227,19 +356,27 @@ class OQPyUnaryExpression(OQPyExpression):
 class OQPyBinaryExpression(OQPyExpression):
     """An expression consisting of two subexpressions joined by an operator."""
 
-    def __init__(self, op: ast.BinaryOperator, lhs: AstConvertible, rhs: AstConvertible):
+    def __init__(
+        self,
+        op: ast.BinaryOperator,
+        lhs: AstConvertible,
+        rhs: AstConvertible,
+        ast_type: ast.ClassicalType | None = None,
+    ):
         super().__init__()
         self.op = op
         self.lhs = lhs
         self.rhs = rhs
-        # TODO (#50): More robust type checking which considers both arguments
+        # TODO (#9): More robust type checking which considers both arguments
         #   types, as well as the operator.
-        if isinstance(lhs, OQPyExpression):
-            self.type = lhs.type
-        elif isinstance(rhs, OQPyExpression):
-            self.type = rhs.type
-        else:
-            raise TypeError("Neither lhs nor rhs is an expression?")
+        if ast_type is None:
+            if isinstance(lhs, OQPyExpression):
+                ast_type = lhs.type
+            elif isinstance(rhs, OQPyExpression):
+                ast_type = rhs.type
+            else:
+                raise TypeError("Neither lhs nor rhs is an expression?")
+        self.type = ast_type
 
         # Adding floats to durations is not allowed. So we promote types as necessary.
         if isinstance(self.type, ast.DurationType) and self.op in [
@@ -247,10 +384,10 @@ class OQPyBinaryExpression(OQPyExpression):
             ast.BinaryOperator["-"],
         ]:
             # Late import to avoid circular imports.
-            from oqpy.timing import make_duration
+            from oqpy.timing import convert_float_to_duration
 
-            self.lhs = make_duration(self.lhs)
-            self.rhs = make_duration(self.rhs)
+            self.lhs = convert_float_to_duration(self.lhs)
+            self.rhs = convert_float_to_duration(self.rhs)
 
     def to_ast(self, program: Program) -> ast.BinaryExpression:
         """Converts the OQpy expression into an ast node."""
diff --git a/oqpy/classical_types.py b/oqpy/classical_types.py
index 252b831..d11d7be 100644
--- a/oqpy/classical_types.py
+++ b/oqpy/classical_types.py
@@ -42,7 +42,7 @@ from oqpy.base import (
     optional_ast,
     to_ast,
 )
-from oqpy.timing import make_duration
+from oqpy.timing import convert_float_to_duration
 
 if TYPE_CHECKING:
     from typing import Literal
@@ -158,15 +158,15 @@ class Identifier(OQPyExpression):
 
     name: str
 
-    def __init__(self, name: str) -> None:
-        self.type = None
+    def __init__(self, name: str, ast_type: ast.ClassicalType) -> None:
         self.name = name
+        self.type = ast_type
 
     def to_ast(self, program: Program) -> ast.Expression:
         return ast.Identifier(name=self.name)
 
 
-pi = Identifier(name="pi")
+pi = Identifier(name="pi", ast_type=ast.FloatType())
 
 
 class _ClassicalVar(Var, OQPyExpression):
@@ -327,7 +327,7 @@ class DurationVar(_ClassicalVar):
         **type_kwargs: Any,
     ) -> None:
         if init_expression is not None and not isinstance(init_expression, str):
-            init_expression = make_duration(init_expression)
+            init_expression = convert_float_to_duration(init_expression)
         super().__init__(init_expression, name, *args, **type_kwargs)
 
 
@@ -381,7 +381,9 @@ class ArrayVar(_ClassicalVar):
 
         # Automatically handle Duration array.
         if base_type is DurationVar and kwargs["init_expression"] is not None:
-            kwargs["init_expression"] = (make_duration(i) for i in kwargs["init_expression"])
+            kwargs["init_expression"] = (
+                convert_float_to_duration(i) for i in kwargs["init_expression"]
+            )
 
         super().__init__(
             *args,
diff --git a/oqpy/control_flow.py b/oqpy/control_flow.py
index 29a7056..f4727c9 100644
--- a/oqpy/control_flow.py
+++ b/oqpy/control_flow.py
@@ -30,7 +30,7 @@ from oqpy.classical_types import (
     _ClassicalVar,
     convert_range,
 )
-from oqpy.timing import make_duration
+from oqpy.timing import convert_float_to_duration
 
 ClassicalVarT = TypeVar("ClassicalVarT", bound=_ClassicalVar)
 
@@ -126,7 +126,7 @@ def ForIn(
         set_declaration = convert_range(program, iterator)
     elif isinstance(iterator, Iterable):
         if identifier_type is DurationVar:
-            iterator = (make_duration(i) for i in iterator)
+            iterator = (convert_float_to_duration(i) for i in iterator)
 
         set_declaration = ast.DiscreteSet([to_ast(program, i) for i in iterator])
     else:
diff --git a/oqpy/program.py b/oqpy/program.py
index b7540a3..0efb0f6 100644
--- a/oqpy/program.py
+++ b/oqpy/program.py
@@ -41,7 +41,7 @@ from oqpy.base import (
     to_ast,
 )
 from oqpy.pulse import FrameVar, PortVar, WaveformVar
-from oqpy.timing import make_duration
+from oqpy.timing import convert_duration_to_float, convert_float_to_duration
 
 __all__ = ["Program"]
 
@@ -91,6 +91,8 @@ class ProgramState:
 class Program:
     """A builder class for OpenQASM/OpenPulse programs."""
 
+    DURATION_MAX_DIGITS = 12
+
     def __init__(self, version: Optional[str] = "3.0", simplify_constants: bool = True) -> None:
         self.stack: list[ProgramState] = [ProgramState()]
         self.defcals: dict[
@@ -364,7 +366,7 @@ class Program:
         """Apply a delay to a set of qubits or frames."""
         if not isinstance(qubits_or_frames, Iterable):
             qubits_or_frames = [qubits_or_frames]
-        ast_duration = to_ast(self, make_duration(time))
+        ast_duration = to_ast(self, convert_float_to_duration(time))
         ast_qubits_or_frames = map_to_ast(self, qubits_or_frames)
         self._add_statement(ast.DelayInstruction(ast_duration, ast_qubits_or_frames))
         return self
@@ -393,32 +395,37 @@ class Program:
 
     def set_phase(self, frame: AstConvertible, phase: AstConvertible) -> Program:
         """Set the phase of a particular frame."""
-        self.function_call("set_phase", [frame, phase])
+        # We use make_float to force phase to be a unitless (i.e. non-duration) quantity.
+        # Users are expected to keep track the units that are not expressible in openqasm
+        # such as s^{-1}. For instance, in 2 * oqpy.pi * tppi * DurationVar(1e-8),
+        # tppi is a float but has a frequency unit. This will coerce the result type
+        # to a float by assuming the duration should be represented in seconds."
+        self.function_call("set_phase", [frame, convert_duration_to_float(phase)])
         return self
 
     def shift_phase(self, frame: AstConvertible, phase: AstConvertible) -> Program:
         """Shift the phase of a particular frame."""
-        self.function_call("shift_phase", [frame, phase])
+        self.function_call("shift_phase", [frame, convert_duration_to_float(phase)])
         return self
 
     def set_frequency(self, frame: AstConvertible, freq: AstConvertible) -> Program:
         """Set the frequency of a particular frame."""
-        self.function_call("set_frequency", [frame, freq])
+        self.function_call("set_frequency", [frame, convert_duration_to_float(freq)])
         return self
 
     def shift_frequency(self, frame: AstConvertible, freq: AstConvertible) -> Program:
         """Shift the frequency of a particular frame."""
-        self.function_call("shift_frequency", [frame, freq])
+        self.function_call("shift_frequency", [frame, convert_duration_to_float(freq)])
         return self
 
     def set_scale(self, frame: AstConvertible, scale: AstConvertible) -> Program:
         """Set the amplitude scaling of a particular frame."""
-        self.function_call("set_scale", [frame, scale])
+        self.function_call("set_scale", [frame, convert_duration_to_float(scale)])
         return self
 
     def shift_scale(self, frame: AstConvertible, scale: AstConvertible) -> Program:
         """Shift the amplitude scaling of a particular frame."""
-        self.function_call("shift_scale", [frame, scale])
+        self.function_call("shift_scale", [frame, convert_duration_to_float(scale)])
         return self
 
     def returns(self, expression: AstConvertible) -> Program:
@@ -473,7 +480,7 @@ class Program:
     def _do_assignment(self, var: AstConvertible, op: str, value: AstConvertible) -> None:
         """Helper function for variable assignment operations."""
         if isinstance(var, classical_types.DurationVar):
-            value = make_duration(value)
+            value = convert_float_to_duration(value)
         var_ast = to_ast(self, var)
         if isinstance(var_ast, ast.IndexExpression):
             assert isinstance(var_ast.collection, ast.Identifier)
diff --git a/oqpy/subroutines.py b/oqpy/subroutines.py
index 1c26f27..fe0aa37 100644
--- a/oqpy/subroutines.py
+++ b/oqpy/subroutines.py
@@ -28,7 +28,7 @@ import oqpy.program
 from oqpy.base import AstConvertible, OQPyExpression, make_annotations, to_ast
 from oqpy.classical_types import OQFunctionCall, _ClassicalVar
 from oqpy.quantum_types import Qubit
-from oqpy.timing import make_duration
+from oqpy.timing import convert_float_to_duration
 
 __all__ = ["subroutine", "annotate_subroutine", "declare_extern", "declare_waveform_generator"]
 
@@ -200,14 +200,14 @@ def declare_extern(
                 raise TypeError(f"{name}() got multiple values for argument '{k}'.")
 
             if type(arg_types[k_idx]) == ast.DurationType:
-                new_args[k_idx] = make_duration(call_kwargs[k])
+                new_args[k_idx] = convert_float_to_duration(call_kwargs[k])
             else:
                 new_args[k_idx] = call_kwargs[k]
 
         # Casting floats into durations for the non-keyword arguments
         for i, a in enumerate(call_args):
             if type(arg_types[i]) == ast.DurationType:
-                new_args[i] = make_duration(a)
+                new_args[i] = convert_float_to_duration(a)
         return OQFunctionCall(name, new_args, return_type, extern_decl=extern_decl)
 
     return call_extern
diff --git a/oqpy/timing.py b/oqpy/timing.py
index 1c0eae7..417ee84 100644
--- a/oqpy/timing.py
+++ b/oqpy/timing.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import contextlib
+import warnings
 from typing import TYPE_CHECKING, Iterator, cast
 
 from openpulse import ast
@@ -29,14 +30,14 @@ if TYPE_CHECKING:
     from oqpy.program import Program
 
 
-__all__ = ["Box", "make_duration"]
+__all__ = ["Box", "convert_float_to_duration", "convert_float_to_duration", "make_duration"]
 
 
 @contextlib.contextmanager
 def Box(program: Program, duration: AstConvertible | None = None) -> Iterator[None]:
     """Creates a section of the program with a specified duration."""
     if duration is not None:
-        duration = make_duration(duration)
+        duration = convert_float_to_duration(duration)
     program._push()
     yield
     state = program._pop()
@@ -44,9 +45,24 @@ def Box(program: Program, duration: AstConvertible | None = None) -> Iterator[No
 
 
 def make_duration(time: AstConvertible) -> HasToAst:
+    """Make value into an expression representing a duration."""
+    warnings.warn(
+        "make_duration name is deprecated in favor of convert_float_to_duration",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return convert_float_to_duration(time)
+
+
+def convert_float_to_duration(time: AstConvertible) -> HasToAst:
     """Make value into an expression representing a duration."""
     if isinstance(time, (float, int)):
         return OQDurationLiteral(time)
+    if isinstance(time, OQPyExpression):
+        if isinstance(time.type, (ast.UintType, ast.IntType, ast.FloatType)):
+            time = time * OQDurationLiteral(1)
+        elif not isinstance(time.type, ast.DurationType):
+            raise TypeError(f"Cannot convert expression with type {time.type} to duration")
     if hasattr(time, "to_ast"):
         return time  # type: ignore[return-value]
     if hasattr(time, "_to_oqpy_expression"):
@@ -57,14 +73,27 @@ def make_duration(time: AstConvertible) -> HasToAst:
     )
 
 
+def convert_duration_to_float(value: AstConvertible) -> AstConvertible:
+    if isinstance(value, OQPyExpression) and isinstance(value.type, ast.DurationType):
+        value = value / OQDurationLiteral(1)
+    return value
+
+
 class OQDurationLiteral(OQPyExpression):
     """An expression corresponding to a duration literal."""
 
-    def __init__(self, duration: float) -> None:
+    def __init__(self, duration_seconds: float) -> None:
         super().__init__()
-        self.duration = duration
-        self.type = ast.DurationType
+        self.duration_seconds = duration_seconds
+        self.type = ast.DurationType()
 
     def to_ast(self, program: Program) -> ast.DurationLiteral:
         # Todo (#53): make better units?
-        return ast.DurationLiteral(1e9 * self.duration, ast.TimeUnit.ns)
+        n = program.DURATION_MAX_DIGITS
+        if self.duration_seconds >= 1:
+            return ast.DurationLiteral(round(self.duration_seconds, n), ast.TimeUnit.s)
+        if self.duration_seconds >= 1e-3:
+            return ast.DurationLiteral(round(1e3 * self.duration_seconds, n), ast.TimeUnit.ms)
+        if self.duration_seconds >= 1e-6:
+            return ast.DurationLiteral(round(1e6 * self.duration_seconds, n), ast.TimeUnit.us)
+        return ast.DurationLiteral(round(1e9 * self.duration_seconds, n), ast.TimeUnit.ns)


Test patch:
diff --git a/tests/test_directives.py b/tests/test_directives.py
index d0a78e3..4aa5bdc 100644
--- a/tests/test_directives.py
+++ b/tests/test_directives.py
@@ -42,7 +42,8 @@ def _type_matches(val, type_hint) -> bool:
     """
     origin = typing.get_origin(type_hint)
     if origin is None:
-        return isinstance(val, type_hint)
+        # Make an exception for int where float is requested.
+        return isinstance(val, type_hint) or (isinstance(val, int) and issubclass(type_hint, float))
     args = typing.get_args(type_hint)
 
     union_types = [typing.Union]
@@ -302,9 +303,9 @@ def test_non_trivial_array_access():
         """
         OPENQASM 3.0;
         port my_port;
-        array[duration, 5] duration_array = {0.0ns, 250000000.0ns, 500000000.0ns, 750000000.0ns, 1000000000.0ns};
+        array[duration, 5] duration_array = {0.0ns, 250.0ms, 500.0ms, 750.0ms, 1s};
         int[32] one = 1;
-        duration one_second = 1000000000.0ns;
+        duration one_second = 1s;
         frame my_frame = newframe(my_port, 1000000000.0, 0);
         for int idx in [0:3] {
             delay[duration_array[idx + one] + one_second] my_frame;
@@ -401,8 +402,8 @@ def test_binary_expressions():
     prog.set(d, d / 5)
     prog.set(d, d + 5e-9)
     prog.set(d, 5e-9 - d)
-    prog.set(d, d + make_duration(10e-9))
-    prog.set(f, d / make_duration(1))
+    prog.set(d, d + convert_float_to_duration(10e-9))
+    prog.set(f, d / convert_float_to_duration(1))
 
     expected = textwrap.dedent(
         """
@@ -443,7 +444,7 @@ def test_binary_expressions():
         d = d + 5.0ns;
         d = 5.0ns - d;
         d = d + 10.0ns;
-        f = d / 1000000000.0ns;
+        f = d / 1s;
         """
     ).strip()
 
@@ -657,7 +658,7 @@ def test_for_in_var_types():
         OPENQASM 3.0;
         port my_port;
         frame my_frame = newframe(my_port, 3000000000.0, 0);
-        for duration d in {1.0ns, 2.0ns, 5.0ns, 10.0ns, 1000.0ns} {
+        for duration d in {1.0ns, 2.0ns, 5.0ns, 10.0ns, 1.0us} {
             delay[d] my_frame;
         }
         """
@@ -839,7 +840,7 @@ def test_box_and_timings():
 
     with pytest.raises(TypeError):
         f = FloatVar(200e-9, "f", needs_declaration=False)
-        make_duration(f.to_ast(prog))
+        convert_float_to_duration(f.to_ast(prog))
 
     expected = textwrap.dedent(
         """
@@ -849,7 +850,7 @@ def test_box_and_timings():
         frame framename = newframe(portname, 1000000000.0, 0);
         box[500.0ns] {
             play(framename, constant(100.0ns, 0.5));
-            delay[20000.0ns] framename;
+            delay[20.0us] framename;
             play(framename, constant(100.0ns, 0.5));
         }
         box {
@@ -879,8 +880,8 @@ def test_play_capture():
         extern constant(duration, complex[float[64]]) -> waveform;
         port portname;
         frame framename = newframe(portname, 1000000000.0, 0);
-        waveform kernel = constant(1000.0ns, 1);
-        play(framename, constant(1000.0ns, 0.5));
+        waveform kernel = constant(1.0us, 1);
+        play(framename, constant(1.0us, 0.5));
         capture(framename, kernel);
         """
     ).strip()
@@ -966,27 +967,27 @@ def test_defcals():
         frame tx_frame = newframe(tx_port, 5752000000.0, 0);
         frame rx_frame = newframe(rx_port, 5752000000.0, 0);
         defcal x $2 {
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         defcal rx(angle[32] theta) $2 {
             theta += 0.1;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         defcal rx(pi / 3) $2 {
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         defcal xy(angle[32] theta, pi / 2) $1, $2 {
             theta += 0.1;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         defcal xy(angle[32] theta, float[64] phi, 10) $1, $2 {
             theta += 0.1;
             phi += 0.2;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         defcal readout $2 -> bit {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            capture(rx_frame, constant(2400.0ns, 1));
+            play(tx_frame, constant(2.4us, 0.2));
+            capture(rx_frame, constant(2.4us, 1));
         }
         """
     ).strip()
@@ -997,7 +998,7 @@ def test_defcals():
         """
         defcal rx(angle[32] theta) $2 {
             theta += 0.1;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         """
     ).strip()
@@ -1008,7 +1009,7 @@ def test_defcals():
     expect_defcal_rx_pio2 = textwrap.dedent(
         """
         defcal rx(pi / 3) $2 {
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         """
     ).strip()
@@ -1020,7 +1021,7 @@ def test_defcals():
         """
         defcal xy(angle[32] theta, pi / 2) $1, $2 {
             theta += 0.1;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         """
     ).strip()
@@ -1035,7 +1036,7 @@ def test_defcals():
         defcal xy(angle[32] theta, float[64] phi, 10) $1, $2 {
             theta += 0.1;
             phi += 0.2;
-            play(q_frame, constant(1000.0ns, 0.1));
+            play(q_frame, constant(1.0us, 0.1));
         }
         """
     ).strip()
@@ -1049,8 +1050,8 @@ def test_defcals():
     expect_defcal_readout_q2 = textwrap.dedent(
         """
         defcal readout $2 -> bit {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            capture(rx_frame, constant(2400.0ns, 1));
+            play(tx_frame, constant(2.4us, 0.2));
+            capture(rx_frame, constant(2.4us, 1));
         }
         """
     ).strip()
@@ -1103,8 +1104,8 @@ def test_returns():
         frame tx_frame = newframe(tx_port, 5752000000.0, 0);
         frame rx_frame = newframe(rx_port, 5752000000.0, 0);
         defcal measure_v1 $0 -> bit {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            return capture_v2(rx_frame, 2400.0ns);
+            play(tx_frame, constant(2.4us, 0.2));
+            return capture_v2(rx_frame, 2.4us);
         }
         int[32] j = 0;
         int[32] k = 0;
@@ -1118,8 +1119,8 @@ def test_returns():
     expected_defcal_measure_v1_q0 = textwrap.dedent(
         """
         defcal measure_v1 $0 -> bit {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            return capture_v2(rx_frame, 2400.0ns);
+            play(tx_frame, constant(2.4us, 0.2));
+            return capture_v2(rx_frame, 2.4us);
         }
         """
     ).strip()
@@ -1211,18 +1212,18 @@ def test_ramsey_example():
             frame tx_frame = newframe(tx_port, 5752000000.0, 0);
         }
         defcal readout $2 {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            capture(rx_frame, constant(2400.0ns, 1));
+            play(tx_frame, constant(2.4us, 0.2));
+            capture(rx_frame, constant(2.4us, 1));
         }
         defcal x90 $2 {
             play(q_frame, gaussian(32.0ns, 8.0ns, 0.2063, 0.0));
         }
         cal {
             for int shot in [0:1000] {
-                duration ramsey_delay = 12000.0ns;
+                duration ramsey_delay = 12.0us;
                 angle[32] tppi_angle = 0;
                 for int delay_increment in [0:80] {
-                    delay[100000.0ns];
+                    delay[100.0us];
                     set_phase(q_frame, 0);
                     set_phase(rx_frame, 0);
                     set_phase(tx_frame, 0);
@@ -1250,8 +1251,8 @@ def test_ramsey_example():
     expect_defcal_readout_q2 = textwrap.dedent(
         """
         defcal readout $2 {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            capture(rx_frame, constant(2400.0ns, 1));
+            play(tx_frame, constant(2.4us, 0.2));
+            capture(rx_frame, constant(2.4us, 1));
         }
         """
     ).strip()
@@ -1309,12 +1310,12 @@ def test_rabi_example():
             frame q0_readout_tx_frame = newframe(zcu216_dac230_0, 3571600000, 0);
             frame q0_readout_rx_frame = newframe(zcu216_adc225_0, 3571600000, 0);
             waveform rabi_pulse_wf = gaussian(52.0ns, 13.0ns, 1.0, 0.0);
-            waveform readout_waveform_wf = constant(1600.0ns, 0.02);
-            waveform readout_kernel_wf = constant(1600.0ns, 1);
+            waveform readout_waveform_wf = constant(1.6us, 0.02);
+            waveform readout_kernel_wf = constant(1.6us, 1);
             for int shot in [1:1000] {
                 set_scale(q0_transmon_xy_frame, -0.2);
                 for int amplitude in [1:101] {
-                    delay[200000.0ns] q0_transmon_xy_frame, q0_readout_tx_frame, q0_readout_rx_frame;
+                    delay[200.0us] q0_transmon_xy_frame, q0_readout_tx_frame, q0_readout_rx_frame;
                     set_phase(q0_transmon_xy_frame, 0);
                     set_phase(q0_readout_tx_frame, 0);
                     set_phase(q0_readout_rx_frame, 0);
@@ -1362,7 +1363,7 @@ def test_program_add():
         port p1;
         frame f1 = newframe(p1, 5000000000.0, 0);
         waveform wf = constant(100.0ns, 0.5);
-        delay[1000.0ns];
+        delay[1.0us];
         defcal x180 $1 {
             play(f1, wf);
         }
@@ -1566,7 +1567,7 @@ def test_annotate():
         prog.gate(q1, "x")
     with oqpy.Else(prog):
         prog.annotate(("annotation-in-else"))
-        prog.delay(make_duration(1e-8), q1)
+        prog.delay(convert_float_to_duration(1e-8), q1)
     prog.annotate("annotation-after-if")
 
     prog.annotate("annotation-no-else-before-if")
@@ -1716,14 +1717,14 @@ def test_duration_literal_arithmetic():
     # Test that duration literals can be used as a part of expression.
     port = oqpy.PortVar("myport")
     frame = oqpy.FrameVar(port, 1e9, name="myframe")
-    delay_time = oqpy.make_duration(50e-9)  # 50 ns
-    one_second = oqpy.make_duration(1)  # 1 second
+    delay_time = oqpy.convert_float_to_duration(50e-9)  # 50 ns
+    one_second = oqpy.convert_float_to_duration(1)  # 1 second
     delay_repetition = 10
 
     program = oqpy.Program()
     repeated_delay = delay_repetition * delay_time
     assert isinstance(repeated_delay, OQPyExpression)
-    assert repeated_delay.type == ast.DurationType
+    assert repeated_delay.type == ast.DurationType()
 
     program.delay(repeated_delay, frame)
     program.shift_phase(frame, 2 * oqpy.pi * (delay_time / one_second))
@@ -1734,7 +1735,7 @@ def test_duration_literal_arithmetic():
         port myport;
         frame myframe = newframe(myport, 1000000000.0, 0);
         delay[10 * 50.0ns] myframe;
-        shift_phase(myframe, 2 * pi * (50.0ns / 1000000000.0ns));
+        shift_phase(myframe, 2 * pi * (50.0ns / 1s));
         """
     ).strip()
 
@@ -1743,24 +1744,24 @@ def test_duration_literal_arithmetic():
 
 
 def test_make_duration():
-    assert expr_matches(make_duration(1e-3), OQDurationLiteral(1e-3))
-    assert expr_matches(make_duration(OQDurationLiteral(1e-4)), OQDurationLiteral(1e-4))
+    assert expr_matches(convert_float_to_duration(1e-3), OQDurationLiteral(1e-3))
+    assert expr_matches(convert_float_to_duration(OQDurationLiteral(1e-4)), OQDurationLiteral(1e-4))
 
     class MyExprConvertible:
         def _to_oqpy_expression(self):
             return OQDurationLiteral(1e-5)
 
-    assert expr_matches(make_duration(MyExprConvertible()), OQDurationLiteral(1e-5))
+    assert expr_matches(convert_float_to_duration(MyExprConvertible()), OQDurationLiteral(1e-5))
 
     class MyToAst:
         def to_ast(self):
             return OQDurationLiteral(1e-6)
 
     obj = MyToAst()
-    assert make_duration(obj) is obj
+    assert convert_float_to_duration(obj) is obj
 
     with pytest.raises(TypeError):
-        make_duration("asdf")
+        convert_float_to_duration("asdf")
 
 
 def test_autoencal():
@@ -1784,12 +1785,12 @@ def test_autoencal():
             extern constant(duration, complex[float[64]]) -> waveform;
             port portname;
             frame framename = newframe(portname, 1000000000.0, 0);
-            waveform kernel = constant(1000.0ns, 1);
+            waveform kernel = constant(1.0us, 1);
         }
         int[32] i = 0;
         i += 1;
         cal {
-            play(framename, constant(1000.0ns, 0.5));
+            play(framename, constant(1.0us, 0.5));
             capture(framename, kernel);
         }
         """
@@ -1869,11 +1870,11 @@ def test_ramsey_example_blog():
         }
         duration delay_time = 0.0ns;
         defcal reset $1 {
-            delay[1000000.0ns];
+            delay[1.0ms];
         }
         defcal measure $1 {
-            play(tx_frame, constant(2400.0ns, 0.2));
-            capture(rx_frame, constant(2400.0ns, 1));
+            play(tx_frame, constant(2.4us, 0.2));
+            capture(rx_frame, constant(2.4us, 1));
         }
         defcal x90 $1 {
             play(xy_frame, gaussian(32.0ns, 8.0ns, 0.2063));
@@ -1951,6 +1952,27 @@ def test_oqpy_range():
     _check_respects_type_hints(prog)
 
 
+def test_duration_coercion():
+    frame = FrameVar(name="f1")
+    prog = Program()
+    v = oqpy.FloatVar(0.1, name="v")
+    prog.delay(v * 100e-9, frame)
+    d = oqpy.DurationVar(100e-9, name="d")
+    prog.shift_phase(frame, d * 1e4)
+    expected = textwrap.dedent(
+        """
+        OPENQASM 3.0;
+        float[64] v = 0.1;
+        frame f1;
+        duration d = 100.0ns;
+        delay[v * 1e-07 * 1s] f1;
+        shift_phase(f1, d * 10000.0 / 1s);
+        """
+    ).strip()
+    assert prog.to_qasm() == expected
+    _check_respects_type_hints(prog)
+
+
 def test_io_declaration():
     x = oqpy.DurationVar("input", name="x")
     y = oqpy.FloatVar("output", name="y")