SkillEventRenderer.cpp
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 * @package RobotAPI::ArmarXObjects::OpossumLogger
17 * @author Joana Plewnia ( joana dot plewnia at kit dot edu )
18 * @date 2026
19 * @copyright http://www.gnu.org/licenses/gpl-2.0.txt
20 * GNU General Public License
21 */
22
23#include "SkillEventRenderer.h"
24
25#include <algorithm>
26#include <cctype>
27#include <cstdio>
28#include <ctime>
29#include <map>
30
31#include <SimoxUtility/algorithm/string.h>
32
33namespace armarx::opossum
34{
35
36 namespace
37 {
38
39 /// The whitespace characters Python's `str.split()` splits on.
40 const std::string Whitespace = " \t\n\r\f\v";
41
42 bool
43 isBlank(const std::string& text)
44 {
45 return text.find_first_not_of(Whitespace) == std::string::npos;
46 }
47
48 /// `" a b\n c "` -> `"a b c"`, like `" ".join(text.split())`.
49 std::string
50 collapseWhitespace(const std::string& text)
51 {
52 return simox::alg::join(simox::alg::split(text, Whitespace, true, true), " ");
53 }
54
55 /// The part of `text` before the first occurrence of `separator`.
56 std::string
57 before(const std::string& text, const std::string& separator)
58 {
59 const std::size_t pos = text.find(separator);
60 return pos == std::string::npos ? text : text.substr(0, pos);
61 }
62
63 /// The part of `text` after the last occurrence of `separator`.
64 std::string
65 afterLast(const std::string& text, const std::string& separator)
66 {
67 const std::size_t pos = text.rfind(separator);
68 return pos == std::string::npos ? text : text.substr(pos + separator.size());
69 }
70
71 std::string
72 stringOr(const nlohmann::json& value, const std::string& fallback = "")
73 {
74 return value.is_string() ? value.get<std::string>() : fallback;
75 }
76
77 /// Datasets from open-vocabulary detectors: the className is already
78 /// natural language ("glass cup") and needs no lookup.
79 const std::set<std::string>&
80 openVocabularyDatasets()
81 {
82 static const std::set<std::string> datasets{"grounding_sam", "OpenVocabulary"};
83 return datasets;
84 }
85
86 /// How a parameter value is turned into text.
87 enum class Kind
88 {
89 /// Free text, gets quoted.
90 Quoted,
91 /// An ObjectID, or a plain object name.
92 Object,
93 /// A location string `dataset/class/spot`.
94 Location,
95 /// Any other string worth humanizing.
96 Humanize
97 };
98
99 struct Subject
100 {
101 const char* field;
102 std::vector<const char*> keys;
103 Kind kind;
104 };
105
106 /**
107 * Parameter keys carry consistent meaning across skills, so map
108 * key -> field generically rather than per skill. At most one value per
109 * field; the first matching key wins.
110 */
111 const std::vector<Subject>&
112 subjects()
113 {
114 // NB: `tableIdForGarmentPickup` is deliberately absent -- that is the
115 // surface a garment is picked from, not the object being acted on.
116 static const std::vector<Subject> subjects{
117 {"text", {"text"}, Kind::Quoted},
118 {"question", {"question"}, Kind::Quoted},
119 {"object",
120 {"graspObjectName",
121 "object",
122 "objectID",
123 "objectInstanceID",
124 "objectToGrasp",
125 "cartObjectId",
126 "cartObjectID",
127 "object1",
128 "instance_id"},
129 Kind::Object},
130 {"query",
131 {"objectName",
132 "objectText",
133 "basketDetectionQuery",
134 "familiarObjectDetectionQuery",
135 "objectClass",
136 "discovery.objectClass"},
137 Kind::Quoted},
138 {"location",
139 {"location",
140 "locationName",
141 "graspLocationName",
142 "handoverLocationName",
143 "placementLocationName",
144 "navigateToFridgeLocation",
145 "navigateToHandoverLocation",
146 "graspNextGarmentLocationName",
147 "placeGarmentInBasketLocationName",
148 "graspObjectAtLocation"},
149 Kind::Location},
150 {"place", {"commonPlace"}, Kind::Location},
151 {"part", {"objectToInteract", "specificNode", "objectFrame", "node"}, Kind::Humanize},
152 };
153 return subjects;
154 }
155
156 } // namespace
157
158 namespace detail
159 {
160
161 nlohmann::json
162 flat(const nlohmann::json& node)
163 {
164 if (node.is_array())
165 {
166 nlohmann::json out = nlohmann::json::array();
167 for (const auto& element : node)
168 {
169 out.push_back(flat(element));
170 }
171 return out;
172 }
173 if (not node.is_object())
174 {
175 return node;
176 }
177
178 if (const auto value = node.find("_ARON_VALUE"); value != node.end())
179 {
180 return *value;
181 }
182
183 // `_ARON_ELEMENTS` may hold either an object (dict) or an array (list).
184 if (const auto elements = node.find("_ARON_ELEMENTS"); elements != node.end())
185 {
186 if (elements->is_array())
187 {
188 nlohmann::json out = nlohmann::json::array();
189 for (const auto& element : *elements)
190 {
191 out.push_back(flat(element));
192 }
193 return out;
194 }
195 nlohmann::json out = nlohmann::json::object();
196 for (auto it = elements->begin(); it != elements->end(); ++it)
197 {
198 out[it.key()] = flat(it.value());
199 }
200 return out;
201 }
202
203 return nlohmann::json();
204 }
205
206 std::string
207 humanize(const std::string& text)
208 {
209 // Split camelCase, lowercasing the boundary (`laundryBasket` ->
210 // `laundry basket`) but keeping a leading capital (`Fridge_handle`
211 // -> `Fridge handle`).
212 std::string split;
213 split.reserve(text.size() + 8);
214 for (std::size_t i = 0; i < text.size(); ++i)
215 {
216 const unsigned char c = static_cast<unsigned char>(text[i]);
217 const unsigned char previous =
218 i > 0 ? static_cast<unsigned char>(text[i - 1]) : '\0';
219 if (std::isupper(c) and (std::islower(previous) or std::isdigit(previous)))
220 {
221 split += ' ';
222 split += static_cast<char>(std::tolower(c));
223 }
224 else
225 {
226 split += text[i];
227 }
228 }
229
230 // Replace runs of `-` and `_` by a single space.
231 std::string separated;
232 separated.reserve(split.size());
233 bool inSeparator = false;
234 for (const char c : split)
235 {
236 if (c == '-' or c == '_')
237 {
238 inSeparator = true;
239 continue;
240 }
241 if (inSeparator)
242 {
243 separated += ' ';
244 inSeparator = false;
245 }
246 separated += c;
247 }
248 if (inSeparator)
249 {
250 separated += ' ';
251 }
252
253 return simox::alg::trim_copy(separated);
254 }
255
256 bool
257 isObjectID(const nlohmann::json& value)
258 {
259 return value.is_object() and value.contains("className") and value.contains("dataset") and
260 value.contains("instanceName");
261 }
262
263 const nlohmann::json*
264 lookup(const nlohmann::json& parameters, const std::string& key)
265 {
266 if (not parameters.is_object())
267 {
268 return nullptr;
269 }
270 if (const std::size_t dot = key.find('.'); dot != std::string::npos)
271 {
272 const auto outer = parameters.find(key.substr(0, dot));
273 return outer == parameters.end() ? nullptr : lookup(*outer, key.substr(dot + 1));
274 }
275 const auto value = parameters.find(key);
276 return value == parameters.end() ? nullptr : &(*value);
277 }
278
279 std::string
280 errorText(const std::string& message)
281 {
282 if (message.empty())
283 {
284 return "";
285 }
286
287 static const std::string reasonMarker = "Reason:";
288 std::string text;
289 if (const std::size_t reasonStart = message.find(reasonMarker);
290 reasonStart != std::string::npos)
291 {
292 // The `Reason:` line is prefixed with a full C++ signature, and is
293 // followed by a backtrace we do not want.
294 const std::string reason = simox::alg::trim_copy(
295 before(message.substr(reasonStart + reasonMarker.size()), "\nBacktrace"));
296 text = collapseWhitespace(afterLast(reason, ": "));
297 }
298 else
299 {
300 // The first line is often just "Caught armarx::LocalException:", and
301 // 501 messages trail off into an Ice dump after "The error was:".
302 text = collapseWhitespace(before(before(message, "The error was:"), "\n"));
303 }
304
305 if (text.size() > 100)
306 {
307 const std::string head = text.substr(0, 100);
308 const std::size_t space = head.rfind(' ');
309 text = (space == std::string::npos ? head : head.substr(0, space)) + "...";
310 }
311 return text;
312 }
313
314 std::string
315 quoted(const std::string& text)
316 {
317 std::string escaped;
318 escaped.reserve(text.size() + 2);
319 for (const char c : text)
320 {
321 escaped += (c == '"') ? '\'' : (c == '\n' ? ' ' : c);
322 }
323 return "\"" + escaped + "\"";
324 }
325
326 std::optional<std::string>
327 stateOf(const std::string& status)
328 {
329 static const std::map<std::string, std::string> states{{"Running", "started"},
330 {"Succeeded", "done"},
331 {"Failed", "failed"},
332 {"Aborted", "aborted"}};
333 const auto state = states.find(status);
334 return state == states.end() ? std::nullopt : std::optional{state->second};
335 }
336
337 std::string
338 calledBy(const std::string& executorName)
339 {
340 return afterLast(afterLast(executorName, "->"), "/");
341 }
342
343 std::string
345 {
346 // Local time with truncated (not rounded) milliseconds, matching
347 // Python's `datetime.fromtimestamp(...).isoformat(timespec="milliseconds")`.
348 const std::int64_t microSeconds = time.toMicroSecondsSinceEpoch();
349 const std::time_t seconds = static_cast<std::time_t>(microSeconds / 1'000'000);
350 const int milliSeconds = static_cast<int>((microSeconds % 1'000'000) / 1'000);
351
352 std::tm local{};
353 localtime_r(&seconds, &local);
354
355 char buffer[32];
356 if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &local) == 0)
357 {
358 return "";
359 }
360
361 char result[40];
362 std::snprintf(result, sizeof(result), "%s.%03d", buffer, milliSeconds);
363 return result;
364 }
365
366 } // namespace detail
367
371
372 SkillEventRenderer::SkillEventRenderer(std::set<std::string> noiseSkills,
373 SpokenNameLookup spokenNameLookup) :
374 noiseSkills(std::move(noiseSkills)), spokenNameLookup(std::move(spokenNameLookup))
375 {
376 }
377
378 const std::set<std::string>&
380 {
381 // Pure body/gaze control, plus primitives that only implement a skill
382 // already logged above them (LookAt under LookAtObject, CommonActions
383 // under GraspCup). These would swamp the summary.
384 static const std::set<std::string> noise{
385 "ShapeHand",
386 "CloseHand",
387 "OpenHand",
388 "MoveJointsToPosition",
389 "MoveJointsToNamedConfiguration",
390 "MoveJointsWithVelocity",
391 "SetKneeHipPosition",
392 "HomePose",
393 "SafeHomePose",
394 "MoveArmsToDefaultHighPosition",
395 "MoveArmsAboveBelowTable",
396 "SwitchCoordinationMode",
397 "ZeroTorque",
398 "ResetGazeTargets",
399 "SetCustomGazeTarget",
400 "LookAhead",
401 "LookAlongTrajectory",
402 "LookAt",
403 "LookDown",
404 "LookDownstraight",
405 "LookLeft",
406 "LookRight",
407 "CommonActions",
408 "Placing::AnyObject::AtPose",
409 "grasping_failure_execute_encoder_calibration",
410 };
411 return noise;
412 }
413
414 std::string
416 {
417 const std::set<std::string>& noise = DefaultNoiseSkills();
418 return simox::alg::join(std::vector<std::string>(noise.begin(), noise.end()), ",");
419 }
420
421 std::set<std::string>
422 SkillEventRenderer::ParseSkillList(const std::string& commaSeparated)
423 {
424 const std::vector<std::string> names = simox::alg::split(commaSeparated, ",", true, true);
425 return std::set<std::string>(names.begin(), names.end());
426 }
427
428 std::string
429 SkillEventRenderer::objectName(const nlohmann::json& objectID) const
430 {
431 if (not detail::isObjectID(objectID))
432 {
433 return "";
434 }
435 const std::string className = stringOr(objectID.at("className"));
436 if (className.empty())
437 {
438 return "";
439 }
440
441 const std::string dataset = stringOr(objectID.at("dataset"));
442 if (openVocabularyDatasets().count(dataset) > 0)
443 {
444 return className;
445 }
446
447 std::optional<std::string> spokenName;
448 if (spokenNameLookup)
449 {
450 spokenName = spokenNameLookup(dataset, className);
451 }
452 // Humanize the spoken name too: some newer entries store the raw
453 // camelCase className as the spoken name (`IKEA/laundryBasket`).
454 return detail::humanize(spokenName.value_or(className));
455 }
456
457 std::string
458 SkillEventRenderer::locationName(const std::string& value) const
459 {
460 const std::string text = before(value, ":");
461 const std::vector<std::string> parts = simox::alg::split(text, "/", false, false);
462 if (parts.size() < 3)
463 {
464 return detail::humanize(text);
465 }
466
467 const std::string spot =
468 simox::alg::join(std::vector<std::string>(parts.begin() + 2, parts.end()), "/");
469 const nlohmann::json objectID = {
470 {"dataset", parts[0]}, {"className", parts[1]}, {"instanceName", ""}};
471 return detail::humanize(spot) + " of " + objectName(objectID);
472 }
473
474 std::vector<std::string>
475 SkillEventRenderer::subjectFields(const nlohmann::json& parameters) const
476 {
477 std::vector<std::string> fields;
478 for (const Subject& subject : subjects())
479 {
480 for (const char* key : subject.keys)
481 {
482 const nlohmann::json* value = detail::lookup(parameters, key);
483 if (value == nullptr)
484 {
485 continue;
486 }
487
488 const std::string text = stringOr(*value);
489 std::string rendered;
490 switch (subject.kind)
491 {
492 case Kind::Quoted:
493 if (value->is_string() and not isBlank(text))
494 {
495 rendered = detail::quoted(text);
496 }
497 break;
498 case Kind::Object:
499 if (detail::isObjectID(*value))
500 {
501 rendered = objectName(*value);
502 }
503 else if (value->is_string() and not isBlank(text))
504 {
505 // Some skills name the object directly ("apple juice").
506 rendered = detail::humanize(text);
507 }
508 break;
509 case Kind::Location:
510 if (value->is_string() and not isBlank(text))
511 {
512 rendered = locationName(text);
513 }
514 break;
515 case Kind::Humanize:
516 if (value->is_string() and not isBlank(text))
517 {
518 rendered = detail::humanize(text);
519 }
520 break;
521 }
522
523 if (not rendered.empty())
524 {
525 fields.push_back(std::string(subject.field) + ":" + rendered);
526 break;
527 }
528 }
529 }
530 return fields;
531 }
532
533 bool
534 SkillEventRenderer::isNoiseSkill(const std::string& skillName) const
535 {
536 return noiseSkills.count(skillName) > 0;
537 }
538
539 std::optional<std::string>
541 {
542 const std::optional<std::string> state = detail::stateOf(event.status);
543 if (not state.has_value() or isNoiseSkill(event.skillName))
544 {
545 return std::nullopt;
546 }
547
548 const nlohmann::json parameters = detail::flat(event.parameters);
549 const nlohmann::json result = detail::flat(event.result);
550
551 std::vector<std::string> fields{"skill:" + event.skillName};
552 for (std::string& field : subjectFields(parameters))
553 {
554 fields.push_back(std::move(field));
555 }
556 // executorName is the call stack; its last segment is the caller.
557 fields.push_back("called_by:" + detail::calledBy(event.executorName));
558 fields.push_back("state:" + *state);
559 fields.push_back("time_stamp:" + detail::timestamp(event.timestamp));
560
561 if (*state == "failed" or *state == "aborted")
562 {
563 const nlohmann::json* errorMessage =
564 result.is_object() ? detail::lookup(result, "errorMessage") : nullptr;
565 const std::string reason =
566 detail::errorText(errorMessage == nullptr ? "" : stringOr(*errorMessage));
567 if (not reason.empty())
568 {
569 fields.push_back("error:" + reason);
570 }
571 }
572
573 return simox::alg::join(fields, ", ");
574 }
575
576} // namespace armarx::opossum
std::string timestamp()
std::string space
constexpr T c
Represents a point in time.
Definition DateTime.h:25
std::int64_t toMicroSecondsSinceEpoch() const
Definition DateTime.cpp:87
static std::string DefaultNoiseSkillsString()
DefaultNoiseSkills() as a comma-separated string (for property defaults).
bool isNoiseSkill(const std::string &skillName) const
Whether render() would drop this skill as noise.
static const std::set< std::string > & DefaultNoiseSkills()
Pure body/gaze control and primitives that only implement a skill already logged above them.
static std::set< std::string > ParseSkillList(const std::string &commaSeparated)
Split a comma-separated list into a set, trimming whitespace.
std::optional< std::string > render(const SkillEvent &event) const
Render one event.
std::string timestamp(const armarx::core::time::DateTime &time)
Local time as 2026-07-08T16:45:40.341 (milliseconds, truncated).
std::string errorText(const std::string &message)
Pull the human-readable reason out of an ArmarX error message.
nlohmann::json flat(const nlohmann::json &node)
Decode a self-describing Aron JSON tree into plain JSON.
const nlohmann::json * lookup(const nlohmann::json &parameters, const std::string &key)
Fetch a parameter by name, supporting outer.inner nesting.
std::string quoted(const std::string &text)
Free text needs quoting: it may contain commas, which separate fields.
std::string calledBy(const std::string &executorName)
The last segment of the executor call stack, i.e. the calling skill.
bool isObjectID(const nlohmann::json &value)
Whether value is an ObjectID (has className, dataset and instanceName).
std::optional< std::string > stateOf(const std::string &status)
Map a SkillStatus name to the reported state, if it is reportable.
std::string humanize(const std::string &text)
serving-cart-at-lab -> serving cart at lab; laundryBasket -> laundry basket.
std::function< std::optional< std::string >( const std::string &dataset, const std::string &className)> SpokenNameLookup
Resolves an object class to its natural-language ("spoken") name.
std::vector< std::string > split(const std::string &source, const std::string &splitBy, bool trimElements=false, bool removeEmptyElements=false)
std::shared_ptr< Value > value()
Definition cxxopts.hpp:855
double dot(const Point &x, const Point &y)
Definition point.hpp:57
One skill lifecycle event, as stored in the Skill memory's SkillEvent core segment.
std::string status
One of the armarx::skills::SkillStatus names, e.g. "Running", "Succeeded".
armarx::core::time::DateTime timestamp
std::string executorName
The call stack of the execution, e.g. "ServeDrinks->ServeDrinks::BringObjectFromCart".