Fixposition SDK 0.0.0-heads/main-0-g90a51ff
Collection of c++ libraries and apps for use with Fixposition products
Loading...
Searching...
No Matches
Parsing and decoding FP_A messages
1/**
2 * \verbatim
3 * ___ ___
4 * \ \ / /
5 * \ \/ / Copyright (c) Fixposition AG (www.fixposition.com) and contributors
6 * / /\ \ License: see the LICENSE file
7 * /__/ \__\
8 * \endverbatim
9 *
10 * @file
11 * @brief Fixposition SDK examples: introduction to the parser and FP_A message decoding
12 *
13 * To build and run:
14 *
15 * make
16 * ./build/parser_intro
17 *
18 * This program demonstrates how to use the fpsdk::common::parser functions to parse FP_A protocol
19 * messages from data received from the Vision-RTK 2 sensor as well as how to decode those messages
20 * for futher processing.
21 *
22 * This file is both the source code of this example as well as the documentation on how it works.
23 */
24
25/* LIBC/STL */
26#include <cinttypes>
27#include <cstdint>
28#include <cstdlib>
29
30/* EXTERNAL */
31
32/* Fixposition SDK */
33#include <fpsdk_common/app.hpp>
35#include <fpsdk_common/math.hpp>
38#include <fpsdk_common/time.hpp>
40
41/* PACKAGE */
42
43/* ****************************************************************************************************************** */
44
45using namespace fpsdk::common::time;
46using namespace fpsdk::common::app;
47using namespace fpsdk::common::parser;
48using namespace fpsdk::common::parser::fpa;
49using namespace fpsdk::common::logging;
50using namespace fpsdk::common::trafo;
51using namespace fpsdk::common::math;
52
53// ---------------------------------------------------------------------------------------------------------------------
54
55// This is an example of data received from the Vision-RTK 2 sensor. Note that the data is a long sequence of bytes.
56// Even though it looks like strings, do not treat data received from the sensor as strings. Always treat it as a
57// sequence of bytes that may have any value, including the string nul terminator (0x00) character. For readability
58// the data here is formatted as one FP_A message per line. Do not assume this is how a program would receive the data
59// from the sensor in a real application. Depending on the connection and implementation the app receives the data
60// in chunks, perhaps as small as one byte at a time. Or there may be errors (some detailed below) in transmissions.
61// To extract complete message frames from such a stream of data we can use the *parser*.
62// clang-format off
63const uint8_t SAMPLE_DATA_1[] = {
64 // The data starts with what looks like an incomplete FP_A-ODOMETRY...
65 /* (1) */ "2650,0.00283,-0.00305,-0.02449,0.00108,0.00054,0.00057,-0.00009,-0.00002,0.00024,"
66 "fp_vrtk2-integ_6912e460-1703*3C\r\n"
67 // Then it seems we're receiving correct FP_A messages as expected...
68 /* (2) */ "$FP,ODOMSTATUS,1,2348,574451.500000,2,2,1,1,1,1,,0,,,,,,0,1,3,8,8,3,5,5,,0,6,,,,,,,,,,,,*2D\r\n"
69 /* (3) */ "$FP,EOE,1,2348,574451.500000,FUSION*6C\r\n"
70 // ..and more messages...
71 /* (4) */ "$FP,ODOMETRY,2,2348,574452.000000,4278387.7000,635620.5134,4672339.9355,-0.603913,0.313038,-0.179283,"
72 "0.710741,0.0003,-0.0009,0.0012,-0.00110,0.00128,0.00026,0.2101,0.1256,9.8099,4,0,8,8,-1,0.00073,"
73 "0.00294,0.00107,0.00141,-0.00170,-0.00083,0.02271,0.00042,0.02650,0.00283,-0.00305,-0.02448,0.00101,"
74 "0.00053,0.00056,-0.00008,-0.00002,0.00021,fp_vrtk2-integ_6912e460-1703*17\r\n"
75 /* (5) */ "$FP,ODOMSTATUS,1,2348,574452.000000,2,2,1,1,1,1,,0,,,,,,0,1,3,8,8,3,5,5,,0,6,,,,,,,,,,,,*2B\r\n"
76 /* (6) */ "$FP,EOE,1,2348,574452.000000,FUSION*6A\r\n"
77 // ..and more messages...
78 /* (7) */ "$FP,ODOMETRY,2,2348,574452.500000,4278387.6984,635620.5126,4672339.9366,0.313004,-0.179369,0.710598,"
79 "0.0002,-0.0008,0.0009,0.00060,-0.00117,0.00038,0.2129,0.1266,9.8185,4,0,8,8,-1,0.00073,0.00294,0.00107,"
80 "0.00141,-0.00169,-0.00083,0.02270,0.00042,0.02650,0.00283,-0.00305,-0.02448,0.00098,0.00053,0.00056,"
81 "-0.00008,-0.00002,0.00019,fp_vrtk2-integ_6912e460-1703*01\r\n"
82 /* (8) */ "$FP,ODOMSTATUS,1,2348,574452.500000,2,2,1,1,1,1,,0,,,,,,0,1,3,8,8,3,5,5,,0,6,,,,,,,,,,,,*2E\r\n"
83 /* (9) */ "$FP,EOE,1,2348,574452.500000,FUSION*6F\r\n"
84 // Hmmm... there seems to be some kine of errors in the reception of the data. Perhaps a bad cable or a shaky connector?
85 /* (10) */ "$FP,ODOM3TRY,2,2348,574453.000000,4278387.6988,6\xaa\x55\xaa\x55.604077,0.312982\xba\xad\xc0\xff\xee.71"
86 "0587,0.0010,-0.0005,0.0018,0.00368,-0.00044,0.00092,0.2163,0.1214,9.8162,4,0,8,8,-1,0.00073,0.00294,"
87 "0.00107,0.00141,-0.00170,-0.00083,0.02269,0.00042,0.02650,0.00283,-0.00305,-0.02448,0.00100,0.00053,"
88 "0.00056,-0.00008,-0.00002,0.00021,fp_vrtk2-integ_6912e460-1703*12\r\n"
89 // Ok, back to normal it seems..
90 /* (11) */ "$FP,ODOMSTATUS,1,2348,574453.000000,2,2,1,1,1,1,,0,,,,,,0,1,3,8,8,3,5,5,,0,6,,,,,,,,,,,,*2A\r\n"
91 /* (12) */ "$FP,EOE,1,2348,574453.000000,FUSION*6B\r\n"
92 // The data ends in an incomplete FP_A-ODOMETRY message...
93 /* (13) */ "$FP,ODOMETRY,2,2348,579519.500000,4278387.7342,635620.5918,4672339.8925,-0.700505,0.286"
94};
95const std::size_t SAMPLE_SIZE_1 = sizeof(SAMPLE_DATA_1) - 1; // -1 because "" adds \0
96// A second chunk of data, which is the continuation from the above
97const uint8_t SAMPLE_DATA_2[] = {
98 /* (13) */"891,-0.222201,0.614502,0.0002,0.0000,-0.0002,-0.00071,-0.00146,-0.00001,0.2974,0.0215,9.8060,4,0,8,8,-1,"
99 "0.00025,0.00493,0.00060,0.00099,-0.00161,-0.00033,0.02743,0.00044,0.03268,0.00316,-0.00342,-0.02989,"
100 "0.00094,0.00066,0.00049,-0.00023,-0.00003,0.00007,fp_vrtk2-integ_6912e460-1703*18\r\n"
101 /* (14) */"$FP,ODOMSTATUS,1,2348,579519.500000,2,2,1,1,1,1,,0,,,,,,0,1,3,8,8,3,5,5,,0,6,,,,,,,,,,,,*2D\r\n"
102 /* (15) */"$FP,EOE,1,2348,579519.500000,FUSION*6C\r\n"
103 /* (16) */"$FP,ODOMETRY,2,2348,579520.000000,,,,-0.700563,0.286828,-0.222129,0.614491,0.0013,-0.0012,0.0004,"
104 "-0.00513,-0.00055,-0.00092,0.2984,0.0225,9.8047,4,0,8,8,-1,0.00025,0.00493,0.00060,0.00099,-0.00161,"
105 "-0.00033,0.02743,0.00044,0.03268,0.00316,-0.00342,-0.02989,0.00094,0.00066,0.00048,-0.00024,-0.00003,"
106 "0.00007,fp_vrtk2-integ_6912e460-1703*35\r\n"
107 /* (17) */"$FP,ODOMSTATUS,1,2348,579520.00000"
108};
109const std::size_t SAMPLE_SIZE_2 = sizeof(SAMPLE_DATA_2) - 1; // -1 because "" adds \0
110// clang-format on
111
112int main(int /*argc*/, char** /*argv*/)
113{
114#ifndef NDEBUG
116#endif
117 LoggingSetParams({ LoggingLevel::TRACE });
118
119 // -----------------------------------------------------------------------------------------------------------------
120 // Experiment 1
121 // -----------------------------------------------------------------------------------------------------------------
122 NOTICE("----- Parsing the data into messages -----");
123
124 // The first step is to *parse* the data. This process splits the data into message frames. Note that "parsing" is
125 // not the same as "decoding" the messages. "Parsing" (as per Fixposition SDK definition) refers to splitting a
126 // stream of data into individual messages (als known as "frames"). The Parser recgonises a variety of protocols and
127 // it can reliably split data into messages of all the protocols it understands. It can do so in streams of mixed
128 // data, such as FP_A, NMEA and NOV_B messages in the same stream.
129 //
130
131 // Create a parser instance. Note that one parser should be used per stream of data. If multiple streams (inputs)
132 // are handled, they should all be processed by a separate Parser instance.
133 Parser parser;
134
135 // Feed data to the parser. We'll have to check that we don't overwhelm the parser by feeding too much data.
136 // The details for this are explained in the fpsdk::common::parser documentation in fpsdk_common/parser.hpp.
137 INFO("Adding SAMPLE_DATA_1 (%" PRIuMAX " bytes)", SAMPLE_SIZE_1);
138 if (!parser.Add(SAMPLE_DATA_1, SAMPLE_SIZE_1)) {
139 WARNING("Parser overflow, SAMPLE_DATA_1 is too large!");
140 }
141
142 // Now we can process the data in the parser and we get back the data we fed in above message by message. On
143 // success, the ParserMsg object is populated with the part of the input data that corresponds to a message along
144 // with some meta data, such as a message name. The naming of the messages is explained in the fpsdk::common::parser
145 // documentation.
146 ParserMsg msg;
147 while (parser.Process(msg)) {
148 INFO("Message %-20s (size %" PRIuMAX " bytes)", msg.name_.c_str(), msg.data_.size());
149 // DEBUG_HEXDUMP(msg.data_.data(), msg.data_.size(), NULL, NULL); // Hexdump of the raw message data
150 }
151 // This should print the following output:
152 //
153 // Adding SAMPLE_DATA_1 (1810 bytes)
154 // Message OTHER (size 114 bytes) (1)
155 // Message FP_A-ODOMSTATUS (size 93 bytes) (2)
156 // Message FP_A-EOE (size 40 bytes) (3)
157 // Message FP_A-ODOMETRY (size 372 bytes) (4)
158 // Message FP_A-ODOMSTATUS (size 93 bytes) (5)
159 // Message FP_A-EOE (size 40 bytes) (6)
160 // Message FP_A-ODOMETRY (size 362 bytes) (7)
161 // Message FP_A-ODOMSTATUS (size 93 bytes) (8)
162 // Message FP_A-EOE (size 40 bytes) (9)
163 // Message OTHER (size 256 bytes) (10)
164 // Message OTHER (size 87 bytes) (10)
165 // Message FP_A-ODOMSTATUS (size 93 bytes) (11)
166 // Message FP_A-EOE (size 40 bytes) (12)
167 //
168 // We can observe the following parser behaviour:
169 //
170 // - A total of 1723 bytes of the 1810 input bytes are output in 13 messages (1) - (13)
171 // - Message (1) is of the "fake" type Protocol::OTHER, which means it is data that doesn't match any of the known
172 // protocols. Even though it is a partial FP_A message, the parser cannot recognise this as the frame is
173 // incomplete and therefore does not pass the "looks like an FP_A message" check.
174 // - Similarly, the corrupt message (13) is output as OTHER messages. Since a OTHER message is limited to 256 bytes,
175 // we see two such messages being emitted from the parser.
176 // - Message (14) is missing. This is expected as it is the beginning of a FP_A message and the parser cannot yet
177 // determine that is _not_ such a message. For this decision it would need more data.
178 // - That is, 87 byte (1810 bytes input minus 1723 bytes output) are "stuck" in the parser.
179
180 // Let's add more data, namely the rest of the partial FP_A-ODOMETRY now "stuck" in the parser, and process more
181 INFO("Adding SAMPLE_DATA_2 (%" PRIuMAX " bytes)", SAMPLE_SIZE_2);
182 if (!parser.Add(SAMPLE_DATA_2, SAMPLE_SIZE_2)) {
183 WARNING("Parser overflow, SAMPLE_DATA_2 is too large!");
184 }
185 while (parser.Process(msg)) {
186 INFO("Message %-20s (size %" PRIuMAX " bytes)", msg.name_.c_str(), msg.data_.size());
187 // DEBUG_HEXDUMP(msg.data_.data(), msg.data_.size(), NULL, NULL); // Hexdump of the raw message data
188 }
189 // This should print the following output:
190 //
191 // Adding SAMPLE_DATA_2 (793 bytes)
192 // Message FP_A-ODOMETRY (size 374 bytes) (13)
193 // Message FP_A-ODOMSTATUS (size 93 bytes) (14)
194 // Message FP_A-EOE (size 40 bytes) (15)
195 // Message FP_A-ODOMETRY (size 339 bytes) (16)
196 //
197 // We can observe the following parser behaviour:
198 //
199 // - A total of 846 byte are output in messages. Note that at the time we added the second chunk of data (793 bytes)
200 // there were still 87 bytes from the first chunk left. So in total the parser had 880 bytes of data.
201 // - The parser now had enough data to output message (13)
202 // - Also there was data for messages (14) - (16)
203 // - There must be 34 (880 - 846) bytes "stuck" in the parser now
204
205 // The parser has a "flush" mode that forces "stuck" bytes to be output even though they look like the beginning of
206 // a valid message:
207 INFO("Flushing parser");
208 while (parser.Flush(msg)) {
209 INFO("Message %-20s (size %" PRIuMAX " bytes)", msg.name_.c_str(), msg.data_.size());
210 DEBUG_HEXDUMP(msg.data_.data(), msg.data_.size(), NULL, NULL);
211 }
212 // This should print:
213 //
214 // Message OTHER (size 34 bytes) (17)
215 // 0x0000 00000 24 46 50 2c 4f 44 4f 4d 53 54 41 54 55 53 2c 31 |$FP,ODOMSTATUS,1|
216 // 0x0010 00016 2c 32 33 34 38 2c 35 37 39 35 32 30 2e 30 30 30 |,2348,579520.000|
217 // 0x0020 00032 30 30 |00 |
218 //
219 // And indeed it returned the 34 bytes that were stuck in the parser and indeed they are the incomplete
220 // FP_A-ODOMSTATUS message at the end of the second junk.
221
222 // -----------------------------------------------------------------------------------------------------------------
223 // Experiment 2
224 // -----------------------------------------------------------------------------------------------------------------
225 NOTICE("----- Decoding the data in the messages -----");
226
227 // We have now seen how to *parse* the messages. Let's have a closer look at how to *decode* the payload (contents)
228 // of some of the messages.
229
230 // Reset the parser to assert it's empty and back to the initial state. Then, add all data we have.
231 INFO("Adding SAMPLE_DATA_1+SAMPLE_DATA_2 (%" PRIuMAX " bytes)", SAMPLE_SIZE_1 + SAMPLE_SIZE_2);
232 parser.Reset();
233 if (!parser.Add(SAMPLE_DATA_1, SAMPLE_SIZE_1) || !parser.Add(SAMPLE_DATA_2, SAMPLE_SIZE_2)) {
234 WARNING("Parser overflow, SAMPLE_DATA_1+SAMPLE_DATA_2 is too large!");
235 }
236
237 // Loop though all messages and have a closer look at FP_A-ODOMETRY
238 while (parser.Process(msg)) {
239 // Is it a FP_A-ODOMETRY?
240 if (msg.name_ == FpaOdometryPayload::MSG_NAME) {
241 INFO("Message %-20s (size %" PRIuMAX " bytes)", msg.name_.c_str(), msg.data_.size());
242 // We can now try to decode it
243 FpaOdometryPayload payload;
244 if (payload.SetFromMsg(msg.data_.data(), msg.data_.size())) {
245 INFO("Decode OK");
246 }
247 // Decoding failed
248 else {
249 INFO("Decode fail");
250 }
251 }
252 }
253 // We should get:
254 //
255 // Adding SAMPLE_DATA_1+SAMPLE_DATA_2 (2603 bytes)
256 // Message FP_A-ODOMETRY (size 372 bytes) (4)
257 // Message OK
258 // Message FP_A-ODOMETRY (size 362 bytes) (7)
259 // Message decode fail <---- !!!
260 // Message FP_A-ODOMETRY (size 374 bytes) (13)
261 // Message OK
262 // Message FP_A-ODOMETRY (size 339 bytes) (16)
263 // Message OK
264 //
265 // We again see all four FP_A-ODOMETRY messges. However, the second messge (7) failed to decode. If we have a close
266 // look at SAMPLE_DATA_1 we can see that even though it is a valid FP_A message frame (the checksum is correct) and
267 // the message type ("ODOMETRY") is present, the number of fields does not match the specification for FP_A-ODOMETRY
268 // and therefore FpaOdometryPayload::SetFromMsg() complains. This method does various checks to make sure the data
269 // it decodes matches the corresponding message specification.
270
271 // -----------------------------------------------------------------------------------------------------------------
272 // Experiment 3
273 // -----------------------------------------------------------------------------------------------------------------
274 NOTICE("----- Using decoded data in the messages -----");
275
276 // We repeat the parsing and decoding and now try to *use* some of the data.
277
278 // Reset the parser to assert it's empty and back to the initial state. Then, add all data we have.
279 INFO("Adding SAMPLE_DATA_1+SAMPLE_DATA_2 (%" PRIuMAX " bytes)", SAMPLE_SIZE_1 + SAMPLE_SIZE_2);
280 parser.Reset();
281 if (!parser.Add(SAMPLE_DATA_1, SAMPLE_SIZE_1) || !parser.Add(SAMPLE_DATA_2, SAMPLE_SIZE_2)) {
282 WARNING("Parser overflow, SAMPLE_DATA_1+SAMPLE_DATA_2 is too large!");
283 }
284
285 // Loop though all messages and have a closer look at some of the data in the valid FP_A-ODOMETRY messages
286 while (parser.Process(msg)) {
287 // Ignore messages other than FP_A-ODOMETRY
288 if (msg.name_ != FpaOdometryPayload::MSG_NAME) {
289 continue;
290 }
291 // Ignore invalid FP_A-ODOMETRY messages
292 FpaOdometryPayload payload;
293 if (!payload.SetFromMsg(msg.data_.data(), msg.data_.size())) {
294 continue;
295 }
296
297 INFO("Message %-20s (size %" PRIuMAX " bytes)", msg.name_.c_str(), msg.data_.size());
298
299 // Some the message fields are optional and in principle any field can be a "null" (empty) field. It is highly
300 // recommended to *always* check if the desired fields are valid.
301
302 // For example we can check if the time is available. It it is, we can convert the GPS time to UTC for the
303 // debugging purposes here. (Note that you should probably not use UTC time for anything other than to display
304 // time to humans.)
305
306 // GPS week number and time of week can be indpendently valid or invalid
307 if (payload.gps_time.week.valid && payload.gps_time.tow.valid) {
308 Time time;
309 // We should also handle the week number and/or time of week values not being in range
310 if (time.SetWnoTow({ payload.gps_time.week.value, payload.gps_time.tow.value, WnoTow::Sys::GPS })) {
311 INFO("GPS time %04d:%010.3f (%s)", payload.gps_time.week.value, payload.gps_time.tow.value,
312 time.StrUtcTime().c_str());
313 } else {
314 INFO("GPS time %04d:%010.3f is bad", payload.gps_time.week.value, payload.gps_time.tow.value);
315 }
316 } else {
317 INFO("GPS time not available");
318 }
319
320 // The position data can should be checked for availability, too
321 if (payload.pos.valid) {
322 INFO("Position: [ %.1f, %.1f, %.1f ]", payload.pos.values[0], payload.pos.values[1],
323 payload.pos.values[2]);
324 } else {
325 INFO("Position not available");
326 }
327
328 // We can transform the position to latititude, longitude and height (and lat/lon to degrees). Note that
329 // TfWgs84LlhEcef() should not be used for anything other than debugging. Instead, an appropriately configured
330 // fpsdk::common::trafo::Transformer instance should be used to transform from the input coordinate reference
331 // system (determined by the correction data service used with the sensor) to the desired output coordinate
332 // reference system (which may or may not be WGS-84). See the fusion_epoch example.
333 if (payload.pos.valid) {
334 const auto llh = TfWgs84LlhEcef({ payload.pos.values[0], payload.pos.values[1], payload.pos.values[2] });
335 INFO("LLH: %.6f %.6f %.1f", RadToDeg(llh(0)), RadToDeg(llh(1)), llh(2));
336 }
337 }
338 // This should output:
339 // Adding SAMPLE_DATA_1+SAMPLE_DATA_2 (2603 bytes)
340 // Message FP_A-ODOMETRY (size 372 bytes) (4)
341 // GPS time 2348:574452.000 (2025-01-11 15:33:54.000)
342 // Position: [ 4278387.7, 635620.5, 4672339.9 ]
343 // LLH: 47.400296 8.450363 459.5
344 // Message FP_A-ODOMETRY (size 374 bytes) (13)
345 // GPS time 2348:579519.500 (2025-01-11 16:58:21.500)
346 // Position: [ 4278387.7, 635620.6, 4672339.9 ]
347 // LLH: 47.400296 8.450364 459.5
348 // Message FP_A-ODOMETRY (size 339 bytes) (16)
349 // GPS time 2348:579520.000: 2025-01-11 16:58:22.000
350 // Position not available
351
352 return EXIT_SUCCESS;
353}
354
355/* ****************************************************************************************************************** */
Fixposition SDK: Utilities for apps.
Helper to print a strack trace on SIGSEGV and SIGABRT.
Definition app.hpp:139
Message parser class.
Definition parser.hpp:165
bool Flush(ParserMsg &msg)
Get remaining data from parser as OTHER message(s)
void Reset()
Reset parser.
bool Process(ParserMsg &msg)
Process data in parser, return message.
bool Add(const uint8_t *data, const std::size_t size)
Add data to parser.
std::string StrUtcTime(const int prec=3) const
Stringify as year, month, day, hour, minute and second time (UTC)
bool SetWnoTow(const WnoTow &wnotow)
Set time from GNSS (GPS, Galileo, BeiDou) time (atomic)
Fixposition SDK: Parser FP_A routines and types.
Fixposition SDK: Logging.
#define NOTICE(fmt,...)
Print a notice message.
Definition logging.hpp:64
#define DEBUG_HEXDUMP(data, size, prefix, fmt,...)
Print a debug hexdump.
Definition logging.hpp:76
#define INFO(fmt,...)
Print a info message.
Definition logging.hpp:68
#define WARNING(fmt,...)
Print a warning message.
Definition logging.hpp:60
Fixposition SDK: Math utilities.
Utilities for apps.
Definition app.hpp:34
Math utilities.
Definition math.hpp:35
constexpr T RadToDeg(T radians)
Convert radians to degrees.
Definition math.hpp:79
Parser FP_A routines and types.
Definition fpa.hpp:96
Time utilities.
Definition time.hpp:39
Transformation utilities.
Definition trafo.hpp:36
Eigen::Vector3d TfWgs84LlhEcef(const Eigen::Vector3d &ecef)
Convert ECEF (x, y, z) coordinates to geodetic coordinates (latitude, longitude, height)
Fixposition SDK: Parser.
Message frame output by the Parser.
Definition types.hpp:96
std::string name_
Name of the message.
Definition types.hpp:100
std::vector< uint8_t > data_
Message data.
Definition types.hpp:99
std::array< double, 3 > values
Values.
Definition fpa.hpp:488
FpaInt week
GPS week number, range 0-9999, or null if time not (yet) available.
Definition fpa.hpp:514
FpaFloat tow
GPS time of week, range 0.000-604799.999999, or null if time not (yet) available.
Definition fpa.hpp:515
bool valid
Data is valid.
Definition fpa.hpp:469
FpaFloat3 pos
Position, X/Y/Z components.
Definition fpa.hpp:740
FP_A-ODOMETRY (version 2) messages payload (ECEF)
Definition fpa.hpp:772
bool SetFromMsg(const uint8_t *msg, const std::size_t msg_size) final
Set data from message.
Fixposition SDK: Time utilities.
Fixposition SDK: Transformation utilities.