Repository: cocotb/cocotb

Issue:
Handling imprecise time values
## Problem

A couple users (to my memory @alexforencich and @jwprice100) have lamented about how timing is handled in cocotb. When one creates a time wait with `Timer` with a value that cannot be accurately represented in the simulator, a `ValueError` is thrown. Additionally, the useful helper method `get_sim_steps` also throws a `ValueError` if a value is given which cannot be accurately represented, which is *very* limiting.

## Proposed Solutions

There are several suggestions, feel free to agree/debate each independently.

Change `get_sim_steps` to take an argument to decide how to handle imprecise values by rounding. The round modes would be:
* `error`: throw `ValueError` like is currently done (default)
* `round`: round to the nearest step using `round` builtin
* `ceil`: round up to the nearest step using `math.ceil`
* `floor`: round down to the nearest step using `math.floor`

This argument could also be passed to `Timer`.

An environment variable, a `cocotb` module global variable, or an attribute on the `get_sim_steps` object could be added to globally affect the default rounding mode.

The default of `error` could be changed in 2.0 to be something more sensible like `ceil`. If this was to be done, a `FutureWarning` about the default should be added. This change would not break existing passing tests, but may make existing failing tests pass and would be difficult to change in the future without breaking tests, so it must be considered carefully.


Discussion:

Resolving patch:
diff --git a/cocotb/triggers.py b/cocotb/triggers.py
--- a/cocotb/triggers.py
+++ b/cocotb/triggers.py
@@ -30,6 +30,9 @@
 import abc
 import warnings
 from collections.abc import Awaitable
+from typing import Union, Optional
+from numbers import Real
+from decimal import Decimal
 
 from cocotb import simulator
 from cocotb.log import SimLog
@@ -156,17 +159,26 @@ def unprime(self):
 
 
 class Timer(GPITrigger):
-    """Fires after the specified simulation time period has elapsed."""
-
-    def __init__(self, time=None, units="step", *, time_ps=None):
+    """Fire after the specified simulation time period has elapsed."""
+
+    round_mode: str = "error"
+
+    def __init__(
+        self,
+        time: Union[Real, Decimal] = None,
+        units: str = "step",
+        round_mode: Optional[str] = None,
+        *,
+        time_ps: Union[Real, Decimal] = None
+    ) -> None:
         """
         Args:
-           time (numbers.Real or decimal.Decimal): The time value.
+           time: The time value.
 
                .. versionchanged:: 1.5.0
                   Previously this argument was misleadingly called `time_ps`.
 
-           units (str, optional): One of
+           units: One of
                ``'step'``, ``'fs'``, ``'ps'``, ``'ns'``, ``'us'``, ``'ms'``, ``'sec'``.
                When *units* is ``'step'``,
                the timestep is determined by the simulator (see :make:var:`COCOTB_HDL_TIMEPRECISION`).
@@ -210,6 +222,9 @@ def __init__(self, time=None, units="step", *, time_ps=None):
 
         .. deprecated:: 1.5
             Using None as the the *units* argument is deprecated, use ``'step'`` instead.
+
+        .. versionchanged:: 1.6
+            Support rounding modes.
         """
         GPITrigger.__init__(self)
         if time_ps is not None:
@@ -234,7 +249,9 @@ def __init__(self, time=None, units="step", *, time_ps=None):
                 'Using units=None is deprecated, use units="step" instead.',
                 DeprecationWarning, stacklevel=2)
             units = "step"  # don't propagate deprecated value
-        self.sim_steps = get_sim_steps(time, units)
+        if round_mode is None:
+            round_mode = type(self).round_mode
+        self.sim_steps = get_sim_steps(time, units, round_mode)
 
     def prime(self, callback):
         """Register for a timed callback."""
diff --git a/cocotb/utils.py b/cocotb/utils.py
--- a/cocotb/utils.py
+++ b/cocotb/utils.py
@@ -26,7 +26,9 @@
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 """Collection of handy functions."""
-
+from typing import Union
+from numbers import Real
+from decimal import Decimal
 import ctypes
 import inspect
 import math
@@ -102,41 +104,67 @@ def get_time_from_sim_steps(steps, units):
     return _ldexp10(steps, _get_simulator_precision() - _get_log_time_scale(units))
 
 
-def get_sim_steps(time, units="step"):
+def get_sim_steps(
+    time: Union[Real, Decimal],
+    units: str = "step",
+    round_mode: str = "error"
+) -> int:
     """Calculates the number of simulation time steps for a given amount of *time*.
 
+    When *round_mode* is ``"error"``, a :exc:`ValueError` is thrown if the value cannot
+    be accurately represented in terms of simulator time steps.
+    When *round_mode* is ``"round"``, ``"ceil"``, or ``"floor"``, the corresponding
+    rounding function from the standard library will be used to round to a simulator
+    time step.
+
     Args:
-        time (numbers.Real or decimal.Decimal):  The value to convert to simulation time steps.
-        units (str, optional):  String specifying the units of the result
+        time: The value to convert to simulation time steps.
+        units: String specifying the units of the result
             (one of ``'step'``, ``'fs'``, ``'ps'``, ``'ns'``, ``'us'``, ``'ms'``, ``'sec'``).
-            ``'step'`` means time is already in simulation time steps.
+            ``'step'`` means *time* is already in simulation time steps.
+        round_mode: String specifying how to handle time values that sit between time steps
+            (one of ``'error'``, ``'round'``, ``'ceil'``, ``'floor'``).
 
     Returns:
-        int: The number of simulation time steps.
+        The number of simulation time steps.
 
     Raises:
-        :exc:`ValueError`: If given *time* cannot be represented by simulator precision.
+        ValueError: if the value cannot be represented accurately in terms of simulator
+            time steps when *round_mode* is ``"error"``.
 
     .. versionchanged:: 1.5
         Support ``'step'`` as the the *units* argument to mean "simulator time step".
+
+    .. versionchanged:: 1.6
+        Support rounding modes.
     """
