Subtraction leads to negative time in ROS 2 rclpy (use_sim_time)

한국어로 읽기

What happened when I ran it

A Python node with use_sim_time enabled computes “one second ago” right after startup:

since = self.get_clock().now() - Duration(seconds=1.0)

and gets this:

ValueError: Subtraction leads to negative time.

The usual idea is to start the simulator clock somewhere other than zero. That’s what the Robotics Stack Exchange question “How can you start gazebo with a non-0 simulated clock time?” asks for. If the clock starts at 100 s, then 100 − 1 = 99 and nothing can go negative. Or so it seems.

I wanted to know how much of the problem a start offset actually removes, so I left Gazebo out and tested only the ROS side. A small fake simulator node published /clock starting from S = 0, 0.5, 1 and 100 seconds. A use_sim_time node computed now() - W for windows W of 0.25, 1 and 2 seconds in its constructor, in a wall-clock timer, and in sim-clock timers at 10 Hz and 20 Hz. All of it ran in the official ros:jazzy-ros-base container with networking off. I ran it five times, and every count came out the same in all five runs. What I found:

  • Starting at 100 s did not stop the exception before the first /clock. The constructor read now() as 0 and raised the error. A wall-clock timer raised it on every call until the first /clock arrived. That happened in all four start times.
  • After the first /clock, starting at 100 s fixed it. The sim-clock timers hit no exceptions at all.
  • Sim-clock timers never fired while the clock was still 0. The callbacks that hit the zero window were the constructor and the wall timer, not the sim-clock timers.
  • Starting at or below the window doesn’t fix it. With S = 1 and W = 1 the exception went away, but the first callback got a time of exactly 0. With S = 0.5 and W = 1 the first five 10 Hz callbacks still raised.
  • A result of exactly 0 is not harmless. In tf2_ros, a lookup at time 0 returned the newest transform in the buffer, not the one from W seconds ago.

So the short answer: a start offset larger than your window fixes everything after the first /clock, but nothing before it. You still need a guard, and the guard has to skip when now.nanoseconds <= window.nanoseconds, not just when now is less than the window.

This is a test of rclpy and rcl time handling on ROS 2 Jazzy, not a Gazebo run. Gazebo’s start-time flags are covered later from their source code and changelogs, but I didn’t run them.

Where the exception comes from

In rclpy (Jazzy branch, read at commit 4e607c0), Time.__sub__ with a Duration builds a new Time from self.nanoseconds - other.nanoseconds. The Time constructor rejects negative nanoseconds with ValueError('Nanoseconds value must not be negative'), and __sub__ catches that and re-raises it as 'Subtraction leads to negative time.', keeping the original as the cause (time.py L43-L60, L99-L115).

Some details matter later:

  • A result of exactly 0 ns is allowed. Time(1 s) - Duration(1 s) returns a valid time of 0, with no exception.
  • Time - Time doesn’t raise when the result is negative. It returns a Duration, and Duration only checks for int64 overflow, so negative durations are fine (duration.py L31-L44). The error only comes from subtracting a Duration from a Time.
  • You can’t compare a Time with a Duration. now < WINDOW raises TypeError, so a guard has to compare .nanoseconds values.

Why a 100 s start time doesn’t help before the first /clock

With use_sim_time on, the node’s ROS clock returns the latest value received on /clock (ROS 2 Clock and Time design). Before anything arrives there is no latest value. rclpy’s TimeSource starts with Time(clock_type=ROS_TIME), which is 0 ns, and only replaces it when a /clock message comes in (time_source.py L41-L42, L115-L128). The design document is explicit about what that zero means: “A time value of zero should be considered an error meaning that time is uninitialized” (130_ros_time.md L112-L117).

Setting the simulator to 100 s changes what the simulator will publish. It doesn’t change what your node reads before the first message. Until then, now() - Duration(seconds=1.0) is 0 - 1 s, whatever start time you chose.

Which callbacks actually hit the zero window

It’s easy to assume your timer callback is what fails at startup. For sim-clock timers, the rcl source says otherwise. create_timer uses the node’s clock by default (node.py L1777-L1781). An rcl timer sets its first deadline to now + period when it is created (timer.c L198-L199). While the clock sits at 0, that deadline never comes. When the first /clock jumps the time forward past the deadline, the timer is woken once right away (timer.c L113-L118).

So the code that sees now() == 0 is code that doesn’t wait for sim time:

  • the node constructor,
  • timers on the wall clock (create_timer(..., clock=Clock(clock_type=ClockType.STEADY_TIME)) in rclpy, create_wall_timer in rclcpp),
  • subscription and service callbacks that run before the first /clock, for example a sensor topic that starts publishing first.

