From 270c9049ae5c5c044619486a359306b10ca3e506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Caron?= Date: Fri, 1 Sep 2023 14:23:42 +0200 Subject: [PATCH 1/5] Add unit test to cover AsyncRate.dt --- tests/test_async_rate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_async_rate.py b/tests/test_async_rate.py index 17c48b0..be91061 100644 --- a/tests/test_async_rate.py +++ b/tests/test_async_rate.py @@ -38,6 +38,10 @@ async def test_init(self): """ self.assertIsNotNone(self.rate) + def test_period_dt(self): + """Check that period and dt are the same.""" + self.assertAlmostEqual(self.rate.period, self.rate.dt) + async def test_remaining(self): """ After one period has expired, the "remaining" time becomes negative. From a2ccbf6782ee260ff3c9f15b9da6f8bd52e7dbb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Caron?= Date: Fri, 1 Sep 2023 14:25:47 +0200 Subject: [PATCH 2/5] Add dt and next_tick properties to AsyncRateLimiter --- loop_rate_limiters/async_rate_limiter.py | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/loop_rate_limiters/async_rate_limiter.py b/loop_rate_limiters/async_rate_limiter.py index ff633b8..296eeba 100644 --- a/loop_rate_limiters/async_rate_limiter.py +++ b/loop_rate_limiters/async_rate_limiter.py @@ -57,12 +57,12 @@ class AsyncRateLimiter: exceeded the rate clock. """ + __next_tick: float + __period: float _last_loop_time: float _loop: asyncio.AbstractEventLoop - _next_tick: float measured_period: float name: str - period: float slack: float warn: bool @@ -80,22 +80,37 @@ def __init__( loop = asyncio.get_event_loop() period = 1.0 / frequency assert loop.is_running() + self.__period = period self._last_loop_time = loop.time() self._loop = loop - self._next_tick = loop.time() + period + self.__next_tick = loop.time() + period self.measured_period = 0.0 self.name = name - self.period = period self.slack = 0.0 self.warn = warn + @property + def dt(self) -> float: + """Desired period between two calls to :func:`sleep`, in seconds.""" + return self.__period + + @property + def next_tick(self) -> float: + """Time of next clock tick.""" + return self.__next_tick + + @property + def period(self) -> float: + """Desired period between two calls to :func:`sleep`, in seconds.""" + return self.__period + async def remaining(self) -> float: """Get the time remaining until the next expected clock tick. Returns: Time remaining, in seconds, until the next expected clock tick. """ - return self._next_tick - self._loop.time() + return self.__next_tick - self._loop.time() async def sleep(self, block_duration: float = 5e-4): """Sleep the duration required to regulate the loop frequency. @@ -116,17 +131,17 @@ async def sleep(self, block_duration: float = 5e-4): average error with a single asyncio.sleep). Empirically a block duration of 0.5 ms gives good behavior at 400 Hz or lower. """ - self.slack = self._next_tick - self._loop.time() + self.slack = self.__next_tick - self._loop.time() if self.slack > 0.0: - block_time = self._next_tick - block_duration - while self._loop.time() < self._next_tick: + block_time = self.__next_tick - block_duration + while self._loop.time() < self.__next_tick: if self._loop.time() < block_time: await asyncio.sleep(1e-5) # non-zero sleep duration - elif self.slack < -0.1 * self.period and self.warn: + elif self.slack < -0.1 * self.__period and self.warn: logging.warning( "%s is late by %f [ms]", self.name, round(1e3 * self.slack, 1) ) loop_time = self._loop.time() self.measured_period = loop_time - self._last_loop_time self._last_loop_time = loop_time - self._next_tick = loop_time + self.period + self.__next_tick = loop_time + self.__period From 2f0a2be05e72fa0a6efcb4046e235a454fd76e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Caron?= Date: Fri, 1 Sep 2023 14:30:29 +0200 Subject: [PATCH 3/5] AsyncRateLimiter: make measured_period a property --- loop_rate_limiters/async_rate_limiter.py | 61 ++++++++++++++---------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/loop_rate_limiters/async_rate_limiter.py b/loop_rate_limiters/async_rate_limiter.py index 296eeba..2d993d9 100644 --- a/loop_rate_limiters/async_rate_limiter.py +++ b/loop_rate_limiters/async_rate_limiter.py @@ -47,23 +47,18 @@ class AsyncRateLimiter: https://github.com/ros/ros_comm/blob/noetic-devel/clients/rospy/src/rospy/timer.py Attributes: - measured_period: Actual period in seconds measured at the end of the - last call to :func:`sleep`. name: Human-readable name used for logging. - period: Desired loop period in seconds. - slack: Duration in seconds remaining until the next tick at the - beginning of the last call to :func:`sleep`. warn: If set (default), warn when the time between two calls exceeded the rate clock. """ + __last_loop_time: float + __loop: asyncio.AbstractEventLoop + __measured_period: float __next_tick: float __period: float - _last_loop_time: float - _loop: asyncio.AbstractEventLoop - measured_period: float + __slack: float name: str - slack: float warn: bool def __init__( @@ -80,13 +75,13 @@ def __init__( loop = asyncio.get_event_loop() period = 1.0 / frequency assert loop.is_running() - self.__period = period - self._last_loop_time = loop.time() - self._loop = loop + self.__last_loop_time = loop.time() + self.__loop = loop + self.__measured_period = 0.0 self.__next_tick = loop.time() + period - self.measured_period = 0.0 + self.__period = period + self.__slack = 0.0 self.name = name - self.slack = 0.0 self.warn = warn @property @@ -94,6 +89,14 @@ def dt(self) -> float: """Desired period between two calls to :func:`sleep`, in seconds.""" return self.__period + @property + def measured_period(self) -> float: + """Period measured at the end of the last call to :func:`sleep`. + + This duration is in seconds. + """ + return self.__measured_period + @property def next_tick(self) -> float: """Time of next clock tick.""" @@ -104,13 +107,21 @@ def period(self) -> float: """Desired period between two calls to :func:`sleep`, in seconds.""" return self.__period + @property + def slack(self) -> float: + """Slack duration computed at the last call to :func:`sleep`. + + This duration is in seconds. + """ + return self.__slack + async def remaining(self) -> float: """Get the time remaining until the next expected clock tick. Returns: Time remaining, in seconds, until the next expected clock tick. """ - return self.__next_tick - self._loop.time() + return self.__next_tick - self.__loop.time() async def sleep(self, block_duration: float = 5e-4): """Sleep the duration required to regulate the loop frequency. @@ -131,17 +142,19 @@ async def sleep(self, block_duration: float = 5e-4): average error with a single asyncio.sleep). Empirically a block duration of 0.5 ms gives good behavior at 400 Hz or lower. """ - self.slack = self.__next_tick - self._loop.time() - if self.slack > 0.0: + self.__slack = self.__next_tick - self.__loop.time() + if self.__slack > 0.0: block_time = self.__next_tick - block_duration - while self._loop.time() < self.__next_tick: - if self._loop.time() < block_time: + while self.__loop.time() < self.__next_tick: + if self.__loop.time() < block_time: await asyncio.sleep(1e-5) # non-zero sleep duration - elif self.slack < -0.1 * self.__period and self.warn: + elif self.__slack < -0.1 * self.__period and self.warn: logging.warning( - "%s is late by %f [ms]", self.name, round(1e3 * self.slack, 1) + "%s is late by %f [ms]", + self.name, + round(1e3 * self.__slack, 1), ) - loop_time = self._loop.time() - self.measured_period = loop_time - self._last_loop_time - self._last_loop_time = loop_time + loop_time = self.__loop.time() + self.__measured_period = loop_time - self.__last_loop_time + self.__last_loop_time = loop_time self.__next_tick = loop_time + self.__period From de0efe3b0a4666bd93addf5e4142b5c7fa64c862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Caron?= Date: Fri, 1 Sep 2023 14:31:08 +0200 Subject: [PATCH 4/5] Update the changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0719f40..5f230e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added + +- AsyncRateLimiter: `dt` property + +### Changed + +- AsyncRateLimiter: `measured_period` is now a property +- AsyncRateLimiter: `next_tick` is now a property +- AsyncRateLimiter: `period` is now a property +- AsyncRateLimiter: `slack` is now a property + ## [0.5.0] - 2023/07/25 ### Added From f6eaf564a04c5e4f02025ec58ed9eb34944bbbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Caron?= Date: Fri, 1 Sep 2023 14:32:02 +0200 Subject: [PATCH 5/5] Add success check to CI workflow --- .github/workflows/main.yml | 47 ++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 80e1b4c..1e5ffc0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,57 +7,57 @@ on: branches: [ main ] jobs: - lint: - name: "Code style" + coverage: + name: "Coverage" runs-on: ubuntu-latest steps: - name: "Checkout sources" uses: actions/checkout@v3 - - name: "Set up Python ${{ matrix.python-version }}" + - name: "Set up Python 3.8" uses: actions/setup-python@v4 with: - python-version: "${{ matrix.python-version }}" + python-version: "3.8" - name: "Install dependencies" run: | python -m pip install --upgrade pip # tox version: https://github.com/tox-dev/tox/issues/2778 - python -m pip install tox==3.28.0 + python -m pip install coveralls tox==3.28.0 - - name: "Test with tox for ${{ matrix.os }}" + - name: "Check code coverage" run: | - tox -e lint + tox -e coverage - coverage: - name: "Coverage" + - name: "Coveralls" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + coveralls --service=github + + lint: + name: "Code style" runs-on: ubuntu-latest steps: - name: "Checkout sources" uses: actions/checkout@v3 - - name: "Set up Python 3.8" + - name: "Set up Python ${{ matrix.python-version }}" uses: actions/setup-python@v4 with: - python-version: "3.8" + python-version: "${{ matrix.python-version }}" - name: "Install dependencies" run: | python -m pip install --upgrade pip # tox version: https://github.com/tox-dev/tox/issues/2778 - python -m pip install coveralls tox==3.28.0 - - - name: "Check code coverage" - run: | - tox -e coverage + python -m pip install tox==3.28.0 - - name: "Coveralls" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: "Test with tox for ${{ matrix.os }}" run: | - coveralls --service=github + tox -e lint test: name: "Test ${{ matrix.os }} with python-${{ matrix.python-version }}" @@ -88,3 +88,10 @@ jobs: tox env: PLATFORM: ${{ matrix.os }} + + ci_success: + name: "CI success" + runs-on: ubuntu-latest + needs: [coverage, lint, test] + steps: + - run: echo "CI workflow completed successfully"