-    result = time
     if units not in (None, "step"):
-        result = _ldexp10(result, _get_log_time_scale(units) - _get_simulator_precision())
+        result = _ldexp10(time, _get_log_time_scale(units) - _get_simulator_precision())
+    else:
+        result = time
     if units is None:
         warnings.warn(
             'Using units=None is deprecated, use units="step" instead.',
             DeprecationWarning, stacklevel=2)
         units="step"  # don't propagate deprecated value
 
-    result_rounded = math.floor(result)
-
-    if result_rounded != result:
-        raise ValueError("Unable to accurately represent {}({}) with the "
-                         "simulator precision of 1e{}".format(
-                             time, units, _get_simulator_precision()))
+    if round_mode == "error":
+        result_rounded = math.floor(result)
+        if result_rounded != result:
+            precision = _get_simulator_precision()
+            raise ValueError(
+                f"Unable to accurately represent {time}({units}) with the simulator precision of 1e{precision}"
+            )
+    elif round_mode == "ceil":
+        result_rounded = math.ceil(result)
+    elif round_mode == "round":
+        result_rounded = round(result)
+    elif round_mode == "floor":
+        result_rounded = math.floor(result)
+    else:
+        raise ValueError(f"Invalid round_mode specifier: {round_mode}")
 
-    return int(result_rounded)
+    return result_rounded
 
 
 def _get_log_time_scale(units):


Test patch:
diff --git a/tests/test_cases/test_cocotb/Makefile b/tests/test_cases/test_cocotb/Makefile
--- a/tests/test_cases/test_cocotb/Makefile
+++ b/tests/test_cases/test_cocotb/Makefile
@@ -21,6 +21,7 @@ MODULE := "\
 	test_logging,\
 	test_pytest,\
 	test_queues,\
+	test_utils,\
 	"
 
 ifeq ($(SIM),questa)
diff --git a/tests/test_cases/test_cocotb/test_timing_triggers.py b/tests/test_cases/test_cocotb/test_timing_triggers.py
--- a/tests/test_cases/test_cocotb/test_timing_triggers.py
+++ b/tests/test_cases/test_cocotb/test_timing_triggers.py
@@ -12,6 +12,7 @@
 """
 import cocotb
 import warnings
+import pytest
 from cocotb.triggers import Timer, RisingEdge, ReadOnly, ReadWrite, Join, NextTimeStep, First, TriggerException
 from cocotb.utils import get_sim_time, get_sim_steps
 from cocotb.clock import Clock
@@ -255,3 +256,24 @@ async def test_time_units_eq_None(dut):
         await cocotb.triggers.with_timeout(example(), timeout_time=12_000_000, timeout_unit=None)
         assert issubclass(w[-1].category, DeprecationWarning)
         assert 'Using timeout_unit=None is deprecated, use timeout_unit="step" instead.' in str(w[-1].message)
+
+
+@cocotb.test()
+async def test_timer_round_mode(_):
+
+    # test invalid round_mode specifier
+    with pytest.raises(ValueError) as e:
+        Timer(1, "step", "notvalid")
+    assert "invalid" in str(e).lower()
+
+    # test default, update if default changes
+    with pytest.raises(ValueError):
+        Timer(0.5, "step")
+
+    # test valid
+    with pytest.raises(ValueError):
+        Timer(0.5, "step", "error")
+    assert Timer(24.0, "step", "error").sim_steps == 24
+    assert Timer(1.2, "step", "floor").sim_steps == 1
+    assert Timer(1.2, "step", "ceil").sim_steps == 2
+    assert Timer(1.2, "step", "round").sim_steps == 1
diff --git a/tests/test_cases/test_cocotb/test_utils.py b/tests/test_cases/test_cocotb/test_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_cocotb/test_utils.py
@@ -0,0 +1,27 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+import cocotb
+import cocotb.utils as utils
+import pytest
+
+
+@cocotb.test()
+async def test_get_sim_steps(_):
+
+    # test invalid round_mode specifier
+    with pytest.raises(ValueError) as e:
+        utils.get_sim_steps(1, "step", "notvalid")
+    assert "invalid" in str(e).lower()
+
+    # test default, update if default changes
+    with pytest.raises(ValueError):
+        utils.get_sim_steps(0.5, "step")
+
+    # test valid
+    with pytest.raises(ValueError):
+        utils.get_sim_steps(0.5, "step", "error")
+    assert utils.get_sim_steps(24.0, "step", "error") == 24
+    assert utils.get_sim_steps(1.2, "step", "floor") == 1
+    assert utils.get_sim_steps(1.2, "step", "ceil") == 2
+    assert utils.get_sim_steps(1.2, "step", "round") == 1