How many sim-clock callbacks fail after the first /clock

Take a sim-clock timer at f Hz, a window of W seconds, and a clock that starts at 0. The ticks land at k/f seconds for k = 1, 2, 3, … A tick raises when k/f < W. That gives ⌈W·f⌉ − 1 failing ticks. When W·f is a whole number, the next tick (k = W·f) doesn’t raise but returns a time of exactly 0. The shortcut W·f − 1 is only right when W·f is a whole number. With W = 0.25 s and f = 10 Hz it gives 1.5, which can’t be a count. The real answer is 2 (the 0.1 s and 0.2 s ticks).

If the clock starts at S instead, the first callback after the first /clock computes S − W. It’s positive if S > W, exactly 0 if S = W, and raises if S < W.

The failing stretch lasts W seconds of sim time. If the real-time factor r (sim seconds per wall second) stays constant, that’s W ÷ r seconds on the wall clock. At r = 0.25 and W = 1 s, you watch the errors for 4 wall seconds. The number of failing ticks doesn’t depend on r.

These counts assume the timer was created at time 0 and that /clock arrives often enough that now() inside the callback equals the tick time. The experiment below checks both.

The experiment: a fake /clock and four start times

One process, one SingleThreadedExecutor, two nodes. The fake simulator node (use_sim_time off) publishes /clock starting at S, advancing 10 ms of sim time per message, one message every 5 ms of wall time, so a nominal real-time factor of 2. Once the subject node’s /clock subscription is matched, the publisher deliberately waits 0.5 s of wall time before the first message. That wait is the “before the first /clock” window. Then it runs until S + 2.5 s of sim time.

The subject node (use_sim_time on) computes now() - 1 s in its constructor and in a 20 Hz STEADY (wall-clock) timer. It records now() in a 10 Hz and a 20 Hz sim-clock timer. Afterwards, in the same run, it replays the real rclpy subtraction on every recorded now() for W = 0.25, 1 and 2 s and counts exceptions, zero results and positive results. It also applies two guards, “skip if now <= W” and “skip if now == 0 or now < W“, and counts what gets past each one. Each start time uses its own ROS domain ID and rclpy context.

The code, plan and raw output of every run are in the reproduction folder. The exact files used for this article are experiment.py, plan.md (the predictions, written before the run) and results.json. To rerun it in an empty directory with Docker, using the same image digest:

curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/1062d4a269e61217efbe154a831fef37a65920f6/experiments/gazebo-nonzero-sim-clock-start-rclpy-negative-time-window/experiment.py
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"

A good run prints JSON lines in this order: env, static, tf2_time_zero, four scenario lines and summary, and exits with code 0.

Environment: image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy package 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default), no network, 1 CPU and 1 GiB. 5 runs, all with exit code 0, 8.6 to 8.61 s each. Every count below was identical in all 5 runs.

Before the first /clock (same for S = 0, 0.5, 1 and 100)

Check Prediction (plan.md) Measured
now() in the constructor 0 now_ns 0 in all four scenarios
now() - 1 s in the constructor ValueError ValueError in 4 of 4
20 Hz STEADY timer calls that saw now = 0 exception every time 10 calls, 10 exceptions, in every scenario
Sim-clock timer calls before the first /clock none 0 at 10 Hz and 0 at 20 Hz
Wall time from node creation to first /clock publisher holds for 0.5 s 0.516 to 0.589 s

S = 0, sim-clock timers (exceptions / zero results)

W (s) f (Hz) Prediction ⌈W·f⌉ − 1 / zero Measured First positive tick, now (s)
0.25 10 2 / 0 2 / 0 0.3
0.25 20 4 / 1 4 / 1 0.3
1 10 9 / 1 9 / 1 1.1
1 20 19 / 1 19 / 1 1.05
2 10 19 / 1 19 / 1 2.1
2 20 39 / 1 39 / 1 2.05

With S = 0 the first sim-clock call came at now = 0.1 s (10 Hz, 24 calls) and 0.05 s (20 Hz, 49 calls). For W = 1 s and f = 10 Hz, the first positive tick arrived 0.55 s of wall time after the first /clock.

S above 0, sim-clock timers (exceptions / zero results)

