diff --git a/CMakeLists.txt b/CMakeLists.txt index a84f02b..d82c11f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(location_plotter VERSION 0.2.0 LANGUAGES CXX) +project(location_plotter VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -62,6 +62,9 @@ add_executable(location_parser_tests src/TelemetryPoint.h ) target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB) +target_compile_options(location_parser_tests PRIVATE + $<$:-UNDEBUG> +) add_test(NAME location_parser_tests COMMAND location_parser_tests) add_executable(location_main_window_tests @@ -82,6 +85,9 @@ add_executable(location_main_window_tests src/TelemetryPoint.h ) target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_options(location_main_window_tests PRIVATE + $<$:-UNDEBUG> +) qt_add_resources(location_main_window_tests "test_app_resources" PREFIX "/" FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png diff --git a/README.md b/README.md index 58ec7d6..5b775aa 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,14 @@ into a single plotted point. The GPS firmware's level-1 paired protocol is source-aware. Every received `source=gps` location is added to the blue GPS series and every -`source=imu_corrected` location is immediately added to the red corrected series. +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. Paths are built independently, so alternating UART lines do not create GPS-to-IMU -zigzags. When corrected points exist, CEP50, average and scatter calculations use -only that corrected series; legacy input without a source keeps the previous behavior. +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. Synthetic `source=trajectory` points form a third independent green ground-truth path. The map legend identifies all three series. @@ -187,7 +191,8 @@ ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da des Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili `/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta `/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak -gosterilir. Varsayilan baud rate `115200`'dur. +gosterilir. 50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud +rate `460800`'dur. If the port cannot be opened, make sure the user has serial access: @@ -218,7 +223,13 @@ without logging out. mosaic (NASA SVS visualization 2915), projected locally without a runtime network request. - RINEX download starts at every launch, repeats every 15 minutes, and falls back through the previous two UTC days if today's stream file is unavailable. Current and next-minute satellite - positions are propagated from the cached ephemeris, then smoothly interpolated at 30 FPS. + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the UTC time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous + connected-PRN set, so satellites that were lost no longer remain green. +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. - The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. - The main content width and the right-side dashboard/UART height are both adjustable by diff --git a/src/GlobeWidget.cpp b/src/GlobeWidget.cpp index d6661a8..ab82880 100644 --- a/src/GlobeWidget.cpp +++ b/src/GlobeWidget.cpp @@ -17,6 +17,7 @@ #include #include +#include namespace { constexpr double Pi = 3.14159265358979323846; @@ -222,8 +223,10 @@ GlobeWidget::GlobeWidget(QWidget *parent) setMouseTracking(true); setCursor(Qt::OpenHandCursor); setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin")); - m_satelliteAnimationTimer.setInterval(33); - m_satelliteAnimationTimer.setTimerType(Qt::PreciseTimer); + // GNSS satellites move only a few screen pixels per minute. Ten frames per + // second is visually smooth here and avoids repainting a static globe at 30 FPS. + m_satelliteAnimationTimer.setInterval(100); + m_satelliteAnimationTimer.setTimerType(Qt::CoarseTimer); connect(&m_satelliteAnimationTimer, &QTimer::timeout, this, &GlobeWidget::updateSatelliteAnimation); } @@ -246,8 +249,29 @@ void GlobeWidget::setSatelliteMotion(const QVector ¤t, // Continue from the currently rendered points when a fresh ephemeris arrives, // avoiding even a small visual jump at the update boundary. - m_satelliteMotionStart = m_satellites.isEmpty() ? current : m_satellites; - m_satelliteMotionTarget = next; + const QVector &candidateStart = + m_satellites.isEmpty() ? current : m_satellites; + QHash starts; + starts.reserve(candidateStart.size()); + for (const SatellitePosition &satellite : candidateStart) { + starts.insert(satellite.prn, satellite); + } + + m_satelliteMotionStart.clear(); + m_satelliteMotionTarget.clear(); + m_satelliteMotionStart.reserve(next.size()); + m_satelliteMotionTarget.reserve(next.size()); + for (const SatellitePosition &target : next) { + const auto start = starts.constFind(target.prn); + if (start == starts.cend()) continue; + m_satelliteMotionStart.push_back(start.value()); + m_satelliteMotionTarget.push_back(target); + } + if (m_satelliteMotionStart.isEmpty()) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } m_satelliteMotionDurationMs = durationMs; m_satelliteMotionClock.restart(); m_satelliteAnimationTimer.start(); @@ -261,18 +285,11 @@ void GlobeWidget::updateSatelliteAnimation() const double progress = std::clamp( static_cast(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs, 0.0, 1.0); - QHash targets; - targets.reserve(m_satelliteMotionTarget.size()); - for (const SatellitePosition &satellite : m_satelliteMotionTarget) { - targets.insert(satellite.prn, satellite); - } - QVector interpolated; interpolated.reserve(m_satelliteMotionStart.size()); - for (const SatellitePosition &start : m_satelliteMotionStart) { - const auto targetIt = targets.constFind(start.prn); - if (targetIt == targets.cend()) continue; - const SatellitePosition &target = targetIt.value(); + for (qsizetype i = 0; i < m_satelliteMotionStart.size(); ++i) { + const SatellitePosition &start = m_satelliteMotionStart[i]; + const SatellitePosition &target = m_satelliteMotionTarget[i]; SatellitePosition point = start; point.x += (target.x - start.x) * progress; point.y += (target.y - start.y) * progress; @@ -347,6 +364,78 @@ void GlobeWidget::paintEvent(QPaintEvent *) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); + const qreal devicePixelRatio = devicePixelRatioF(); + const qint64 lightingMinute = + QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60; + if (m_staticLayer.isNull() || m_staticLayerSize != size() + || !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio) + || m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch + || m_staticLayerZoom != m_zoom + || m_staticLayerLightingMinute != lightingMinute) { + rebuildStaticLayer(); + } + painter.drawPixmap(0, 0, m_staticLayer); + + drawSatelliteCoverage(painter); + if (m_hasReceiver) drawVisibilityCone(painter); + if (m_hasReceiver) drawReceiver(painter); + const int visibleCount = drawSatellites(painter); + + painter.setPen(QColor(229, 240, 249)); + QFont titleFont = painter.font(); + titleFont.setPixelSize(18); + titleFont.setBold(true); + painter.setFont(titleFont); + painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, + QStringLiteral("GNSS YORUNGE GORUNUMU")); + QFont smallFont = painter.font(); + smallFont.setPixelSize(11); + smallFont.setBold(false); + painter.setFont(smallFont); + painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); + painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); + + const int legendY = height() - 25; + const int legendX = width() / 2 - 125; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(42, 224, 137)); + painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); + painter.setPen(QColor(101, 239, 176)); + painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 79, 95)); + painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); + painter.setPen(QColor(255, 132, 143)); + painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); + painter.setPen(QColor(138, 183, 207)); + painter.drawText(legendX + 181, legendY + 1, + QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") + .arg(m_observedPrns.size()).arg(m_satellites.size()) + .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); + + if (!m_hasReceiver) { + painter.setPen(QColor(219, 232, 241)); + QFont waitingFont = painter.font(); + waitingFont.setPixelSize(16); + waitingFont.setBold(true); + painter.setFont(waitingFont); + painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, + QStringLiteral("Ilk gecerli konum bekleniyor")); + } +} + +void GlobeWidget::rebuildStaticLayer() +{ + const qreal devicePixelRatio = devicePixelRatioF(); + const QSize pixelSize( + std::max(1, qRound(width() * devicePixelRatio)), + std::max(1, qRound(height() * devicePixelRatio))); + QPixmap layer(pixelSize); + layer.setDevicePixelRatio(devicePixelRatio); + layer.fill(Qt::transparent); + QPainter painter(&layer); + painter.setRenderHint(QPainter::Antialiasing, true); + QLinearGradient background(0, 0, width(), height()); background.setColorAt(0.0, QColor(3, 10, 21)); background.setColorAt(0.55, QColor(8, 24, 43)); @@ -401,59 +490,16 @@ void GlobeWidget::paintEvent(QPaintEvent *) painter.restore(); for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0); - drawSatelliteCoverage(painter); - if (m_hasReceiver) drawVisibilityCone(painter); - if (m_hasReceiver) drawReceiver(painter); - drawSatellites(painter); + painter.end(); - painter.setPen(QColor(229, 240, 249)); - QFont titleFont = painter.font(); - titleFont.setPixelSize(18); - titleFont.setBold(true); - painter.setFont(titleFont); - painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, - QStringLiteral("GNSS YORUNGE GORUNUMU")); - QFont smallFont = painter.font(); - smallFont.setPixelSize(11); - smallFont.setBold(false); - painter.setFont(smallFont); - painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); - painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); - painter.setPen(QColor(105, 143, 166, 145)); - painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignRight | Qt::AlignVCenter, - QStringLiteral("Earth: NASA/GSFC Blue Marble")); - - const int legendY = height() - 25; - const int legendX = width() / 2 - 125; - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(42, 224, 137)); - painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); - painter.setPen(QColor(101, 239, 176)); - painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(255, 79, 95)); - painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); - painter.setPen(QColor(255, 132, 143)); - painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); - painter.setPen(QColor(138, 183, 207)); - int visibleCount = 0; - for (const auto &satellite : m_satellites) { - if (satelliteElevation(satellite) >= m_elevationMaskDegrees) ++visibleCount; - } - painter.drawText(legendX + 181, legendY + 1, - QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") - .arg(m_observedPrns.size()).arg(m_satellites.size()) - .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); - - if (!m_hasReceiver) { - painter.setPen(QColor(219, 232, 241)); - QFont waitingFont = painter.font(); - waitingFont.setPixelSize(16); - waitingFont.setBold(true); - painter.setFont(waitingFont); - painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, - QStringLiteral("Ilk gecerli konum bekleniyor")); - } + m_staticLayer = std::move(layer); + m_staticLayerSize = size(); + m_staticLayerDevicePixelRatio = devicePixelRatio; + m_staticLayerYaw = m_yaw; + m_staticLayerPitch = m_pitch; + m_staticLayerZoom = m_zoom; + m_staticLayerLightingMinute = + QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60; } void GlobeWidget::drawBlueMarble(QPainter &painter) const @@ -558,9 +604,10 @@ void GlobeWidget::drawBlueMarble(QPainter &painter) const void GlobeWidget::drawContinents(QPainter &painter) const { const double radius = std::min(width(), height()) * 0.255 * m_zoom; - const double pixelRatio = std::min(devicePixelRatioF(), 1.5); + const double pixelRatio = m_dragging ? 0.75 : std::min(devicePixelRatioF(), 1.5); const int textureSize = std::clamp( - static_cast(std::ceil(radius * 2.0 * pixelRatio)), 320, 900); + static_cast(std::ceil(radius * 2.0 * pixelRatio)), + m_dragging ? 240 : 320, m_dragging ? 480 : 900); const QPointF center(width() * 0.5, height() * 0.52); const QRectF target(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0); @@ -960,33 +1007,41 @@ void GlobeWidget::drawReceiver(QPainter &painter) const painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI")); } -void GlobeWidget::drawSatellites(QPainter &painter) const +int GlobeWidget::drawSatellites(QPainter &painter) const { - if (!m_hasReceiver) return; + if (!m_hasReceiver) return 0; const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; - struct Item { SatellitePosition satellite; ProjectedPoint projected; }; + struct Item { + SatellitePosition satellite; + ProjectedPoint projected; + double elevation = -90.0; + }; QVector items; + items.reserve(m_satellites.size()); for (const auto &satellite : m_satellites) { const double length = std::sqrt(satellite.x * satellite.x + satellite.y * satellite.y + satellite.z * satellite.z); if (length <= 0.0) continue; items.push_back({satellite, project(satellite.x / length, satellite.y / length, - satellite.z / length, orbitRadius)}); + satellite.z / length, orbitRadius), + satelliteElevation(satellite)}); } std::sort(items.begin(), items.end(), [](const Item &a, const Item &b) { return a.projected.depth < b.projected.depth; }); + const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, + earthRadius * 1.02); + int visibleCount = 0; for (const Item &item : items) { const bool found = m_observedPrns.contains(item.satellite.prn); - const bool inElevationMask = satelliteElevation(item.satellite) >= m_elevationMaskDegrees; + const bool inElevationMask = item.elevation >= m_elevationMaskDegrees; + if (inElevationMask) ++visibleCount; QColor color = found ? QColor(42, 224, 137) : QColor(255, 79, 95); if (item.projected.depth < 0.0) color.setAlpha(75); const double markerRadius = found ? 6.5 : 5.0; if (inElevationMask) { - const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; - const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); - const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, - earthRadius * 1.02); QColor sightLine(70, 224, 231, item.projected.depth < 0.0 ? 25 : 80); painter.setPen(QPen(sightLine, 0.8, Qt::DashLine)); painter.drawLine(receiverPoint.point, item.projected.point); @@ -1002,6 +1057,7 @@ void GlobeWidget::drawSatellites(QPainter &painter) const painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn); } } + return visibleCount; } double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const @@ -1047,7 +1103,9 @@ void GlobeWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_dragging = false; + m_staticLayer = QPixmap(); setCursor(Qt::OpenHandCursor); + update(); } } diff --git a/src/GlobeWidget.h b/src/GlobeWidget.h index aad6c14..896233b 100644 --- a/src/GlobeWidget.h +++ b/src/GlobeWidget.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ public: void setReceiverLocation(double latitude, double longitude); void clearReceiverLocation(); void setRinexStatus(const QString &status, bool error); + [[nodiscard]] QSet observedPrns() const { return m_observedPrns; } public slots: void setElevationMask(double degrees); @@ -50,9 +52,10 @@ private: void drawVisibilityCone(QPainter &painter) const; void drawGraticule(QPainter &painter) const; void drawReceiver(QPainter &painter) const; - void drawSatellites(QPainter &painter) const; + int drawSatellites(QPainter &painter) const; double satelliteElevation(const SatellitePosition &satellite) const; void updateSatelliteAnimation(); + void rebuildStaticLayer(); QVector m_satellites; QVector m_satelliteMotionStart; @@ -64,6 +67,13 @@ private: mutable double m_landTextureYaw = 0.0; mutable double m_landTexturePitch = 0.0; mutable qint64 m_landTextureLightingMinute = -1; + QPixmap m_staticLayer; + QSize m_staticLayerSize; + qreal m_staticLayerDevicePixelRatio = 0.0; + double m_staticLayerYaw = 0.0; + double m_staticLayerPitch = 0.0; + double m_staticLayerZoom = 0.0; + qint64 m_staticLayerLightingMinute = -1; QSet m_observedPrns; QString m_status = QStringLiteral("Konum bekleniyor"); bool m_statusError = false; diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index bb6621a..f60ff53 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -103,6 +103,26 @@ std::optional LocationParser::parseLine(const QString &line) telemetry.source = LocationSource::Gps; } } + static const QRegularExpression fusionModeRegex( + QStringLiteral(R"((?:^|\s)mode\s*=\s*(?gps[_-]?aided|dead[_-]?reckoning)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch fusionModeMatch = fusionModeRegex.match(trimmed); + if (fusionModeMatch.hasMatch()) { + telemetry.fusionMode = fusionModeMatch.captured(QStringLiteral("mode")).startsWith( + QStringLiteral("dead"), Qt::CaseInsensitive) + ? FusionMode::DeadReckoning : FusionMode::GpsAided; + } + static const QRegularExpression gpsAgeRegex( + QStringLiteral(R"((?:^|\s)gps[_-]?age[_-]?ms\s*=\s*(?\d+)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch gpsAgeMatch = gpsAgeRegex.match(trimmed); + if (gpsAgeMatch.hasMatch()) { + bool ok = false; + const qint64 ageMs = gpsAgeMatch.captured(QStringLiteral("age")).toLongLong(&ok); + if (ok) { + telemetry.gpsAgeMs = ageMs; + } + } bool hadCoordinateField = false; bool hadLatitudeField = false; bool hadLongitudeField = false; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index c841c80..2f8ed39 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -312,7 +312,7 @@ public: int columnCount(const QModelIndex &parent = QModelIndex()) const override { - return parent.isValid() ? 0 : 7; + return parent.isValid() ? 0 : 8; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override @@ -332,6 +332,14 @@ public: case 0: return point.receivedAt.toLocalTime().toString(QStringLiteral("HH:mm:ss.zzz")); case 1: + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::DeadReckoning) { + return QStringLiteral("IMU corrected (DR)"); + } + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::GpsAided) { + return QStringLiteral("IMU corrected (GPS)"); + } return locationSourceText(point.source); case 2: return coordinateText(point.latitude, point.latitudeText); @@ -343,6 +351,10 @@ public: return optionalIntText(point.satellites); case 6: return optionalDopText(point.dop.pdop); + case 7: + return point.gpsAgeMs.has_value() + ? QStringLiteral("%1 ms").arg(*point.gpsAgeMs) + : QStringLiteral("-"); default: return {}; } @@ -356,7 +368,8 @@ public: } static const QStringList headers = { QStringLiteral("Saat"), QStringLiteral("Kaynak"), QStringLiteral("Lat"), QStringLiteral("Long"), - QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP")}; + QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP"), + QStringLiteral("GPS yas")}; return section >= 0 && section < headers.size() ? headers[section] : QVariant(); } @@ -405,6 +418,14 @@ MainWindow::MainWindow(QWidget *parent) } statusBar()->showMessage(status, error ? 8000 : 4000); }); + connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) { + m_refreshRinexButton->setEnabled(!loading); + m_refreshRinexButton->setText( + loading ? QStringLiteral("RINEX indiriliyor...") + : QStringLiteral("RINEX Guncelle")); + }); + connect(m_refreshRinexButton, &QPushButton::clicked, + m_rinex, &RinexNavigation::refresh); m_rinex->refresh(); auto *rinexRefreshTimer = new QTimer(this); rinexRefreshTimer->setInterval(15 * 60 * 1000); @@ -630,7 +651,12 @@ void MainWindow::handleSerialLine(const QString &line) static const QRegularExpression solveStartRegex( QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression trackingSnapshotRegex( + QStringLiteral( + R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prns?|svns?)\s*=\s*\[[^\]]*\])"), + QRegularExpression::CaseInsensitiveOption); const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch(); + const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch(); if (solveStarted) { // Never combine a coordinate fragment left by the previous navigation // period with fields from the new one. A complete pending fix is still @@ -643,10 +669,14 @@ void MainWindow::handleSerialLine(const QString &line) m_observedPrns.clear(); } const QSet linePrns = LocationParser::parseGpsPrns(line); - if (!linePrns.isEmpty()) { + if (trackingSnapshot) { + // This line is a complete point-in-time channel snapshot. Replacing + // prevents satellites from older solves remaining green indefinitely. + m_observedPrns = linePrns; + } else if (!linePrns.isEmpty()) { m_observedPrns.unite(linePrns); } - if (solveStarted || !linePrns.isEmpty()) { + if (solveStarted || trackingSnapshot || !linePrns.isEmpty()) { m_globe->setObservedPrns(m_observedPrns); } @@ -953,7 +983,7 @@ void MainWindow::buildUi() for (const int baud : {9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}) { m_baudCombo->addItem(QString::number(baud)); } - m_baudCombo->setCurrentText(QStringLiteral("115200")); + m_baudCombo->setCurrentText(QStringLiteral("460800")); m_maxPointsSpin = new QSpinBox(this); m_maxPointsSpin->setObjectName(QStringLiteral("maxPointsInput")); @@ -1006,7 +1036,25 @@ void MainWindow::buildUi() auto *viewTabs = new QTabWidget(this); viewTabs->setObjectName(QStringLiteral("mainViewTabs")); viewTabs->addTab(m_map, QStringLiteral("2B Harita")); - viewTabs->addTab(m_globe, QStringLiteral("3B Dunya · Uydular")); + + auto *globeTab = new QWidget(viewTabs); + auto *globeTabLayout = new QVBoxLayout(globeTab); + globeTabLayout->setContentsMargins(0, 0, 0, 0); + globeTabLayout->setSpacing(6); + auto *rinexToolbar = new QWidget(globeTab); + auto *rinexToolbarLayout = new QHBoxLayout(rinexToolbar); + rinexToolbarLayout->setContentsMargins(10, 6, 10, 0); + rinexToolbarLayout->addWidget(new QLabel( + QStringLiteral("GPS yayin efemerisi · otomatik: 15 dk"), rinexToolbar)); + rinexToolbarLayout->addStretch(1); + m_refreshRinexButton = new QPushButton(QStringLiteral("RINEX Guncelle"), rinexToolbar); + m_refreshRinexButton->setObjectName(QStringLiteral("refreshRinexButton")); + m_refreshRinexButton->setToolTip( + QStringLiteral("Guncel RINEX navigasyon dosyasini simdi indir")); + rinexToolbarLayout->addWidget(m_refreshRinexButton); + globeTabLayout->addWidget(rinexToolbar); + globeTabLayout->addWidget(m_globe, 1); + viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular")); auto *sidePanel = new QWidget(this); sidePanel->setMinimumWidth(330); @@ -1259,6 +1307,8 @@ void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) { TelemetryPoint point; point.source = telemetry.source; + point.fusionMode = telemetry.fusionMode; + point.gpsAgeMs = telemetry.gpsAgeMs; point.latitude = *telemetry.latitude; point.longitude = *telemetry.longitude; point.latitudeText = telemetry.latitudeText; @@ -1270,8 +1320,21 @@ void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) point.rawLine = telemetry.rawLine; point.receivedAt = QDateTime::currentDateTimeUtc(); + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + bool correctedPositionFresh = false; + for (auto existing = m_points.crbegin(); existing != m_points.crend(); ++existing) { + if (existing->source == LocationSource::ImuCorrected) { + correctedPositionFresh = + existing->receivedAt.msecsTo(point.receivedAt) + <= CorrectedPreferenceWindowMs; + break; + } + } + m_points.push_back(point); - m_globe->setReceiverLocation(point.latitude, point.longitude); + if (point.source == LocationSource::ImuCorrected || !correctedPositionFresh) { + m_globe->setReceiverLocation(point.latitude, point.longitude); + } if (m_locationTimeoutTimer != nullptr && m_serial->isOpen()) { m_locationTimeoutTimer->start(); } @@ -1363,6 +1426,12 @@ void MainWindow::mergePendingTelemetry(const ParsedTelemetry &telemetry) commitPendingTelemetry(); } + if (telemetry.fusionMode != FusionMode::Unknown) { + m_pendingTelemetry.fusionMode = telemetry.fusionMode; + } + if (telemetry.gpsAgeMs.has_value()) { + m_pendingTelemetry.gpsAgeMs = telemetry.gpsAgeMs; + } if (telemetry.latitude.has_value()) { m_pendingTelemetry.source = telemetry.source; m_pendingTelemetry.latitude = telemetry.latitude; @@ -1444,5 +1513,5 @@ int MainWindow::selectedBaudRate() const { bool ok = false; const int baudRate = m_baudCombo->currentText().toInt(&ok); - return ok ? baudRate : 115200; + return ok ? baudRate : 460800; } diff --git a/src/MainWindow.h b/src/MainWindow.h index 925cd67..522fbdb 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -82,6 +82,7 @@ private: QPushButton *m_clearButton = nullptr; QPushButton *m_openLogButton = nullptr; QPushButton *m_rinexUpdateButton = nullptr; + QPushButton *m_refreshRinexButton = nullptr; QCheckBox *m_autoFollowCheck = nullptr; QCheckBox *m_showTelemetryCheck = nullptr; QCheckBox *m_showRecentPointsCheck = nullptr; diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index 9d5eb87..f892e6b 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -224,6 +224,11 @@ void MapWidget::clearCepMarker() update(); } +QPointF MapWidget::centerCoordinate() const +{ + return QPointF(m_centerLat, m_centerLon); +} + void MapWidget::setAutoFollow(bool enabled) { if (m_autoFollow == enabled) { @@ -779,11 +784,14 @@ void MapWidget::disableAutoFollowFromUserAction() int MapWidget::preferredFocusIndex() const { - // Once corrected ESKF output is present, both camera and detail bubble - // stay locked to its newest point. Raw GPS and truth records may arrive - // later in an interleaved stream and must not pull the camera away. + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + const int newestIndex = m_points.size() - 1; for (int index = m_points.size() - 1; index >= 0; --index) { - if (m_points[index].source == LocationSource::ImuCorrected) return index; + if (m_points[index].source == LocationSource::ImuCorrected) { + const qint64 ageMs = m_points[index].receivedAt.msecsTo( + m_points[newestIndex].receivedAt); + return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex; + } } - return m_points.isEmpty() ? -1 : m_points.size() - 1; + return newestIndex; } diff --git a/src/MapWidget.h b/src/MapWidget.h index 9d99767..3efcb3a 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -27,7 +27,7 @@ public: void setCepMarker(double latitude, double longitude, const std::optional &radiusMeters); void clearCepMarker(); - [[nodiscard]] QPointF centerCoordinate() const { return {m_centerLat, m_centerLon}; } + [[nodiscard]] QPointF centerCoordinate() const; public slots: void setAutoFollow(bool enabled); diff --git a/src/RinexNavigation.cpp b/src/RinexNavigation.cpp index 2fa7993..fa60def 100644 --- a/src/RinexNavigation.cpp +++ b/src/RinexNavigation.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include namespace { @@ -23,29 +25,6 @@ constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0; constexpr double CurrentGpsUtcOffsetSeconds = 18.0; constexpr int PositionUpdateIntervalMs = 60000; -struct Ephemeris { - QString prn; - QDateTime toc; - double crs = 0.0; - double deltaN = 0.0; - double m0 = 0.0; - double cuc = 0.0; - double eccentricity = 0.0; - double cus = 0.0; - double sqrtA = 0.0; - double toe = 0.0; - double cic = 0.0; - double omega0 = 0.0; - double cis = 0.0; - double i0 = 0.0; - double crc = 0.0; - double omega = 0.0; - double omegaDot = 0.0; - double idot = 0.0; - int gpsWeek = 0; - bool healthy = true; -}; - double parseField(const QString &line, int field) { QString text = line.mid(4 + field * 19, 19).trimmed(); @@ -91,11 +70,261 @@ QByteArray gunzip(const QByteArray &input) QDateTime gpsEpoch() { - return QDateTime(QDate(1980, 1, 6), QTime(0, 0), - QTimeZone::fromSecondsAheadOfUtc(0)); + return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); } -SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc) +} // namespace + +RinexNavigation::RinexNavigation(QObject *parent) + : QObject(parent) +{ + m_positionTimer.setInterval(PositionUpdateIntervalMs); + connect(&m_positionTimer, &QTimer::timeout, + this, &RinexNavigation::updateCurrentPositions); + m_positionTimer.start(); +} + +void RinexNavigation::refresh() +{ + if (m_loading) return; + if (m_ephemerides.isEmpty()) { + loadCache(); + } + setLoading(true); + emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false); + requestDay(QDateTime::currentDateTimeUtc().date(), 0); +} + +void RinexNavigation::requestDay(const QDate &date, int fallbackIndex) +{ + const QString year = QString::number(date.year()); + const QString day = QStringLiteral("%1").arg(date.dayOfYear(), 3, 10, QLatin1Char('0')); + // BKG's stream-derived file is updated during the UTC day; the final IGS + // combination is normally published later and is therefore unsuitable here. + const QString file = QStringLiteral("BRDC00WRD_S_") + year + day + + QStringLiteral("0000_01D_MN.rnx.gz"); + const QUrl url(QStringLiteral("https://igs.bkg.bund.de/root_ftp/IGS/BRDC/%1/%2/%3") + .arg(year, day, file)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("ANKASYS-LocationPlotter/1.0 (RINEX client)")); + request.setTransferTimeout(15000); + QNetworkReply *reply = m_network.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, date, fallbackIndex] { + const bool ok = reply->error() == QNetworkReply::NoError; + const QByteArray data = ok ? reply->readAll() : QByteArray{}; + reply->deleteLater(); + if (!ok || data.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + } else { + setLoading(false); + if (!m_ephemerides.isEmpty()) { + publishCurrentMotion(); + emit statusChanged( + QStringLiteral("RINEX indirilemedi; mevcut efemeris kullaniliyor"), + true); + } else if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true); + } + } + return; + } + handleDownload(data, date, fallbackIndex); + }); +} + +void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &date, + int fallbackIndex) +{ + const QByteArray rinex = gunzip(compressed); + QString error; + QVector ephemerides = parseEphemerides(rinex, &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + return; + } + setLoading(false); + if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + } + return; + } + + QFile cache(cachePath()); + QDir().mkpath(QFileInfo(cache).absolutePath()); + if (cache.open(QIODevice::WriteOnly)) cache.write(rinex); + m_ephemerides = std::move(ephemerides); + setLoading(false); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3 UTC") + .arg(positions.size()) + .arg(date.toString(QStringLiteral("dd.MM.yyyy"))) + .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false); +} + +bool RinexNavigation::loadCache() +{ + QFile cache(cachePath()); + if (!cache.open(QIODevice::ReadOnly)) return false; + QString error; + QVector ephemerides = parseEphemerides(cache.readAll(), &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) return false; + m_ephemerides = std::move(ephemerides); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2 UTC") + .arg(positions.size()) + .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false); + return true; +} + +QString RinexNavigation::cachePath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/latest-navigation.rnx"); +} + +void RinexNavigation::updateCurrentPositions() +{ + if (m_ephemerides.isEmpty()) return; + publishCurrentMotion(); +} + +void RinexNavigation::publishCurrentMotion() +{ + if (m_ephemerides.isEmpty()) return; + + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector selected = selectEphemerides(m_ephemerides, now); + const QVector current = propagateEphemerides(selected, now); + if (current.isEmpty()) return; + + const QVector next = propagateEphemerides( + selected, now.addMSecs(PositionUpdateIntervalMs)); + emit satellitesUpdated(current); + if (!next.isEmpty()) { + emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs); + } +} + +void RinexNavigation::setLoading(bool loading) +{ + if (m_loading == loading) return; + m_loading = loading; + emit loadingChanged(loading); +} + +QVector RinexNavigation::parseEphemerides( + const QByteArray &rinex, QString *error) +{ + const QString text = QString::fromLatin1(rinex); + const QStringList lines = text.split('\n'); + int index = 0; + while (index < lines.size() && !lines[index].contains(QStringLiteral("END OF HEADER"))) ++index; + if (index >= lines.size()) { + if (error) *error = QStringLiteral("END OF HEADER bulunamadi"); + return {}; + } + ++index; + + QVector result; + result.reserve(512); + while (index < lines.size()) { + const QString first = lines[index]; + if (first.size() < 3) { ++index; continue; } + const QChar system = first[0]; + if (!system.isLetter()) { ++index; continue; } + const int blockLines = (system == QLatin1Char('R') || system == QLatin1Char('S')) ? 4 : 8; + if (index + blockLines > lines.size()) break; + if (system != QLatin1Char('G')) { index += blockLines; continue; } + + const QStringList epoch = first.mid(3, 20).simplified().split(' '); + if (epoch.size() < 6) { index += 8; continue; } + bool dateOk = false; + const int year = epoch[0].toInt(&dateOk); + const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()), + QTime(epoch[3].toInt(), epoch[4].toInt(), + static_cast(epoch[5].toDouble())), Qt::UTC); + if (!dateOk || !toc.isValid()) { index += 8; continue; } + + Ephemeris eph; + eph.prn = first.left(3).trimmed().toUpper(); + eph.toc = toc; + eph.crs = parseField(lines[index + 1], 1); + eph.deltaN = parseField(lines[index + 1], 2); + eph.m0 = parseField(lines[index + 1], 3); + eph.cuc = parseField(lines[index + 2], 0); + eph.eccentricity = parseField(lines[index + 2], 1); + eph.cus = parseField(lines[index + 2], 2); + eph.sqrtA = parseField(lines[index + 2], 3); + eph.toe = parseField(lines[index + 3], 0); + eph.cic = parseField(lines[index + 3], 1); + eph.omega0 = parseField(lines[index + 3], 2); + eph.cis = parseField(lines[index + 3], 3); + eph.i0 = parseField(lines[index + 4], 0); + eph.crc = parseField(lines[index + 4], 1); + eph.omega = parseField(lines[index + 4], 2); + eph.omegaDot = parseField(lines[index + 4], 3); + eph.idot = parseField(lines[index + 5], 0); + eph.gpsWeek = static_cast(std::llround(parseField(lines[index + 5], 2))); + const double health = parseField(lines[index + 6], 1); + eph.healthy = !std::isfinite(health) || std::llround(health) == 0; + + if (finiteFields({eph.crs, eph.deltaN, eph.m0, eph.cuc, eph.eccentricity, + eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis, + eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot}) + && eph.sqrtA > 4000.0 && eph.gpsWeek > 0) { + result.push_back(eph); + } + index += 8; + } + if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi"); + return result; +} + +QVector RinexNavigation::selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QHash closest; + QHash closestAge; + // GPS navigation epochs are labelled in GPS system time, not UTC. + const QDateTime whenGps = whenUtc.addSecs( + static_cast(CurrentGpsUtcOffsetSeconds)); + for (const Ephemeris &eph : ephemerides) { + const qint64 age = std::abs(eph.toc.msecsTo(whenGps)); + if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { + closest[eph.prn] = eph; + closestAge[eph.prn] = age; + } + } + + QVector result; + result.reserve(closest.size()); + for (const Ephemeris &eph : closest) result.push_back(eph); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { + return a.prn < b.prn; + }); + return result; +} + +QVector RinexNavigation::propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QVector result; + result.reserve(ephemerides.size()); + for (const Ephemeris &eph : ephemerides) result.push_back(propagate(eph, whenUtc)); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); + return result; +} + +SatellitePosition RinexNavigation::propagate(const Ephemeris &eph, const QDateTime &whenUtc) { const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0 + CurrentGpsUtcOffsetSeconds; @@ -138,208 +367,9 @@ SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc) return result; } -} // namespace - -RinexNavigation::RinexNavigation(QObject *parent) - : QObject(parent) -{ - m_positionTimer.setInterval(PositionUpdateIntervalMs); - connect(&m_positionTimer, &QTimer::timeout, - this, &RinexNavigation::updateCurrentPositions); - m_positionTimer.start(); -} - -void RinexNavigation::refresh() -{ - if (m_loading) return; - if (m_latestRinex.isEmpty()) { - loadCache(); - } - m_loading = true; - emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false); - requestDay(QDateTime::currentDateTimeUtc().date(), 0); -} - -void RinexNavigation::requestDay(const QDate &date, int fallbackIndex) -{ - const QString year = QString::number(date.year()); - const QString day = QStringLiteral("%1").arg(date.dayOfYear(), 3, 10, QLatin1Char('0')); - // BKG's stream-derived file is updated during the UTC day; the final IGS - // combination is normally published later and is therefore unsuitable here. - const QString file = QStringLiteral("BRDC00WRD_S_") + year + day - + QStringLiteral("0000_01D_MN.rnx.gz"); - const QUrl url(QStringLiteral("https://igs.bkg.bund.de/root_ftp/IGS/BRDC/%1/%2/%3") - .arg(year, day, file)); - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::UserAgentHeader, - QStringLiteral("ANKASYS-LocationPlotter/1.0 (RINEX client)")); - request.setTransferTimeout(15000); - QNetworkReply *reply = m_network.get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply, date, fallbackIndex] { - const bool ok = reply->error() == QNetworkReply::NoError; - const QByteArray data = ok ? reply->readAll() : QByteArray{}; - reply->deleteLater(); - if (!ok || data.isEmpty()) { - if (fallbackIndex < 2) { - requestDay(date.addDays(-1), fallbackIndex + 1); - } else { - m_loading = false; - if (!loadCache()) { - emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true); - } - } - return; - } - handleDownload(data, date, fallbackIndex); - }); -} - -void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &date, - int fallbackIndex) -{ - const QByteArray rinex = gunzip(compressed); - QString error; - const QVector positions = parseGpsNavigation( - rinex, QDateTime::currentDateTimeUtc(), &error); - if (positions.isEmpty()) { - if (fallbackIndex < 2) { - requestDay(date.addDays(-1), fallbackIndex + 1); - return; - } - m_loading = false; - if (!loadCache()) { - emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); - } - return; - } - - QFile cache(cachePath()); - QDir().mkpath(QFileInfo(cache).absolutePath()); - if (cache.open(QIODevice::WriteOnly)) cache.write(rinex); - m_latestRinex = rinex; - m_loading = false; - publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 (UTC)") - .arg(positions.size()).arg(date.toString(QStringLiteral("dd.MM.yyyy"))), false); -} - -bool RinexNavigation::loadCache() -{ - QFile cache(cachePath()); - if (!cache.open(QIODevice::ReadOnly)) return false; - QString error; - const QByteArray cachedRinex = cache.readAll(); - const QVector positions = parseGpsNavigation( - cachedRinex, QDateTime::currentDateTimeUtc(), &error); - if (positions.isEmpty()) return false; - m_latestRinex = cachedRinex; - publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX").arg(positions.size()), false); - return true; -} - -QString RinexNavigation::cachePath() const -{ - return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) - + QStringLiteral("/latest-navigation.rnx"); -} - -void RinexNavigation::updateCurrentPositions() -{ - if (m_latestRinex.isEmpty()) return; - publishCurrentMotion(); -} - -void RinexNavigation::publishCurrentMotion() -{ - if (m_latestRinex.isEmpty()) return; - - const QDateTime now = QDateTime::currentDateTimeUtc(); - const QVector current = parseGpsNavigation(m_latestRinex, now); - if (current.isEmpty()) return; - - const QVector next = parseGpsNavigation( - m_latestRinex, now.addMSecs(PositionUpdateIntervalMs)); - emit satellitesUpdated(current); - if (!next.isEmpty()) { - emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs); - } -} - QVector RinexNavigation::parseGpsNavigation( const QByteArray &rinex, const QDateTime &whenUtc, QString *error) { - const QString text = QString::fromLatin1(rinex); - const QStringList lines = text.split('\n'); - int index = 0; - while (index < lines.size() && !lines[index].contains(QStringLiteral("END OF HEADER"))) ++index; - if (index >= lines.size()) { - if (error) *error = QStringLiteral("END OF HEADER bulunamadi"); - return {}; - } - ++index; - - QHash closest; - QHash closestAge; - while (index < lines.size()) { - const QString first = lines[index]; - if (first.size() < 3) { ++index; continue; } - const QChar system = first[0]; - if (!system.isLetter()) { ++index; continue; } - const int blockLines = (system == QLatin1Char('R') || system == QLatin1Char('S')) ? 4 : 8; - if (index + blockLines > lines.size()) break; - if (system != QLatin1Char('G')) { index += blockLines; continue; } - - const QStringList epoch = first.mid(3, 20).simplified().split(' '); - if (epoch.size() < 6) { index += 8; continue; } - bool dateOk = false; - const int year = epoch[0].toInt(&dateOk); - const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()), - QTime(epoch[3].toInt(), epoch[4].toInt(), - static_cast(epoch[5].toDouble())), - QTimeZone::fromSecondsAheadOfUtc(0)); - if (!dateOk || !toc.isValid()) { index += 8; continue; } - - Ephemeris eph; - eph.prn = first.left(3).trimmed().toUpper(); - eph.toc = toc; - eph.crs = parseField(lines[index + 1], 1); - eph.deltaN = parseField(lines[index + 1], 2); - eph.m0 = parseField(lines[index + 1], 3); - eph.cuc = parseField(lines[index + 2], 0); - eph.eccentricity = parseField(lines[index + 2], 1); - eph.cus = parseField(lines[index + 2], 2); - eph.sqrtA = parseField(lines[index + 2], 3); - eph.toe = parseField(lines[index + 3], 0); - eph.cic = parseField(lines[index + 3], 1); - eph.omega0 = parseField(lines[index + 3], 2); - eph.cis = parseField(lines[index + 3], 3); - eph.i0 = parseField(lines[index + 4], 0); - eph.crc = parseField(lines[index + 4], 1); - eph.omega = parseField(lines[index + 4], 2); - eph.omegaDot = parseField(lines[index + 4], 3); - eph.idot = parseField(lines[index + 5], 0); - eph.gpsWeek = static_cast(std::llround(parseField(lines[index + 5], 2))); - const double health = parseField(lines[index + 6], 1); - eph.healthy = !std::isfinite(health) || std::llround(health) == 0; - - if (finiteFields({eph.crs, eph.deltaN, eph.m0, eph.cuc, eph.eccentricity, - eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis, - eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot}) - && eph.sqrtA > 4000.0 && eph.gpsWeek > 0) { - const qint64 age = std::abs(toc.secsTo(whenUtc)); - if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { - closest[eph.prn] = eph; - closestAge[eph.prn] = age; - } - } - index += 8; - } - - QVector result; - result.reserve(closest.size()); - for (const Ephemeris &eph : closest) result.push_back(propagate(eph, whenUtc)); - std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); - if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi"); - return result; + const QVector ephemerides = parseEphemerides(rinex, error); + return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc); } diff --git a/src/RinexNavigation.h b/src/RinexNavigation.h index 7d331d2..4b99f4d 100644 --- a/src/RinexNavigation.h +++ b/src/RinexNavigation.h @@ -26,17 +26,49 @@ signals: const QVector &next, int durationMs); void statusChanged(const QString &status, bool error); + void loadingChanged(bool loading); private: + struct Ephemeris { + QString prn; + QDateTime toc; + double crs = 0.0; + double deltaN = 0.0; + double m0 = 0.0; + double cuc = 0.0; + double eccentricity = 0.0; + double cus = 0.0; + double sqrtA = 0.0; + double toe = 0.0; + double cic = 0.0; + double omega0 = 0.0; + double cis = 0.0; + double i0 = 0.0; + double crc = 0.0; + double omega = 0.0; + double omegaDot = 0.0; + double idot = 0.0; + int gpsWeek = 0; + bool healthy = true; + }; + void requestDay(const QDate &date, int fallbackIndex); void handleDownload(const QByteArray &compressed, const QDate &date, int fallbackIndex); bool loadCache(); QString cachePath() const; void updateCurrentPositions(); void publishCurrentMotion(); + void setLoading(bool loading); + static QVector parseEphemerides( + const QByteArray &rinex, QString *error = nullptr); + static QVector selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc); + static QVector propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc); + static SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc); QNetworkAccessManager m_network; QTimer m_positionTimer; - QByteArray m_latestRinex; + QVector m_ephemerides; bool m_loading = false; }; diff --git a/src/TelemetryPoint.h b/src/TelemetryPoint.h index 437683f..fa50479 100644 --- a/src/TelemetryPoint.h +++ b/src/TelemetryPoint.h @@ -12,6 +12,12 @@ enum class LocationSource { Trajectory }; +enum class FusionMode { + Unknown, + GpsAided, + DeadReckoning +}; + inline QString locationSourceText(LocationSource source) { switch (source) { @@ -43,6 +49,8 @@ struct DopValues { struct TelemetryPoint { LocationSource source = LocationSource::Generic; + FusionMode fusionMode = FusionMode::Unknown; + std::optional gpsAgeMs; double latitude = 0.0; double longitude = 0.0; QString latitudeText; @@ -56,6 +64,8 @@ struct TelemetryPoint { struct ParsedTelemetry { LocationSource source = LocationSource::Generic; + FusionMode fusionMode = FusionMode::Unknown; + std::optional gpsAgeMs; std::optional latitude; std::optional longitude; QString latitudeText; diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index 4592436..eb5d40f 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -1,4 +1,5 @@ #include "../src/MainWindow.h" +#include "../src/GlobeWidget.h" #include "../src/MapWidget.h" #include @@ -54,6 +55,8 @@ int main(int argc, char **argv) auto *showRecentPoints = window.findChild(QStringLiteral("showRecentPointsCheck")); auto *showAverage = window.findChild(QStringLiteral("showAverageCheck")); auto *portSelector = window.findChild(QStringLiteral("portSelector")); + auto *refreshRinexButton = + window.findChild(QStringLiteral("refreshRinexButton")); auto *map = window.findChild(); auto *mainViewTabs = window.findChild(QStringLiteral("mainViewTabs")); auto *sideSplitter = window.findChild(QStringLiteral("sideSplitter")); @@ -76,6 +79,9 @@ int main(int argc, char **argv) assert(showRecentPoints != nullptr); assert(showAverage != nullptr); assert(portSelector != nullptr); + assert(refreshRinexButton != nullptr); + assert(refreshRinexButton->text() == QStringLiteral("RINEX Guncelle") + || refreshRinexButton->text() == QStringLiteral("RINEX indiriliyor...")); assert(portSelector->isEditable()); assert(portSelector->count() >= 1); assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart")); @@ -115,6 +121,15 @@ int main(int argc, char **argv) assert(std::abs(map->centerCoordinate().y() - 29.5) < 0.000001); map->clearPoints(); + sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prns=[3,8]")); + assert(map != nullptr); + auto *globe = window.findChild(); + assert(globe != nullptr); + assert(globe->observedPrns() + == QSet({QStringLiteral("G03"), QStringLiteral("G08")})); + sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prns=[14]")); + assert(globe->observedPrns() == QSet({QStringLiteral("G14")})); + assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, Q_ARG(double, 40.25), Q_ARG(double, 29.5))); assert(referenceLatInput->text() == QStringLiteral("40.250000000000000")); @@ -236,6 +251,27 @@ int main(int argc, char **argv) assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); maxPointsInput->setValue(10); + { + MapWidget followMap; + TelemetryPoint corrected; + corrected.source = LocationSource::ImuCorrected; + corrected.latitude = 40.1; + corrected.longitude = 29.1; + corrected.receivedAt = QDateTime::currentDateTimeUtc(); + + TelemetryPoint rawGps; + rawGps.source = LocationSource::Gps; + rawGps.latitude = 41.0; + rawGps.longitude = 30.0; + rawGps.receivedAt = corrected.receivedAt.addMSecs(100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(40.1, 29.1)); + + rawGps.receivedAt = corrected.receivedAt.addMSecs(1100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(41.0, 30.0)); + } + const QByteArray screenshotPath = qgetenv("LOCATION_PLOTTER_TEST_SCREENSHOT"); if (!screenshotPath.isEmpty()) { sendLine(window, QStringLiteral("NAV solve start sats:5")); diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index 1b701af..c9d1a1a 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -9,9 +9,39 @@ namespace { -bool closeTo(double left, double right) +bool closeTo(double left, double right, double tolerance = 0.000001) { - return std::fabs(left - right) < 0.000001; + return std::fabs(left - right) < tolerance; +} + +QString rinexField(double value) +{ + return QStringLiteral("%1").arg(value, 19, 'E', 12); +} + +QString rinexContinuation(std::initializer_list values) +{ + QString line(4, QLatin1Char(' ')); + for (double value : values) line += rinexField(value); + return line + QLatin1Char('\n'); +} + +QByteArray syntheticGpsNavigation(const QDateTime &gpsEpochLabel, int gpsWeek, double toe) +{ + QString rinex = QStringLiteral( + " 3.05 N: GNSS NAV DATA M: MIXED RINEX VERSION / TYPE\n" + " END OF HEADER\n"); + rinex += QStringLiteral("G01 %1").arg( + gpsEpochLabel.toString(QStringLiteral("yyyy MM dd HH mm ss"))); + rinex += rinexField(0.0) + rinexField(0.0) + rinexField(0.0) + QLatin1Char('\n'); + rinex += rinexContinuation({1.0, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 5153.7954775}); + rinex += rinexContinuation({toe, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, static_cast(gpsWeek), 0.0}); + rinex += rinexContinuation({2.0, 0.0, 0.0, 1.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0}); + return rinex.toLatin1(); } } // namespace @@ -181,11 +211,14 @@ int main(int argc, char **argv) { const auto parsed = LocationParser::parseLine(QStringLiteral( - "[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 " + "[LOCATION] source=imu_corrected mode=dead_reckoning " + "lat=40.7356 lon=31.5985 height=812.7 gps_age_ms=3180 " "gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5")); assert(parsed.has_value()); assert(parsed->hasLocation()); assert(parsed->source == LocationSource::ImuCorrected); + assert(parsed->fusionMode == FusionMode::DeadReckoning); + assert(parsed->gpsAgeMs == 3180); assert(parsed->satellites == 6); assert(closeTo(*parsed->dop.hdop, 2.7)); } @@ -207,6 +240,25 @@ int main(int argc, char **argv) assert(parsed->satellites == 4); } + { + constexpr int gpsWeek = 2428; + constexpr double toe = 403200.0; + const QDateTime gpsEpoch(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); + const QDateTime gpsLabel = gpsEpoch.addSecs( + static_cast(gpsWeek * 604800.0 + toe)); + const QDateTime matchingUtc = gpsLabel.addSecs(-18); + QString error; + const auto satellites = RinexNavigation::parseGpsNavigation( + syntheticGpsNavigation(gpsLabel, gpsWeek, toe), matchingUtc, &error); + assert(error.isEmpty()); + assert(satellites.size() == 1); + const double semiMajorAxis = 5153.7954775 * 5153.7954775; + const double node = -7.2921151467e-5 * toe; + assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1)); + assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1)); + assert(closeTo(satellites[0].z, 0.0, 0.1)); + } + const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX"); if (!rinexPath.isEmpty()) { QFile file(rinexPath);