QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
LogFileParser.cc
Go to the documentation of this file.
1#include "LogFileParser.h"
2
8
9#include <QtConcurrent/QtConcurrent>
10#include <QtCore/QFileInfo>
11#include <QtCore/QFutureWatcher>
12#include <QtCore/QPointer>
13
14#include <algorithm>
15#include <cmath>
16#include <limits>
17
18QGC_LOGGING_CATEGORY(LogFileParserLog, "AnalyzeView.LogFileParser")
19
20namespace {
21
22LogParseResult _parseFile(const QString &filePath, const ProgressCallback &progressCallback = nullptr, const CancelToken &cancelToken = nullptr)
23{
24 const QString suffix = QFileInfo(filePath).suffix().toLower();
25
26 if (suffix == QStringLiteral("bin") || suffix == QStringLiteral("log")) {
27 return DataFlashParser::parseFile(filePath, progressCallback, cancelToken);
28 }
29
30 if (suffix == QStringLiteral("ulg")) {
31 return ULogParser::parseFile(filePath, progressCallback, cancelToken);
32 }
33
34 const QString fileTypeDescription = suffix.isEmpty()
35 ? LogFileParser::tr("no extension")
36 : QStringLiteral(".%1").arg(suffix);
37
38 LogParseResult result;
39 result.errorMessage = LogFileParser::tr(
40 "Unsupported file type (%1) for file '%2'. Expected .bin, .log, or .ulg.")
41 .arg(fileTypeDescription, filePath);
42 return result;
43}
44
45} // namespace
46
47// ============================================================================
48// LogFileParser
49// ============================================================================
50
52 : QObject(parent)
53{
54 qCDebug(LogFileParserLog) << this;
55}
56
58{
59 qCDebug(LogFileParserLog) << this;
60}
61
62bool LogFileParser::parseFile(const QString &filePath)
63{
64 ++_parseRequestId;
65 clear();
66 const LogParseResult result = _parseFile(filePath);
67 if (!result.ok) {
68 _setParseError(result.errorMessage);
69 return false;
70 }
71 _applyResult(result);
72 qCDebug(LogFileParserLog) << "Parsed fields" << _availableFields.count()
73 << "parameters" << _parameters.count()
74 << "events" << _events.count();
75 return true;
76}
77
78void LogFileParser::startParsingAsync(const QString &filePath)
79{
80 clear(); // cancels any in-flight parse, resets data
81
82 _cancelToken = std::make_shared<std::atomic<bool>>(false);
83 const quint64 requestId = ++_parseRequestId;
84 _parsing = true;
85 emit parsingChanged();
86
87 // Use QPointer so the background thread's invokeMethod posts are safe even if
88 // this object is destroyed before all pending events are drained from the queue.
89 QPointer<LogFileParser> self(this);
90 auto progressCallback = [self, requestId](float v) {
91 if (!self) return;
92 QMetaObject::invokeMethod(self.data(), [self, requestId, v]() {
93 if (!self || requestId != self->_parseRequestId) return;
94 self->_parseProgress = v;
95 emit self->parseProgressChanged();
96 }, Qt::QueuedConnection);
97 };
98
99 auto *watcher = new QFutureWatcher<LogParseResult>(this);
100 (void) connect(watcher, &QFutureWatcher<LogParseResult>::finished, this,
101 [this, watcher, filePath, requestId]() {
102 const LogParseResult result = watcher->result();
103 watcher->deleteLater();
104
105 if (requestId != _parseRequestId) {
106 return;
107 }
108
109 _parseProgress = 1.f;
111 _parsing = false;
112 emit parsingChanged();
113
114 if (!result.ok) {
115 _setParseError(result.errorMessage);
116 emit parseFileFinished(filePath, false, result.errorMessage);
117 return;
118 }
119
120 _applyResult(result);
121 emit parseFileFinished(filePath, true, QString());
122 });
123
124 watcher->setFuture(QtConcurrent::run([filePath, progressCallback, cancelToken = _cancelToken]() {
125 return _parseFile(filePath, progressCallback, cancelToken);
126 }));
127}
128
129void LogFileParser::_applyResult(const LogParseResult &result)
130{
131 _availableFields = result.availableFields;
132 _plottableFields = result.plottableFields;
133 _parameters = result.parameters;
134
135 // Enrich parameter rows with FactMetaData (decimal places, units,
136 // short description, enum strings/values) from the bundled metadata JSON.
137 if (!_parameters.isEmpty()) {
140#ifndef QGC_NO_ARDUPILOT_DIALECT
143 _parameters,
144 result.detectedVehicleType,
146 result.firmwareMinorVersion);
147#endif
148 }
149 }
150 _events = result.events;
151 _messages = result.messages;
152 _modeSegments = result.modeSegments;
153 _dropouts = result.dropouts;
154
155 // Build mode color cache in first-appearance (chronological) order.
156 _modeColorCache.clear();
157 _modeNames.clear();
158 for (const QVariant &v : _modeSegments) {
159 const QString mode = v.toMap().value(QStringLiteral("mode")).toString();
160 if (!mode.isEmpty() && !_modeNames.contains(mode)) {
161 _modeColorCache.insert(mode, _modeColorCache.size());
162 _modeNames.append(mode);
163 }
164 }
165 _fieldSamples = result.fieldSamples;
166 _sampleCount = result.sampleCount;
167 _detectedVehicleType = result.detectedVehicleType;
170 emit parametersChanged();
171 emit eventsChanged();
172 emit messagesChanged();
173 emit modeSegmentsChanged();
174 emit modeNamesChanged();
175 emit dropoutsChanged();
177 if (_minTimestamp != result.minTimestamp || _maxTimestamp != result.maxTimestamp) {
178 _minTimestamp = result.minTimestamp;
179 _maxTimestamp = result.maxTimestamp;
180 emit timeRangeChanged();
181 }
182 emit sampleCountChanged();
183 if (_startTime != result.startTime) {
184 _startTime = result.startTime;
185 emit startTimeChanged();
186 }
187
188 _parseComplete = true;
190}
191
193{
194 if (_parsing) {
195 ++_parseRequestId;
196 if (_cancelToken) {
197 _cancelToken->store(true, std::memory_order_relaxed);
198 }
199 _parsing = false;
200 emit parsingChanged();
201 }
202
203 const bool oldParseComplete = _parseComplete;
204 _parseComplete = false;
205 if (oldParseComplete) { emit parseCompleteChanged(); }
206
207 if (!_parseError.isEmpty()) { _parseError.clear(); emit parseErrorChanged(); }
208 if (!_availableFields.isEmpty()) { _availableFields.clear(); emit availableFieldsChanged(); }
209 if (!_parameters.isEmpty()) { _parameters.clear(); emit parametersChanged(); }
210 if (!_events.isEmpty()) { _events.clear(); emit eventsChanged(); }
211 if (!_messages.isEmpty()) { _messages.clear(); emit messagesChanged(); }
212 if (!_modeSegments.isEmpty()) { _modeSegments.clear(); emit modeSegmentsChanged(); }
213 if (!_modeNames.isEmpty()) { _modeNames.clear(); _modeColorCache.clear(); emit modeNamesChanged(); }
214 if (!_dropouts.isEmpty()) { _dropouts.clear(); emit dropoutsChanged(); }
215 if (!_detectedVehicleType.isEmpty()) { _detectedVehicleType.clear(); emit detectedVehicleTypeChanged(); }
216 if (!_plottableFields.isEmpty()) { _plottableFields.clear(); emit plottableFieldsChanged(); }
217
218 _fieldSamples.clear();
219 _gpsLatField.clear();
220 _gpsLonField.clear();
221 _gpsAltField.clear();
222 if (_minTimestamp != -1.0 || _maxTimestamp != -1.0) {
223 _minTimestamp = -1.0;
224 _maxTimestamp = -1.0;
225 emit timeRangeChanged();
226 }
227 if (_sampleCount != 0) { _sampleCount = 0; emit sampleCountChanged(); }
228 if (!_startTime.isNull()) { _startTime = QDateTime(); emit startTimeChanged(); }
229 if (_parseProgress != 0.f) { _parseProgress = 0.f; emit parseProgressChanged(); }
230}
231
232QVariantList LogFileParser::fieldSamples(const QString &fieldName) const
233{
234 QVariantList output;
235 const auto it = _fieldSamples.constFind(fieldName);
236 if (it == _fieldSamples.cend()) { return output; }
237 const QVector<QPointF> &points = it.value();
238 output.reserve(points.size());
239 for (const QPointF &p : points) { output.append(p); }
240 return output;
241}
242
243QVariantMap LogFileParser::fieldMinMax(const QString &fieldName) const
244{
245 const auto it = _fieldSamples.constFind(fieldName);
246 if (it == _fieldSamples.cend() || it->isEmpty()) { return {}; }
247 const QVector<QPointF> &points = it.value();
248 double minY = std::numeric_limits<double>::max();
249 double maxY = std::numeric_limits<double>::lowest();
250 for (const QPointF &p : points) {
251 if (p.y() < minY) minY = p.y();
252 if (p.y() > maxY) maxY = p.y();
253 }
254 return QVariantMap{{QStringLiteral("min"), minY}, {QStringLiteral("max"), maxY}};
255}
256
257QVariantList LogFileParser::fieldSamplesFiltered(const QString &fieldName, double minX, double maxX, int pixelWidth) const
258{
259 QVariantList output;
260 const auto it = _fieldSamples.constFind(fieldName);
261 if (it == _fieldSamples.cend() || pixelWidth <= 0 || maxX <= minX) { return output; }
262
263 const QVector<QPointF> &points = it.value();
264
265 // Find the slice within [minX, maxX]
266 const auto sliceBegin = std::lower_bound(points.cbegin(), points.cend(), minX,
267 [](const QPointF &p, double t) { return p.x() < t; });
268 const auto sliceEnd = std::upper_bound(sliceBegin, points.cend(), maxX,
269 [](double t, const QPointF &p) { return t < p.x(); });
270
271 const qsizetype sliceCount = std::distance(sliceBegin, sliceEnd);
272 if (sliceCount == 0) { return output; }
273
274 // If already sparse enough, return slice as-is
275 if (sliceCount <= 4 * pixelWidth) {
276 output.reserve(sliceCount);
277 for (auto jt = sliceBegin; jt != sliceEnd; ++jt) { output.append(*jt); }
278 return output;
279 }
280
281 // Screen-space min/max bucketing: one bucket per pixel column.
282 // For each column track first, min-y, max-y, last indices; flush in time order.
283 output.reserve(4 * pixelWidth);
284 const double range = maxX - minX;
285
286 auto columnOf = [&](double x) -> int {
287 return static_cast<int>((x - minX) / range * pixelWidth);
288 };
289
290 // Per-column state
291 int curCol = -1;
292 qsizetype firstIdx = -1;
293 qsizetype minIdx = -1;
294 qsizetype maxIdx = -1;
295 qsizetype lastIdx = -1;
296
297 auto flush = [&]() {
298 if (firstIdx < 0) return;
299 // Collect the up-to-4 representative indices in time order, deduplicated
300 qsizetype indices[4] = { firstIdx, minIdx, maxIdx, lastIdx };
301 std::sort(indices, indices + 4);
302 qsizetype prev = -1;
303 for (qsizetype idx : indices) {
304 if (idx != prev) {
305 output.append(*(sliceBegin + idx));
306 prev = idx;
307 }
308 }
309 };
310
311 for (qsizetype i = 0; i < sliceCount; ++i) {
312 const QPointF &p = *(sliceBegin + i);
313 const int col = std::clamp(columnOf(p.x()), 0, pixelWidth - 1);
314 if (col != curCol) {
315 flush();
316 curCol = col;
317 firstIdx = i;
318 minIdx = i;
319 maxIdx = i;
320 } else {
321 if (p.y() < (sliceBegin + minIdx)->y()) minIdx = i;
322 if (p.y() > (sliceBegin + maxIdx)->y()) maxIdx = i;
323 }
324 lastIdx = i;
325 }
326 flush();
327
328 return output;
329}
330
331double LogFileParser::fieldValueAt(const QString &fieldName, double timestampSeconds) const
332{
333 const auto it = _fieldSamples.constFind(fieldName);
334 if (it == _fieldSamples.cend() || it->isEmpty()) {
335 return std::numeric_limits<double>::quiet_NaN();
336 }
337 const QVector<QPointF> &points = it.value();
338 const auto lower = std::lower_bound(points.cbegin(), points.cend(), timestampSeconds,
339 [](const QPointF &p, double t) { return p.x() < t; });
340
341 if (lower == points.cbegin()) { return lower->y(); }
342 if (lower == points.cend()) { return points.constLast().y(); }
343
344 const auto prev = std::prev(lower);
345 return (std::fabs(prev->x() - timestampSeconds) <= std::fabs(lower->x() - timestampSeconds))
346 ? prev->y() : lower->y();
347}
348
349QString LogFileParser::modeColor(const QString &modeName) const
350{
351 static const QStringList modePalette = {
352 QStringLiteral("#E53935"), // red
353 QStringLiteral("#FB8C00"), // orange
354 QStringLiteral("#FDD835"), // yellow
355 QStringLiteral("#43A047"), // green
356 QStringLiteral("#00897B"), // teal
357 QStringLiteral("#00ACC1"), // cyan
358 QStringLiteral("#1E88E5"), // blue
359 QStringLiteral("#5E35B1"), // indigo
360 QStringLiteral("#8E24AA"), // purple
361 QStringLiteral("#D81B60"), // pink
362 QStringLiteral("#6D4C41"), // brown
363 QStringLiteral("#546E7A"), // blue grey
364 };
365
366 const auto it = _modeColorCache.constFind(modeName);
367 if (it == _modeColorCache.constEnd()) {
368 return modePalette[0]; // unknown mode; cache was not seeded yet
369 }
370 return modePalette[it.value() % modePalette.size()];
371}
372
373QString LogFileParser::modeAt(double timestampSeconds) const
374{
375 for (const QVariant &v : _modeSegments) {
376 const QVariantMap seg = v.toMap();
377 const double start = seg.value(QStringLiteral("start")).toDouble();
378 const double end = seg.value(QStringLiteral("end")).toDouble();
379 if (timestampSeconds >= start && timestampSeconds <= end) {
380 return seg.value(QStringLiteral("mode")).toString();
381 }
382 }
383 return QString();
384}
385
386QVariantList LogFileParser::eventsNear(double timestampSeconds, double thresholdSeconds) const
387{
388 QVariantList matches;
389 const double threshold = std::max(0.0, thresholdSeconds);
390 const auto lower = std::lower_bound(_events.cbegin(), _events.cend(),
391 timestampSeconds - threshold,
392 [](const QVariant &v, double t) {
393 return v.toMap().value(QStringLiteral("time")).toDouble() < t;
394 });
395 for (auto it = lower; it != _events.cend(); ++it) {
396 const QVariantMap ev = it->toMap();
397 if (ev.value(QStringLiteral("time")).toDouble() > timestampSeconds + threshold) { break; }
398 matches.append(ev);
399 }
400 return matches;
401}
402
403void LogFileParser::_setParseError(const QString &error)
404{
405 if (_parseError != error) {
406 _parseError = error;
407 emit parseErrorChanged();
408 }
409}
410
412{
413 return _gpsAltField;
414}
415
416QVariantMap LogFileParser::gpsCoordAt(double timestampSeconds) const
417{
418 if (_gpsLatField.isEmpty() || _gpsLonField.isEmpty()) {
419 return {};
420 }
421
422 const auto latIt = _fieldSamples.constFind(_gpsLatField);
423 const auto lonIt = _fieldSamples.constFind(_gpsLonField);
424 if (latIt == _fieldSamples.cend() || lonIt == _fieldSamples.cend()) {
425 return {};
426 }
427
428 const QVector<QPointF> &latPts = latIt.value();
429 const QVector<QPointF> &lonPts = lonIt.value();
430 if (latPts.isEmpty() || lonPts.isEmpty()) {
431 return {};
432 }
433
434 // Binary search for the sample with timestamp closest to timestampSeconds.
435 int lo = 0;
436 int hi = latPts.size() - 1;
437 while (lo < hi) {
438 const int mid = (lo + hi) / 2;
439 if (latPts[mid].x() < timestampSeconds) {
440 lo = mid + 1;
441 } else {
442 hi = mid;
443 }
444 }
445 // lo is the first index >= timestampSeconds; compare with lo-1.
446 if (lo > 0) {
447 const double dPrev = timestampSeconds - latPts[lo - 1].x();
448 const double dCurr = latPts[lo].x() - timestampSeconds;
449 if (dPrev < dCurr) {
450 --lo;
451 }
452 }
453
454 const int lonIdx = std::min(lo, static_cast<int>(lonPts.size()) - 1);
455 QVariantMap coord;
456 coord[QStringLiteral("latitude")] = latPts[lo].y();
457 coord[QStringLiteral("longitude")] = lonPts[lonIdx].y();
458 return coord;
459}
460
461QVariantList LogFileParser::gpsPath() const
462{
463 // Candidate field-name pairs tried in priority order.
464 // All values are stored in degrees (the parsers handle any raw-unit conversion).
465 // statusField: if non-null, that field's value must be >= statusMinValue to accept the sample.
466 // This is used for APM GPS messages where Status < 3 means no valid 3D fix.
467 struct CandidatePair {
468 const char *latField;
469 const char *lonField;
470 const char *altField;
471 const char *statusField;
472 double statusMinValue;
473 };
474
475 static const CandidatePair candidates[] = {
476 // PX4 ULog — vehicle_global_position (EKF-fused position, double degrees)
477 { "vehicle_global_position.lat", "vehicle_global_position.lon", "vehicle_global_position.alt", nullptr, 0 },
478 { "vehicle_global_position.latitude_deg", "vehicle_global_position.longitude_deg", "vehicle_global_position.alt", nullptr, 0 },
479 // PX4 ULog — vehicle_gps_position / sensor_gps (newer firmware uses latitude_deg)
480 { "vehicle_gps_position.latitude_deg", "vehicle_gps_position.longitude_deg", "vehicle_gps_position.altitude_msl_m", nullptr, 0 },
481 { "vehicle_gps_position[0].latitude_deg", "vehicle_gps_position[0].longitude_deg", "vehicle_gps_position[0].altitude_msl_m", nullptr, 0 },
482 { "sensor_gps.latitude_deg", "sensor_gps.longitude_deg", "sensor_gps.altitude_msl_m", nullptr, 0 },
483 { "sensor_gps[0].latitude_deg", "sensor_gps[0].longitude_deg", "sensor_gps[0].altitude_msl_m", nullptr, 0 },
484 // APM DataFlash — GPS message (Status >= 3 = 3D fix; 'L' type already divided by 1e7)
485 { "GPS.Lat", "GPS.Lng", "GPS.Alt", "GPS.Status", 3 },
486 { "GPS2.Lat", "GPS2.Lng", "GPS2.Alt", "GPS2.Status", 3 },
487 // APM DataFlash — POS message (EKF-fused; no status field, 'L' type already divided by 1e7)
488 { "POS.Lat", "POS.Lng", "POS.Alt", nullptr, 0 },
489 };
490
491 for (const auto &c : candidates) {
492 const auto latIt = _fieldSamples.constFind(QLatin1String(c.latField));
493 const auto lonIt = _fieldSamples.constFind(QLatin1String(c.lonField));
494 if (latIt == _fieldSamples.cend() || lonIt == _fieldSamples.cend()) {
495 continue;
496 }
497
498 const QVector<QPointF> &latPts = latIt.value();
499 const QVector<QPointF> &lonPts = lonIt.value();
500 if (latPts.isEmpty() || lonPts.isEmpty()) {
501 continue;
502 }
503
504 // Resolve optional status field (same message, same sample count as lat/lon).
505 const QVector<QPointF> *statusPts = nullptr;
506 if (c.statusField) {
507 const auto statusIt = _fieldSamples.constFind(QLatin1String(c.statusField));
508 if (statusIt != _fieldSamples.cend() && !statusIt.value().isEmpty()) {
509 statusPts = &statusIt.value();
510 }
511 }
512
513 qCDebug(LogFileParserLog) << "gpsPath: found candidate" << c.latField
514 << "samples:" << latPts.size()
515 << "first lat:" << latPts.first().y()
516 << "first lon:" << lonPts.first().y();
517
518 QVariantList path;
519 const int n = std::min(latPts.size(), lonPts.size());
520 path.reserve(n);
521
522 for (int i = 0; i < n; i++) {
523 // Skip samples that don't have a valid GPS fix.
524 if (statusPts && i < statusPts->size() && (*statusPts)[i].y() < c.statusMinValue) {
525 continue;
526 }
527
528 const double lat = latPts[i].y();
529 const double lon = lonPts[i].y();
530
531 if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0
532 || (qFuzzyIsNull(lat) && qFuzzyIsNull(lon))) {
533 continue;
534 }
535 QVariantMap coord;
536 coord[QStringLiteral("latitude")] = lat;
537 coord[QStringLiteral("longitude")] = lon;
538 path.append(coord);
539 }
540
541 qCDebug(LogFileParserLog) << "gpsPath: valid points after filter:" << path.size();
542
543 if (!path.isEmpty()) {
544 _gpsLatField = QLatin1String(c.latField);
545 _gpsLonField = QLatin1String(c.lonField);
546 // Only cache the alt field if it actually exists and has samples;
547 // otherwise the altitude chart would be shown with no data.
548 const QLatin1String altField(c.altField);
549 const auto altIt = _fieldSamples.constFind(altField);
550 _gpsAltField = (altIt != _fieldSamples.cend() && !altIt.value().isEmpty()) ? altField : QLatin1String{};
551 return path;
552 }
553
554 qCDebug(LogFileParserLog) << "gpsPath: all" << n << "points filtered out for candidate" << c.latField;
555 }
556
557 qCDebug(LogFileParserLog) << "gpsPath: no GPS data found; available fields containing 'lat' or 'lon':";
558 for (auto it = _fieldSamples.cbegin(); it != _fieldSamples.cend(); ++it) {
559 const QString &fn = it.key();
560 if (fn.contains(QLatin1String("lat"), Qt::CaseInsensitive) || fn.contains(QLatin1String("lon"), Qt::CaseInsensitive)) {
561 qCDebug(LogFileParserLog) << " " << fn << "samples:" << it.value().size()
562 << (it.value().isEmpty() ? 0.0 : it.value().first().y());
563 }
564 }
565 return {};
566}
std::function< void(float)> ProgressCallback
std::shared_ptr< std::atomic< bool > > CancelToken
Error error
#define QGC_LOGGING_CATEGORY(name, categoryStr)
Q_INVOKABLE QVariantList eventsNear(double timestampSeconds, double thresholdSeconds) const
Q_INVOKABLE double fieldValueAt(const QString &fieldName, double timestampSeconds) const
Q_INVOKABLE QVariantList fieldSamplesFiltered(const QString &fieldName, double minX, double maxX, int pixelWidth) const
void parametersChanged()
Q_INVOKABLE QVariantMap fieldMinMax(const QString &fieldName) const
Q_INVOKABLE QVariantList fieldSamples(const QString &fieldName) const
void timeRangeChanged()
void parseProgressChanged()
void parseErrorChanged()
void dropoutsChanged()
Q_INVOKABLE void startParsingAsync(const QString &filePath)
void startTimeChanged()
void detectedVehicleTypeChanged()
void sampleCountChanged()
void parsingChanged()
LogFileParser(QObject *parent=nullptr)
void plottableFieldsChanged()
void availableFieldsChanged()
void modeSegmentsChanged()
void parseCompleteChanged()
Q_INVOKABLE QVariantMap gpsCoordAt(double timestampSeconds) const
Q_INVOKABLE QString modeAt(double timestampSeconds) const
Q_INVOKABLE QString gpsAltitudeFieldName() const
Q_INVOKABLE QVariantList gpsPath() const
void parseFileFinished(const QString &filePath, bool ok, const QString &errorMessage)
void modeNamesChanged()
Q_INVOKABLE void clear()
Q_INVOKABLE bool parseFile(const QString &filePath)
Q_INVOKABLE QString modeColor(const QString &modeName) const
void eventsChanged()
void messagesChanged()
static void enrichForAPM(QVariantList &parameters, const QString &vehicleType, int major, int minor)
static void enrichForPX4(QVariantList &parameters)
LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback, const CancelToken &cancelToken)
LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback, const CancelToken &cancelToken)
QVariantList modeSegments
QStringList plottableFields
QStringList availableFields
QHash< QString, QVector< QPointF > > fieldSamples