S (s) W (s) Prediction (plan.md) Measured 10 Hz Measured 20 Hz
0.5 0.25 no numeric prediction 0 / 0 0 / 0
0.5 1 5 / 1 at 10 Hz (first result −0.5 s) 5 / 1 10 / 1
0.5 2 no numeric prediction 15 / 1 30 / 1
1 0.25 no numeric prediction 0 / 0 0 / 0
1 1 first result 0, no exception 0 / 1 0 / 1
1 2 no numeric prediction 10 / 1 20 / 1
100 0.25, 1, 2 0 exceptions after the first /clock 0 / 0 0 / 0

For every S above 0, the first sim-clock call of both timers had now equal to S (0.5, 1.0 or 100.0 s), with 25 calls at 10 Hz and 50 at 20 Hz. The 20 Hz STEADY timer (W = 1 s) made 25 calls after the first /clock, with 10 exceptions at S = 0, 5 at S = 0.5, 0 at S = 1 and 0 at S = 100. Measured real-time factor: 2.0 (one scenario 2.001). Callbacks whose now was off the tick grid: 0 in every run.

Guards, applied to the same recorded ticks: “skip if now.nanoseconds <= W.nanoseconds” left 0 exceptions and 0 zero results in every scenario. “Skip if now is 0 or less than W” left 0 exceptions, but 1 zero result in every case above that has a zero tick.

tf2: with transforms stamped 98, 99 and 100 s in a tf2_ros.Buffer, a lookup at ROS_TIME 0 returned the 100 s transform. A lookup at 99 s returned the 99 s transform.

Raw output from run 1. The scenario line is cut off before "timers" because it is long.

