Diagnostics.h
Go to the documentation of this file.
1/**
2 * This file is part of ArmarX.
3 *
4 * ArmarX is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License version 2 as
6 * published by the Free Software Foundation.
7 *
8 * ArmarX is distributed in the hope that it will be useful, but
9 * WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 *
16 * @author Fabian Reister ( fabian dot reister at kit dot edu )
17 * @date 2026
18 * @copyright http://www.gnu.org/licenses/gpl-2.0.txt
19 * GNU General Public License
20 */
21
22#pragma once
23
24#include <atomic>
25#include <cstddef>
26#include <cstdint>
27#include <string>
28#include <utility>
29#include <vector>
30
33
35{
36
37 /**
38 * @brief What the diagnostics mode records and where it writes it.
39 *
40 * Read once, in the controller's constructor, and never again: the buffers below are sized
41 * from it and must not be resized while the real-time thread is writing into them. Turning
42 * diagnostics on is therefore a config edit plus a restart of the navigator, which is the
43 * right cost for a mode that exists to answer one question.
44 */
45 struct Params
46 {
47 bool enabled{false};
48
49 /// How much history the 1 kHz ring keeps [s]. 60 s is ~5 MB.
50 float rtDurationSeconds{60.F};
51
52 /// How much history the 100 Hz ring keeps [s].
54
55 /// How many distinct trajectories one episode may record. The navigator replans during a
56 /// request, so a dump that kept only the last one would mislabel most of the run.
58
59 std::string outputDirectory{"/tmp/armarx-navigation-diagnostics"};
60
61 /// Bumping this asks the controller to write the current episode and begin the next one.
62 /// The only member of this struct that is read after construction.
64 };
65
66 /**
67 * @brief One cycle of the 1 kHz real-time loop.
68 *
69 * Trivially copyable and fixed size: this is written from `rtRun`, so it must contain no
70 * pointer, no container and nothing whose assignment could allocate.
71 */
72 struct RtSample
73 {
74 std::int64_t timestampUs{0};
75 std::uint64_t episode{0};
76
77 /// `additionalTaskSequence` as observed this cycle. Joins this row to `control.csv`.
78 std::uint64_t sequence{0};
79
80 float x{0.F};
81 float y{0.F};
82 float yaw{0.F};
83
84 /// Measured platform velocity, base frame. The half of the overshoot question that no
85 /// existing sink reports.
86 float vMeasX{0.F};
87 float vMeasY{0.F};
88 float wMeas{0.F};
89
90 /// The twist the 100 Hz task published, before the slew. Zero while the watchdog holds.
91 float vTgtX{0.F};
92 float vTgtY{0.F};
93 float wTgt{0.F};
94
95 /// What was written to the control target, i.e. after `rtSlewTowards`.
96 float vCmdX{0.F};
97 float vCmdY{0.F};
98 float wCmd{0.F};
99
100 float dt{0.F};
101
102 /// The watchdog fired: the 100 Hz task stopped publishing and the command is ramping down.
103 bool stale{false};
104
105 /// A slew bound was binding this cycle, i.e. the command could not follow its target.
106 bool slewLimited{false};
107 };
108
109 /// One cycle of the 100 Hz control task, i.e. one `TrajectoryFollowingController::control`.
111 {
112 std::int64_t timestampUs{0};
113 std::uint64_t episode{0};
114 std::uint64_t sequence{0};
115
116 /// Which trajectory this cycle tracked. Indexes `trajectory.json`.
117 std::uint64_t trajectoryRevision{0};
118
119 float x{0.F};
120 float y{0.F};
121 float yaw{0.F};
122
123 /// The trajectory point the controller projected onto -- the reference it is tracking.
124 float refX{0.F};
125 float refY{0.F};
126 float refYaw{0.F};
127 float refVelocity{0.F};
128
129 std::uint32_t projectionIndex{0};
130 bool finalSegment{false};
131
132 /// Distance to the *last* trajectory point, which is what the controller calls
133 /// `positionError`. The cross-track error is `hypot(x - refX, y - refY)`.
134 float positionError{0.F};
138
139 float ffAngular{0.F};
141 bool ffSaturated{false};
142
143 /// `traj_ctrl::global::GuardVerdict` as an integer.
144 std::int32_t guard{0};
145
146 /// The controller's own output, base frame, before the `alpha` low-pass.
147 float vRawX{0.F};
148 float vRawY{0.F};
149 float wRaw{0.F};
150
151 /// After the low-pass, i.e. what was handed to the real-time thread.
152 float vFiltX{0.F};
153 float vFiltY{0.F};
154 float wFilt{0.F};
155
156 /// The limits in force for *this* cycle. Recorded per sample, not once in the metadata,
157 /// because the safety guard drives them through `updateVelocityLimits` /
158 /// `updateVelocityFactor` while the request runs -- so a snapshot taken at dump time
159 /// shows whatever they were restored to and an episode that was slowed down or stopped
160 /// by the guard is indistinguishable from one that was not.
161 float limitLinear{0.F};
162 float limitAngular{0.F};
163 float velocityFactor{1.F};
164 };
165
166 /**
167 * @brief Single-producer ring buffer whose storage is allocated exactly once.
168 *
169 * `record()` is the whole real-time surface: one assignment into pre-sized storage and one
170 * atomic increment. No allocation, no lock, no branch beyond the modulo.
171 *
172 * The ring is *not* reset between episodes. Resetting the counter would race a drain that is
173 * still running; instead every sample carries its episode and `snapshot()` filters, so a
174 * re-activation during a dump shows up as missing samples rather than as silently
175 * interleaved ones.
176 */
177 template <class Sample>
178 class Ring
179 {
180 public:
181 Ring() = default;
182
183 explicit Ring(const std::size_t capacity) : storage_(capacity)
184 {
185 }
186
187 /**
188 * @brief Allocate the storage. Call exactly once, before the producer starts.
189 *
190 * Separate from the constructor because the capacity comes from the controller's config,
191 * which is only available inside its constructor body -- and because `std::atomic` makes
192 * the ring non-assignable, so re-seating it after the fact is not an option.
193 */
194 void
195 init(const std::size_t capacity)
196 {
197 storage_.assign(capacity, Sample{});
198 written_.store(0, std::memory_order_release);
199 }
200
201 void
202 record(const Sample& sample) noexcept
203 {
204 if (storage_.empty())
205 {
206 return;
207 }
208
209 const std::uint64_t n = written_.load(std::memory_order_relaxed);
210 storage_[static_cast<std::size_t>(n % storage_.size())] = sample;
211
212 // Released after the store, so a reader that observes the count also observes the
213 // sample it refers to.
214 written_.store(n + 1, std::memory_order_release);
215 }
216
217 std::size_t
218 capacity() const noexcept
219 {
220 return storage_.size();
221 }
222
223 std::uint64_t
224 written() const noexcept
225 {
226 return written_.load(std::memory_order_acquire);
227 }
228
229 /// How many samples the ring has overwritten over its whole lifetime.
230 ///
231 /// Not the same as "this episode lost data": the ring is never reset, so this grows
232 /// across every episode the controller ever ran and says nothing about whether the one
233 /// being dumped fitted. Use `truncated()` for that.
234 std::uint64_t
235 overwritten() const noexcept
236 {
237 const std::uint64_t n = written();
238 return n > capacity() ? n - capacity() : 0;
239 }
240
241 /**
242 * @brief Whether samples of `episode` were overwritten before they could be read.
243 *
244 * True only when the ring has wrapped *and* the oldest sample still held belongs to this
245 * episode -- if an older episode is still in there, nothing of this one was lost. The
246 * lifetime `overwritten()` count answers a different question and reports a long-running
247 * controller as lossy even when every episode fitted comfortably.
248 */
249 bool
250 truncated(const std::uint64_t episode) const noexcept
251 {
252 const std::uint64_t n = written();
253
254 if (storage_.empty() or n <= storage_.size())
255 {
256 return false;
257 }
258
259 const std::uint64_t oldest = n - storage_.size();
260 return storage_[static_cast<std::size_t>(oldest % storage_.size())].episode == episode;
261 }
262
263 /**
264 * @brief Copy the retained samples of `episode` into `out`, oldest first. Non-real-time.
265 *
266 * Safe to call while the producer is running, as long as it cannot lap the reader: the
267 * entries between the write cursor and one capacity behind it are only rewritten once the
268 * producer has come all the way round. At the shipped sizes that is 60 s of recording
269 * against a copy measured in milliseconds. The episode filter is what keeps the result
270 * meaningful across that boundary -- samples the producer adds during the copy belong to
271 * the next episode and are excluded rather than mixed in.
272 *
273 * `out` is cleared first.
274 */
275 void
276 snapshot(const std::uint64_t episode, std::vector<Sample>& out) const
277 {
278 out.clear();
279
280 if (storage_.empty())
281 {
282 return;
283 }
284
285 const std::uint64_t n = written();
286 const std::uint64_t first = n > storage_.size() ? n - storage_.size() : 0;
287
288 out.reserve(static_cast<std::size_t>(n - first));
289
290 for (std::uint64_t i = first; i < n; i++)
291 {
292 const Sample& sample = storage_[static_cast<std::size_t>(i % storage_.size())];
293
294 if (sample.episode == episode)
295 {
296 out.push_back(sample);
297 }
298 }
299 }
300
301 private:
302 /// Sized in the constructor, never resized.
303 std::vector<Sample> storage_;
304
305 std::atomic<std::uint64_t> written_{0};
306 };
307
308 /// One trajectory the episode executed, with the revision the samples refer to.
314
315 /// Everything one dump is written from. Assembled off the real-time thread.
316 struct Episode
317 {
318 std::uint64_t episode{0};
319
321 std::string platform;
322
324
325 std::vector<RtSample> rtSamples;
326 std::vector<ControlSample> controlSamples;
327 std::vector<TrajectoryRevision> trajectories;
328
329 /// Whether this ran against a simulated RobotUnit. Several things a reader would
330 /// otherwise have to guess at follow from it: the real-time loop is an order of
331 /// magnitude slower, and the platform's velocity sensor is not populated faithfully.
332 bool simulation{false};
333
334 /// The `dt` bound the real-time slew was actually using [s]. Recorded rather than
335 /// assumed by the reader: it follows from `simulation`, and a reader that guessed would
336 /// misreport every dump taken on the other one.
337 float slewDtBound{0.F};
338
339 /// Whether the ring overwrote samples of *this* episode before the dump read them.
340 bool rtTruncated{false};
341 bool controlTruncated{false};
342
343 /// Lifetime overwrite counts, for context only. See `Ring::overwritten`.
344 std::uint64_t rtOverwritten{0};
345 std::uint64_t controlOverwritten{0};
347 std::uint64_t controlTaskExceptions{0};
348 std::uint32_t controlTargetStaleCycles{0};
349 };
350
351 /**
352 * @brief Write `episode` as `<outputDirectory>/<DateTime>-ep<NNN>/`.
353 *
354 * Contains `meta.json`, `trajectory.json`, `rt.csv` and `control.csv`. `trajectory.json`
355 * reuses the point schema of `server/ParametrizationDump.cpp`, so the same plotting code
356 * reads both.
357 *
358 * Never throws: a debugging aid must not be able to disturb the controller that produced it.
359 * Failures are logged and swallowed.
360 *
361 * @return The directory written, or an empty string on failure.
362 */
363 std::string writeDump(const Episode& episode, const Params& params);
364
365} // namespace armarx::navigation::platform_controller::diagnostics
std::uint64_t overwritten() const noexcept
How many samples the ring has overwritten over its whole lifetime.
void init(const std::size_t capacity)
Allocate the storage.
void snapshot(const std::uint64_t episode, std::vector< Sample > &out) const
Copy the retained samples of episode into out, oldest first.
bool truncated(const std::uint64_t episode) const noexcept
Whether samples of episode were overwritten before they could be read.
std::string writeDump(const Episode &episode, const Params &params)
Write episode as <outputDirectory>/<DateTime>-ep<NNN>/.
This file offers overloads of toIce() and fromIce() functions for STL container types.
One cycle of the 100 Hz control task, i.e. one TrajectoryFollowingController::control.
float refX
The trajectory point the controller projected onto – the reference it is tracking.
std::uint64_t trajectoryRevision
Which trajectory this cycle tracked. Indexes trajectory.json.
float vRawX
The controller's own output, base frame, before the alpha low-pass.
float vFiltX
After the low-pass, i.e. what was handed to the real-time thread.
std::int32_t guard
traj_ctrl::global::GuardVerdict as an integer.
float positionError
Distance to the last trajectory point, which is what the controller calls positionError.
Everything one dump is written from. Assembled off the real-time thread.
std::uint64_t rtOverwritten
Lifetime overwrite counts, for context only. See Ring::overwritten.
traj_ctrl::global::TrajectoryFollowingControllerParams params
float slewDtBound
The dt bound the real-time slew was actually using [s].
bool rtTruncated
Whether the ring overwrote samples of this episode before the dump read them.
bool simulation
Whether this ran against a simulated RobotUnit.
What the diagnostics mode records and where it writes it.
Definition Diagnostics.h:46
float controlDurationSeconds
How much history the 100 Hz ring keeps [s].
Definition Diagnostics.h:53
int maxTrajectoryRevisions
How many distinct trajectories one episode may record.
Definition Diagnostics.h:57
float rtDurationSeconds
How much history the 1 kHz ring keeps [s]. 60 s is ~5 MB.
Definition Diagnostics.h:50
int dumpRequest
Bumping this asks the controller to write the current episode and begin the next one.
Definition Diagnostics.h:63
float vCmdX
What was written to the control target, i.e. after rtSlewTowards.
Definition Diagnostics.h:96
std::uint64_t sequence
additionalTaskSequence as observed this cycle. Joins this row to control.csv.
Definition Diagnostics.h:78
float vMeasX
Measured platform velocity, base frame.
Definition Diagnostics.h:86
bool slewLimited
A slew bound was binding this cycle, i.e. the command could not follow its target.
bool stale
The watchdog fired: the 100 Hz task stopped publishing and the command is ramping down.
float vTgtX
The twist the 100 Hz task published, before the slew. Zero while the watchdog holds.
Definition Diagnostics.h:91
One trajectory the episode executed, with the revision the samples refer to.