{"kind": "static", "zero_minus_1s": {"type": "ValueError", "msg": "Subtraction leads to negative time.", "cause": "Nanoseconds value must not be negative"}, "one_s_minus_1s_ns": 0, "time_minus_later_time": {"type": "Duration", "ns": -1000000000}, "time_lt_duration": {"type": "TypeError", "msg": "'<' not supported between instances of 'Time' and 'Duration'"}}
{"kind": "tf2_time_zero", "stored_stamps_s": [98, 99, 100], "query_zero_ros_time": {"stamp_s": 100.0, "x": 100.0}, "query_99s": {"stamp_s": 99.0, "x": 99.0}}
{"kind": "scenario", "S_s": 100.0, "domain_id": 43, "ctor": {"now_ns": 0, "minus_1s": {"neg": "Subtraction leads to negative time."}}, "wall_timer_20hz": {"calls_now0": 10, "exc_now0": 10, "calls_after_clock": 25, "exc_after_clock": 0}, "pre_clock_wall_s": 0.516, "first_clock_received_s": 100.0, "sim_reached_s": 102.5, "measured_r": 2.0,
{"kind": "summary", "scenarios": 4, "all_reached_end": true, "ctor_exceptions": 4, "elapsed_wall_s": 7.4}

What the numbers mean

Every prediction written in the plan before the run matched. Here’s what I take from them.

The S = 100 line answers the original question. With the clock starting at 100 s, the constructor still read 0 and raised, and the wall-clock timer raised on every call before the first /clock. After the first /clock, nothing raised. A start offset fixes the second half of startup only.

Sim-clock timers start counting when the clock first jumps forward. With S above 0, both timers fired once at exactly S, which is the immediate wake-up after a forward jump that timer.c describes. With S = 0 the first /clock carries 0, which isn’t a jump, so the first tick came one period later. That explains the 24 and 49 calls at S = 0 against 25 and 50 elsewhere.

An offset at or below the window only shortens the failing stretch. For every case with 0 < S ≤ W, the measured exception count equals (W − S)·f. For example, S = 0.5, W = 2, f = 20 gives 1.5 × 20 = 30. This pattern comes from these runs, where S was a whole number of timer periods and /clock was dense. I haven’t tested it as a general formula. At S = W the exception disappears, but the first callback produces a time of 0.

The wall-clock lengths follow W ÷ r. At the measured r of 2, W = 1 s is 0.5 s of wall time. The first positive 10 Hz tick (now = 1.1 s) arrived 0.55 s after the first /clock, which matches. The wall timer’s 10 exceptions after the first /clock at S = 0 fit the same picture: at 20 Hz on the wall clock and r = 2, each call is about 0.1 s of sim time apart, so about ten calls fall before now reaches 1 s.

Limits

  • No Gazebo, no ros_gz_bridge. The /clock here was dense (every 10 ms of sim time). If your bridge publishes /clock less often than your timer period, callbacks can run after /clock has already moved past the tick. Then the boundary tick can come out as a small positive time instead of 0, and the counts can change. In a trial run before the final five, some 20 Hz callbacks did land off the grid this way. The final runs had none.
  • One process and one executor make the order of /clock delivery and timer callbacks more regular than a multi-process launch.
  • Only rclpy on Jazzy was tested. rclcpp, Humble and other distributions weren’t checked, and neither was real hardware. This tells you nothing about robot safety.
  • How long the node waits for its first /clock on your system depends on your launch order and the simulator. The 0.5 s here was chosen by the experiment.

A guard that also catches the zero boundary

A result of exactly 0 doesn’t raise, so it’s easy to treat it as fine. In tf2 it isn’t. The ROS 2 Jazzy tf2 tutorials define a lookup at time 0 as “the latest available transform” (C++ tutorial source L57, Python listener tutorial source L207), and the measurement above showed it. On the boundary tick, you ask for the pose one second ago and quietly get the newest pose instead. No error, just wrong data. That’s harder to find than the exception.

So the guard has to include the boundary. The snippet below is illustrative. Its condition is the same one counted in the experiment’s post-processing, but I didn’t run this exact callback.

from rclpy.duration import Duration

WINDOW = Duration(seconds=1.0)

def on_timer(self):
    now = self.get_clock().now()
    if now.nanoseconds <= WINDOW.nanoseconds:
        return  # no /clock yet (now == 0), or now == WINDOW (result would be time 0)
    since = now - WINDOW
    # use `since` for the tf2 lookup or to select a data window

One <= covers both cases. As long as the window is positive, 0 ns is caught, and so is the tick where now equals the window exactly. A guard written as “now == 0 or now < WINDOW” lets that boundary tick through, which is what the measurement showed. Use the same check in wall timers and in sensor or service callbacks. Don’t compute “W seconds ago” in the constructor at all. With sim time on, it read 0 every time, so move that work to the first guarded callback.

If you only need to compare times, add instead of subtracting. One answer to the Stack Exchange question suggests the same thing: write t1 >= t2 + d instead of t1 - d >= t2. Adding a non-negative duration to a non-negative time can’t go below zero, so this works even when t2 is 0. If you need the “W seconds ago” timestamp itself, for example for a tf2 lookup, you still have to subtract, and you still need the guard.

The cost of the guard is skipping your logic for the first W seconds of sim time. Log that once rather than every tick, or the startup log fills with the same line. That’s my judgement, not something from the source.

Setting the start time in Gazebo

If you also want the offset, the flag depends on which Gazebo you run. This table is from source code and changelogs, not from runs.

Gazebo Start-time option Evidence
Gazebo Sim 7.5.0 (Garden) and later, including 8.x (Harmonic) --initial-sim-time <seconds> (hyphens) gz-sim Changelog 7.5.0 entry, gz-sim8 cmdsim.rb.in L284-L287
Gazebo Classic 11.13.0 and later --initial_sim_time (underscores); applied after reset since 11.14.0 gazebo-classic Changelog 11.13.0, 11.14.0, Server.cc
Fortress (ign-gazebo6) none no such option in cmdgazebo.rb.in

In gz-sim, the value becomes the runner’s simTimeEpoch (SimulationRunner.cc L215-L219), and a rewind goes back to it instead of to 0 (L371-L377). Your ROS distribution usually decides which row applies. Gazebo’s install guide pairs Jazzy with Harmonic and Humble with Fortress (Installing Gazebo with ROS). On the ROS side, /clock comes through ros_gz_bridge as rosgraph_msgs/msg/Clock (ros_gz_bridge README). Some answers suggest setting <state><sim_time> in the SDF world instead. A Fortress user reported that it had no effect, and I found no official confirmation, so I don’t recommend it.

What to do

  • Always: add the now.nanoseconds <= window.nanoseconds guard wherever you compute now() - window, including wall timers and topic or service callbacks. No start offset covers the time before the first /clock.
  • Humble + Fortress: there’s no start-time flag, so the guard is the only fix.
  • Jazzy + Harmonic, or Classic 11.13+: you can also pass --initial-sim-time or --initial_sim_time with a value well above your largest window. That removes negative and zero results after the first /clock. Don’t set it equal to the window (the first callback gets time 0), and don’t set it below (the first callbacks still raise).

Still open, because they need a real Gazebo run: how the flag behaves when passed through gz_args in a ros_gz_sim launch, whether /clock is published when the simulation starts paused, and how much your bridge’s /clock rate shifts the boundary-tick counts. The guard works without knowing any of that. If you want to turn the counts into test expectations, check your own /clock rate first.

Sources