Organize Linux and macOS as separate applications
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
name: Ubuntu build
|
||||
name: Platform builds
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
linux:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, ubuntu-24.04]
|
||||
@@ -21,10 +21,29 @@ jobs:
|
||||
sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev
|
||||
|
||||
- name: Configure
|
||||
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
run: cmake -S linux -B build/linux -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --parallel 2
|
||||
run: cmake --build build/linux --parallel 2
|
||||
|
||||
- name: Test
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
run: ctest --test-dir build/linux --output-on-failure
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install cmake qt
|
||||
|
||||
- name: Configure
|
||||
run: cmake -S macos -B build/macos -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$(brew --prefix qt)"
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build/macos --parallel 2
|
||||
|
||||
- name: Test
|
||||
run: ctest --test-dir build/macos --output-on-failure
|
||||
|
||||
@@ -1,283 +1,70 @@
|
||||
# Location Plotter
|
||||
|
||||
Qt6/C++ GUI that listens to a USB UART on Linux or macOS, parses normal debug lines,
|
||||
and plots live locations on a map.
|
||||
|
||||
The application also includes an interactive 3D Earth/orbit view. It downloads the current
|
||||
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
|
||||
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
|
||||
file is cached for offline startup.
|
||||
|
||||
## Parsed fields
|
||||
|
||||
The parser accepts labeled values in the same line or in separate lines:
|
||||
Bu `main` dali, isletim sistemi surumlerini birbirinden bagimsiz uygulama
|
||||
agaclari olarak tutar:
|
||||
|
||||
```text
|
||||
lat=41.0082 long=28.9784 hheight=123.45 sats=7
|
||||
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7
|
||||
active_sats=8
|
||||
navigation software running with 5 satellites
|
||||
GDOP : 3.9576744550
|
||||
PDOP : 3.2540598660
|
||||
HDOP : 2.3631161789
|
||||
VDOP : 2.2370935468
|
||||
TDOP : 2.2526165852
|
||||
tracking PRNs: G03, G08, 14, 21
|
||||
SV=G27
|
||||
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4
|
||||
lattitude : 40.78925034214206846173
|
||||
longitude : 29.46277530841727809729
|
||||
height : -89.05779508221894502640
|
||||
location_plotter/
|
||||
├── linux/ # origin/tdk_ekf tabanli Linux surumu
|
||||
│ ├── src/
|
||||
│ ├── tests/
|
||||
│ ├── resources/
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── run.sh
|
||||
│ └── setup-ubuntu.sh
|
||||
├── macos/ # origin/macos tabanli macOS surumu
|
||||
│ ├── src/
|
||||
│ ├── tests/
|
||||
│ ├── resources/
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── run.sh
|
||||
│ └── setup-macos.sh
|
||||
└── .github/workflows/ # iki surumun CI tanimlari
|
||||
```
|
||||
|
||||
Accepted location labels:
|
||||
Windows surumu geldiginde ayni seviyede `windows/` klasoru acilmalidir.
|
||||
Bu commit Windows kodu veya bos bir Windows iskeleti eklemez.
|
||||
|
||||
- Latitude: `lat`, `latitude`, `lattitude`
|
||||
- Longitude: `long`, `longitude`, `lon`, `lng`
|
||||
- Height: `hheight`, `height`, `alt`, `altitude`
|
||||
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
|
||||
`activeSats`, `satellites_active`, `num_sats`, `uydu`
|
||||
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
|
||||
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
|
||||
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
|
||||
- `tracked` is accepted as the active satellite count; `used` remains visible in
|
||||
the raw UART line and denotes satellites used by the WLS solution.
|
||||
## Surumler arasindaki farklar
|
||||
|
||||
If a satellite count arrives without a location, the active satellite display is updated.
|
||||
If a location arrives without a satellite count, the latest known satellite count is attached
|
||||
to that point.
|
||||
If DOP values arrive before a location, the latest known values stay visible in the DOP
|
||||
section and are attached to the next plotted point.
|
||||
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
|
||||
into a single plotted point.
|
||||
Linux ve macOS kodlari bilincli olarak ayri tutulur. Ortak duzeltmeler iki
|
||||
klasore de ayri commitlerle uygulanmalidir; bir klasordeki degisiklik digerini
|
||||
otomatik olarak etkilemez.
|
||||
|
||||
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 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. 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.
|
||||
- `linux/`, `tdk_ekf` dalindaki Linux uygulamasidir.
|
||||
- `macos/`, macOS'a ozel menuler, kalici pencere/seri-port ayarlari, otomatik
|
||||
USB UART yenileme, gelismis harita cizimi ve Blue Marble dunya dokusunu
|
||||
iceren daha genis GUI varyantidir.
|
||||
- Seri port adlari farklidir: Linux `/dev/ttyUSB*` ve `/dev/ttyACM*`, macOS
|
||||
`/dev/cu.usb*` ve `/dev/tty.usb*` yollarini kullanir.
|
||||
- macOS ozel baud-rate ayari `IOSSIOSPEED` ile yapilir. Linux standart
|
||||
`termios` baud sabitlerini kullanir.
|
||||
- macOS uygulamasi `.app` bundle olarak paketlenir; Linux normal executable
|
||||
uretir ve seri port izni icin `dialout` grubunu kullanir.
|
||||
- macOS varyantinda ek `map_widget_tests` testi ve yerel Blue Marble kaynagi
|
||||
vardir; Linux varyanti daha kucuk kaynak/test setini korur.
|
||||
|
||||
## UART log replay
|
||||
Bu ayrim, iki surumun bagimsiz gelistirilmesini kolaylastirir; karsiliginda
|
||||
parser, RINEX veya replay gibi ortak bir hata duzeltmesi iki klasorde de
|
||||
uygulanip iki test paketiyle dogrulanmalidir.
|
||||
|
||||
Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded
|
||||
UART `.log`/`.txt` file back through the same parser used by the live serial port.
|
||||
Choose the raw GPS update interval (default `200 ms`) and sensor/fused update
|
||||
interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop.
|
||||
## Calistirma
|
||||
|
||||
Recorder timestamps are optional and do not control replay speed. The replayer
|
||||
removes a leading timestamp when present, identifies `source=gps` as raw GPS and
|
||||
`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the
|
||||
two update timelines while retaining the original UART line order. Other UART
|
||||
diagnostic lines are forwarded with the surrounding location update.
|
||||
|
||||
## Sentetik ESKF senaryolarini açma
|
||||
|
||||
GPS deposundan 48 kombinasyonun replay loglarini üretin:
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps
|
||||
make -C soc/software/fusion/tests scenario-logs
|
||||
```
|
||||
|
||||
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
|
||||
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
|
||||
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
|
||||
doğrudan açılabilir:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/location_plotter
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
./build/location_plotter --replay \
|
||||
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
|
||||
```
|
||||
|
||||
Tek bir özel kombinasyon üretmek için:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps/soc/software/fusion/tests
|
||||
make platform_scenario_matrix_test
|
||||
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
|
||||
```
|
||||
|
||||
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
|
||||
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
|
||||
|
||||
## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel)
|
||||
|
||||
Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan:
|
||||
|
||||
```bash
|
||||
cd location_plotter
|
||||
chmod +x setup-macos.sh run.sh
|
||||
./setup-macos.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin
|
||||
`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine
|
||||
ekler ve testleri calistirir.
|
||||
|
||||
macOS USB UART aygitlarini genellikle iki adla gosterir:
|
||||
|
||||
```text
|
||||
/dev/cu.usbserial-XXXXXXXX
|
||||
/dev/tty.usbserial-XXXXXXXX
|
||||
```
|
||||
|
||||
veya bir CDC aygitinda:
|
||||
|
||||
```text
|
||||
/dev/cu.usbmodemXXXX
|
||||
/dev/tty.usbmodemXXXX
|
||||
```
|
||||
|
||||
Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta
|
||||
disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama
|
||||
acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik
|
||||
algilanmayan bir yol port alanina elle de yazilabilir.
|
||||
|
||||
Istenen port baslatmadan once ortam degiskeniyle de secilebilir:
|
||||
|
||||
```bash
|
||||
LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh
|
||||
```
|
||||
|
||||
Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup
|
||||
olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART
|
||||
donaniminizin macOS surucusunu kontrol edin.
|
||||
|
||||
## Ubuntu'da kurulum
|
||||
|
||||
Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve
|
||||
testleri calistirmak icin:
|
||||
|
||||
```bash
|
||||
git clone <depo-adresi> location_plotter
|
||||
cd location_plotter
|
||||
cd linux
|
||||
./setup-ubuntu.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur,
|
||||
testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler.
|
||||
Kurulum sirasinda `sudo` parolasi istenebilir.
|
||||
|
||||
Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir.
|
||||
|
||||
## Elle derleme
|
||||
|
||||
Gerekli paketler:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake qt6-base-dev zlib1g-dev
|
||||
```
|
||||
|
||||
Derleme ve test:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Run:
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
cd macos
|
||||
./setup-macos.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede
|
||||
calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir,
|
||||
ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir.
|
||||
|
||||
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. 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:
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G dialout "$USER"
|
||||
```
|
||||
|
||||
Log out and back in after changing group membership.
|
||||
|
||||
If the user is already listed in the `dialout` group but the current shell has not picked it
|
||||
up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2`
|
||||
without logging out.
|
||||
|
||||
## Notes
|
||||
|
||||
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
|
||||
points are still drawn over a fallback grid.
|
||||
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
|
||||
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
|
||||
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
|
||||
Drag to rotate the globe and use the mouse wheel to zoom.
|
||||
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
|
||||
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
|
||||
(10 degrees by default, press Enter after typing);
|
||||
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
|
||||
receiver marker without resetting a globe orientation chosen by dragging.
|
||||
- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble
|
||||
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 10 FPS.
|
||||
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
|
||||
line shows the system-local time of the last successful download or cache load.
|
||||
- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the
|
||||
previous connected-PRN set, so satellites that were lost no longer remain green. Firmware
|
||||
channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`).
|
||||
- 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
|
||||
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
|
||||
be enlarged substantially.
|
||||
- `Max` controls how many recent points stay visible and are included in location
|
||||
analysis. The default is 10. When the limit is reached, older points are removed
|
||||
automatically.
|
||||
- Location analysis shows the average of the plotted samples, the last point's deviation
|
||||
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
|
||||
marker can be toggled on the map.
|
||||
- The map layers panel can hide all telemetry overlays, show only the newest location instead
|
||||
of the recent path, and independently toggle the average marker.
|
||||
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
|
||||
path are red. Synthetic ground-truth trajectory is green; the locations table
|
||||
shows the parsed source in its `Kaynak` column.
|
||||
- The application calculates empirical CEP50 as the median horizontal distance of the retained
|
||||
measurements from their spherical mean, then draws the circle around that measurement center.
|
||||
This is the radius containing 50 percent of the retained position fixes and is independent of
|
||||
the optional user reference.
|
||||
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
|
||||
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
|
||||
- Latitude and longitude tokens are preserved exactly as received for the table and map
|
||||
label. Calculations and map projection use IEEE 754 `double` precision.
|
||||
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
|
||||
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
|
||||
but the background tiles cannot add accuracy to the GNSS measurement.
|
||||
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
|
||||
cache in the application's standard cache directory.
|
||||
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
|
||||
rebuild the complete interface for every received line. The locations table uses a virtual
|
||||
model and remains usable with a high `Max` value.
|
||||
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
|
||||
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
|
||||
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
|
||||
- If no new plotted location arrives for 10 seconds while listening, current location
|
||||
points and analysis values are cleared.
|
||||
Ayrintili kullanim ve telemetri formati ilgili klasorun `README.md` dosyasinda
|
||||
yer alir.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(location_plotter VERSION 0.3.0 LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
message(FATAL_ERROR "linux/ yalnizca Linux icin yapilandirilabilir")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network)
|
||||
find_package(ZLIB REQUIRED)
|
||||
|
||||
add_executable(location_plotter
|
||||
src/LocationParser.cpp
|
||||
src/LocationParser.h
|
||||
src/GlobeWidget.cpp
|
||||
src/GlobeWidget.h
|
||||
src/MainWindow.cpp
|
||||
src/MainWindow.h
|
||||
src/MapWidget.cpp
|
||||
src/MapWidget.h
|
||||
src/SerialReader.cpp
|
||||
src/SerialReader.h
|
||||
src/UartReplay.cpp
|
||||
src/UartReplay.h
|
||||
src/RinexNavigation.cpp
|
||||
src/RinexNavigation.h
|
||||
src/SatelliteData.h
|
||||
src/TelemetryPoint.h
|
||||
src/main.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
|
||||
target_compile_definitions(location_plotter PRIVATE
|
||||
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
)
|
||||
|
||||
qt_add_resources(location_plotter "app_resources"
|
||||
PREFIX "/"
|
||||
FILES resources/ankasys-logo.png resources/land-110m.json
|
||||
)
|
||||
|
||||
target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
||||
|
||||
enable_testing()
|
||||
|
||||
add_executable(location_parser_tests
|
||||
tests/parser_tests.cpp
|
||||
src/LocationParser.cpp
|
||||
src/LocationParser.h
|
||||
src/RinexNavigation.cpp
|
||||
src/RinexNavigation.h
|
||||
src/SatelliteData.h
|
||||
src/TelemetryPoint.h
|
||||
)
|
||||
target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB)
|
||||
target_compile_options(location_parser_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
|
||||
)
|
||||
add_test(NAME location_parser_tests COMMAND location_parser_tests)
|
||||
|
||||
add_executable(location_main_window_tests
|
||||
tests/main_window_tests.cpp
|
||||
src/LocationParser.cpp
|
||||
src/LocationParser.h
|
||||
src/MainWindow.cpp
|
||||
src/MainWindow.h
|
||||
src/MapWidget.cpp
|
||||
src/MapWidget.h
|
||||
src/GlobeWidget.cpp
|
||||
src/GlobeWidget.h
|
||||
src/SerialReader.cpp
|
||||
src/SerialReader.h
|
||||
src/UartReplay.cpp
|
||||
src/UartReplay.h
|
||||
src/RinexNavigation.cpp
|
||||
src/RinexNavigation.h
|
||||
src/SatelliteData.h
|
||||
src/TelemetryPoint.h
|
||||
)
|
||||
target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
|
||||
target_compile_definitions(location_main_window_tests PRIVATE
|
||||
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
)
|
||||
target_compile_options(location_main_window_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
|
||||
)
|
||||
qt_add_resources(location_main_window_tests "test_app_resources"
|
||||
PREFIX "/"
|
||||
FILES resources/ankasys-logo.png resources/land-110m.json
|
||||
)
|
||||
target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
||||
add_test(NAME location_main_window_tests COMMAND location_main_window_tests)
|
||||
set_tests_properties(location_main_window_tests PROPERTIES
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache")
|
||||
|
||||
add_executable(location_uart_replay_tests
|
||||
tests/uart_replay_tests.cpp
|
||||
src/UartReplay.cpp
|
||||
src/UartReplay.h
|
||||
src/LocationParser.cpp
|
||||
src/LocationParser.h
|
||||
src/TelemetryPoint.h
|
||||
)
|
||||
target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core)
|
||||
target_compile_options(location_uart_replay_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
|
||||
)
|
||||
add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests)
|
||||
|
||||
install(TARGETS location_plotter
|
||||
BUNDLE DESTINATION .
|
||||
RUNTIME DESTINATION bin
|
||||
)
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
# Location Plotter
|
||||
|
||||
Qt6/C++ GUI that listens to a USB UART on Linux, parses normal debug lines,
|
||||
and plots live locations on a map.
|
||||
|
||||
The application also includes an interactive 3D Earth/orbit view. It downloads the current
|
||||
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
|
||||
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
|
||||
file is cached for offline startup.
|
||||
|
||||
## Parsed fields
|
||||
|
||||
The parser accepts labeled values in the same line or in separate lines:
|
||||
|
||||
```text
|
||||
lat=41.0082 long=28.9784 hheight=123.45 sats=7
|
||||
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7
|
||||
active_sats=8
|
||||
navigation software running with 5 satellites
|
||||
GDOP : 3.9576744550
|
||||
PDOP : 3.2540598660
|
||||
HDOP : 2.3631161789
|
||||
VDOP : 2.2370935468
|
||||
TDOP : 2.2526165852
|
||||
tracking PRNs: G03, G08, 14, 21
|
||||
SV=G27
|
||||
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4
|
||||
lattitude : 40.78925034214206846173
|
||||
longitude : 29.46277530841727809729
|
||||
height : -89.05779508221894502640
|
||||
```
|
||||
|
||||
Accepted location labels:
|
||||
|
||||
- Latitude: `lat`, `latitude`, `lattitude`
|
||||
- Longitude: `long`, `longitude`, `lon`, `lng`
|
||||
- Height: `hheight`, `height`, `alt`, `altitude`
|
||||
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
|
||||
`activeSats`, `satellites_active`, `num_sats`, `uydu`
|
||||
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
|
||||
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
|
||||
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
|
||||
- `tracked` is accepted as the active satellite count; `used` remains visible in
|
||||
the raw UART line and denotes satellites used by the WLS solution.
|
||||
|
||||
If a satellite count arrives without a location, the active satellite display is updated.
|
||||
If a location arrives without a satellite count, the latest known satellite count is attached
|
||||
to that point.
|
||||
If DOP values arrive before a location, the latest known values stay visible in the DOP
|
||||
section and are attached to the next plotted point.
|
||||
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
|
||||
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 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. 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.
|
||||
|
||||
## UART log replay
|
||||
|
||||
Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded
|
||||
UART `.log`/`.txt` file back through the same parser used by the live serial port.
|
||||
Choose the raw GPS update interval (default `200 ms`) and sensor/fused update
|
||||
interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop.
|
||||
|
||||
Recorder timestamps are optional and do not control replay speed. The replayer
|
||||
removes a leading timestamp when present, identifies `source=gps` as raw GPS and
|
||||
`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the
|
||||
two update timelines while retaining the original UART line order. Other UART
|
||||
diagnostic lines are forwarded with the surrounding location update.
|
||||
|
||||
## Sentetik ESKF senaryolarini açma
|
||||
|
||||
GPS deposundan 48 kombinasyonun replay loglarini üretin:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps
|
||||
make -C soc/software/fusion/tests scenario-logs
|
||||
```
|
||||
|
||||
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
|
||||
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
|
||||
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
|
||||
doğrudan açılabilir:
|
||||
|
||||
```bash
|
||||
cd location_plotter/linux
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
./build/location_plotter --replay \
|
||||
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
|
||||
```
|
||||
|
||||
Tek bir özel kombinasyon üretmek için:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps/soc/software/fusion/tests
|
||||
make platform_scenario_matrix_test
|
||||
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
|
||||
```
|
||||
|
||||
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
|
||||
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
|
||||
|
||||
## Ubuntu'da kurulum
|
||||
|
||||
Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve
|
||||
testleri calistirmak icin:
|
||||
|
||||
```bash
|
||||
git clone <depo-adresi> location_plotter
|
||||
cd location_plotter/linux
|
||||
./setup-ubuntu.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur,
|
||||
testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler.
|
||||
Kurulum sirasinda `sudo` parolasi istenebilir.
|
||||
|
||||
Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir.
|
||||
|
||||
## Elle derleme
|
||||
|
||||
Gerekli paketler:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake qt6-base-dev zlib1g-dev
|
||||
```
|
||||
|
||||
Derleme ve test:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede
|
||||
calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir,
|
||||
ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir.
|
||||
|
||||
Port secici `/dev/ttyUSB*` ve `/dev/ttyACM*` aygitlarini listeler. Hic aygit
|
||||
algilanmazsa `/dev/ttyUSB2` duzenlenebilir bir varsayilan yol olarak 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:
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G dialout "$USER"
|
||||
```
|
||||
|
||||
Log out and back in after changing group membership.
|
||||
|
||||
If the user is already listed in the `dialout` group but the current shell has not picked it
|
||||
up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2`
|
||||
without logging out.
|
||||
|
||||
## Notes
|
||||
|
||||
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
|
||||
points are still drawn over a fallback grid.
|
||||
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
|
||||
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
|
||||
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
|
||||
Drag to rotate the globe and use the mouse wheel to zoom.
|
||||
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
|
||||
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
|
||||
(10 degrees by default, press Enter after typing);
|
||||
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
|
||||
receiver marker without resetting a globe orientation chosen by dragging.
|
||||
- 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 10 FPS.
|
||||
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
|
||||
line shows the system-local time of the last successful download or cache load.
|
||||
- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the
|
||||
previous connected-PRN set, so satellites that were lost no longer remain green. Firmware
|
||||
channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`).
|
||||
- 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
|
||||
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
|
||||
be enlarged substantially.
|
||||
- `Max` controls how many recent points stay visible and are included in location
|
||||
analysis. The default is 10. When the limit is reached, older points are removed
|
||||
automatically.
|
||||
- Location analysis shows the average of the plotted samples, the last point's deviation
|
||||
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
|
||||
marker can be toggled on the map.
|
||||
- The map layers panel can hide all telemetry overlays, show only the newest location instead
|
||||
of the recent path, and independently toggle the average marker.
|
||||
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
|
||||
path are red. Synthetic ground-truth trajectory is green; the locations table
|
||||
shows the parsed source in its `Kaynak` column.
|
||||
- The application calculates empirical CEP50 as the median horizontal distance of the retained
|
||||
measurements from their spherical mean, then draws the circle around that measurement center.
|
||||
This is the radius containing 50 percent of the retained position fixes and is independent of
|
||||
the optional user reference.
|
||||
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
|
||||
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
|
||||
- Latitude and longitude tokens are preserved exactly as received for the table and map
|
||||
label. Calculations and map projection use IEEE 754 `double` precision.
|
||||
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
|
||||
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
|
||||
but the background tiles cannot add accuracy to the GNSS measurement.
|
||||
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
|
||||
cache in the application's standard cache directory.
|
||||
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
|
||||
rebuild the complete interface for every received line. The locations table uses a virtual
|
||||
model and remains usable with a high `Max` value.
|
||||
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
|
||||
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
|
||||
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
|
||||
- If no new plotted location arrives for 10 seconds while listening, current location
|
||||
points and analysis values are cleared.
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
print_qt_install_hint() {
|
||||
echo
|
||||
echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:"
|
||||
echo " sudo apt update"
|
||||
echo " sudo apt install build-essential cmake qt6-base-dev zlib1g-dev"
|
||||
echo "veya dogrudan: ./setup-ubuntu.sh"
|
||||
}
|
||||
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo "Bu calistirma betigi Linux icindir." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_dir="$(pwd -P)"
|
||||
|
||||
if [[ -f ./build/CMakeCache.txt ]]; then
|
||||
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
|
||||
if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then
|
||||
echo "Build klasoru farkli bir proje yoluna ait. 'linux/build' klasorunu silip tekrar deneyin." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! cmake -S . -B build; then
|
||||
print_qt_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build build
|
||||
|
||||
missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)"
|
||||
if [[ -n "$missing_libs" ]]; then
|
||||
echo "Eksik paylasimli kutuphaneler:"
|
||||
echo "$missing_libs" | sed 's/^/ /'
|
||||
print_qt_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if id -nG | tr ' ' '\n' | grep -qx dialout; then
|
||||
exec ./build/location_plotter "$@"
|
||||
fi
|
||||
|
||||
if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then
|
||||
exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")"
|
||||
fi
|
||||
|
||||
echo "Bu kullanici dialout grubunda degil. Eklemek icin:"
|
||||
echo " sudo usermod -a -G dialout $(id -un)"
|
||||
echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir."
|
||||
exit 1
|
||||
@@ -0,0 +1,870 @@
|
||||
#include "GlobeWidget.h"
|
||||
|
||||
#include <QLinearGradient>
|
||||
#include <QHash>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPolygonF>
|
||||
#include <QRadialGradient>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
constexpr double Pi = 3.14159265358979323846;
|
||||
constexpr double InitialLongitudeOffset = 0.50;
|
||||
constexpr double InitialLatitudeOffset = 0.28;
|
||||
|
||||
struct Vec3 { double x; double y; double z; };
|
||||
|
||||
Vec3 globeVector(double latitude, double longitude)
|
||||
{
|
||||
const double lat = latitude * Pi / 180.0;
|
||||
const double lon = longitude * Pi / 180.0;
|
||||
return {std::cos(lat) * std::cos(lon), std::cos(lat) * std::sin(lon), std::sin(lat)};
|
||||
}
|
||||
|
||||
const QVector<QPolygonF> &fallbackLandPolygons()
|
||||
{
|
||||
// Deliberately simplified Natural-Earth-like coastlines. QPointF is lon/lat.
|
||||
static const QVector<QPolygonF> polygons = {
|
||||
// North America
|
||||
{{-168, 66}, {-155, 72}, {-138, 70}, {-128, 61}, {-123, 50}, {-124, 42},
|
||||
{-117, 32}, {-106, 24}, {-97, 19}, {-90, 19}, {-83, 24}, {-81, 31},
|
||||
{-75, 39}, {-66, 45}, {-60, 52}, {-64, 60}, {-78, 63}, {-89, 69},
|
||||
{-105, 73}, {-125, 72}, {-145, 75}},
|
||||
// Central America
|
||||
{{-106, 24}, {-97, 18}, {-91, 16}, {-87, 12}, {-83, 9}, {-79, 8},
|
||||
{-77, 10}, {-84, 16}, {-91, 20}},
|
||||
// South America
|
||||
{{-81, 12}, {-72, 12}, {-61, 8}, {-50, 3}, {-43, -5}, {-35, -8},
|
||||
{-39, -19}, {-48, -29}, {-53, -36}, {-60, -47}, {-67, -55},
|
||||
{-74, -50}, {-75, -38}, {-71, -28}, {-70, -18}, {-76, -7}},
|
||||
// Greenland
|
||||
{{-73, 60}, {-52, 59}, {-39, 65}, {-20, 76}, {-28, 83}, {-49, 84},
|
||||
{-65, 78}},
|
||||
// Europe and Asia
|
||||
{{-11, 36}, {-10, 44}, {-5, 50}, {4, 54}, {12, 58}, {20, 69},
|
||||
{34, 71}, {48, 77}, {72, 76}, {96, 78}, {119, 73}, {142, 71},
|
||||
{165, 66}, {178, 58}, {169, 50}, {151, 46}, {141, 38}, {128, 34},
|
||||
{121, 24}, {110, 20}, {105, 10}, {98, 7}, {91, 21}, {80, 8},
|
||||
{72, 18}, {62, 25}, {53, 27}, {45, 38}, {36, 42}, {28, 40},
|
||||
{20, 35}, {12, 36}, {4, 43}},
|
||||
// Arabian peninsula
|
||||
{{35, 31}, {48, 30}, {56, 24}, {51, 15}, {43, 12}, {36, 20}},
|
||||
// India and southeast Asia
|
||||
{{67, 25}, {76, 31}, {88, 25}, {92, 22}, {87, 8}, {80, 6},
|
||||
{74, 15}},
|
||||
{{95, 22}, {106, 21}, {110, 12}, {105, 1}, {100, 7}},
|
||||
// Africa
|
||||
{{-17, 37}, {-5, 37}, {10, 35}, {25, 31}, {34, 22}, {43, 12},
|
||||
{51, 11}, {48, 2}, {42, -12}, {33, -26}, {25, -35}, {17, -34},
|
||||
{10, -25}, {2, -9}, {-8, 5}, {-16, 15}},
|
||||
// Australia
|
||||
{{112, -11}, {125, -10}, {138, -14}, {153, -27}, {151, -39},
|
||||
{139, -44}, {126, -34}, {114, -35}, {111, -23}},
|
||||
// Madagascar
|
||||
{{47, -13}, {51, -17}, {49, -27}, {44, -25}, {43, -18}},
|
||||
// Japan
|
||||
{{130, 31}, {136, 34}, {142, 43}, {145, 44}, {141, 35}},
|
||||
// Great Britain
|
||||
{{-8, 50}, {-3, 58}, {1, 57}, {2, 51}},
|
||||
// New Zealand
|
||||
{{166, -34}, {174, -41}, {178, -47}, {171, -45}},
|
||||
};
|
||||
return polygons;
|
||||
}
|
||||
|
||||
const QVector<QPolygonF> &landPolygons()
|
||||
{
|
||||
static const QVector<QPolygonF> polygons = [] {
|
||||
QFile file(QStringLiteral(":/resources/land-110m.json"));
|
||||
if (!file.open(QIODevice::ReadOnly)) return fallbackLandPolygons();
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||
const QJsonObject root = document.object();
|
||||
const QJsonObject transform = root.value(QStringLiteral("transform")).toObject();
|
||||
const QJsonArray scale = transform.value(QStringLiteral("scale")).toArray();
|
||||
const QJsonArray translate = transform.value(QStringLiteral("translate")).toArray();
|
||||
if (scale.size() != 2 || translate.size() != 2) return fallbackLandPolygons();
|
||||
|
||||
const double scaleX = scale[0].toDouble();
|
||||
const double scaleY = scale[1].toDouble();
|
||||
const double translateX = translate[0].toDouble();
|
||||
const double translateY = translate[1].toDouble();
|
||||
QVector<QPolygonF> decodedArcs;
|
||||
const QJsonArray arcs = root.value(QStringLiteral("arcs")).toArray();
|
||||
decodedArcs.reserve(arcs.size());
|
||||
for (const QJsonValue &arcValue : arcs) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
QPolygonF arc;
|
||||
for (const QJsonValue &pointValue : arcValue.toArray()) {
|
||||
const QJsonArray point = pointValue.toArray();
|
||||
if (point.size() != 2) continue;
|
||||
x += point[0].toInt();
|
||||
y += point[1].toInt();
|
||||
arc.push_back(QPointF(x * scaleX + translateX,
|
||||
y * scaleY + translateY));
|
||||
}
|
||||
decodedArcs.push_back(arc);
|
||||
}
|
||||
|
||||
QVector<QPolygonF> result;
|
||||
const QJsonObject land = root.value(QStringLiteral("objects")).toObject()
|
||||
.value(QStringLiteral("land")).toObject();
|
||||
const QJsonArray geometries = land.value(QStringLiteral("geometries")).toArray();
|
||||
for (const QJsonValue &geometryValue : geometries) {
|
||||
const QJsonObject geometry = geometryValue.toObject();
|
||||
const QJsonArray multiPolygon = geometry.value(QStringLiteral("arcs")).toArray();
|
||||
for (const QJsonValue &polygonValue : multiPolygon) {
|
||||
const QJsonArray rings = polygonValue.toArray();
|
||||
if (rings.isEmpty()) continue;
|
||||
const QJsonArray ringArcs = rings[0].toArray(); // outer coastline
|
||||
QPolygonF polygon;
|
||||
for (const QJsonValue &arcIndexValue : ringArcs) {
|
||||
const int reference = arcIndexValue.toInt();
|
||||
const int index = reference >= 0 ? reference : ~reference;
|
||||
if (index < 0 || index >= decodedArcs.size()) continue;
|
||||
QPolygonF arc = decodedArcs[index];
|
||||
if (reference < 0) std::reverse(arc.begin(), arc.end());
|
||||
for (int i = polygon.isEmpty() ? 0 : 1; i < arc.size(); ++i) {
|
||||
polygon.push_back(arc[i]);
|
||||
}
|
||||
}
|
||||
if (polygon.size() >= 3) result.push_back(polygon);
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? fallbackLandPolygons() : result;
|
||||
}();
|
||||
return polygons;
|
||||
}
|
||||
|
||||
const QImage &landMask()
|
||||
{
|
||||
// A geographic mask decouples coastline quality from the size of the widget.
|
||||
// It is generated once and remains fixed in Earth coordinates while rotating.
|
||||
static const QImage mask = [] {
|
||||
constexpr int Width = 1440;
|
||||
constexpr int Height = 720;
|
||||
QImage image(Width, Height, QImage::Format_Grayscale8);
|
||||
image.fill(0);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(Qt::white);
|
||||
for (const QPolygonF &geographic : landPolygons()) {
|
||||
QPolygonF projected;
|
||||
projected.reserve(geographic.size());
|
||||
double previousLongitude = geographic.first().x();
|
||||
double unwrappedLongitude = previousLongitude;
|
||||
for (const QPointF &point : geographic) {
|
||||
double longitude = point.x();
|
||||
while (longitude - previousLongitude > 180.0) longitude -= 360.0;
|
||||
while (longitude - previousLongitude < -180.0) longitude += 360.0;
|
||||
unwrappedLongitude += longitude - previousLongitude;
|
||||
previousLongitude = longitude;
|
||||
projected.push_back(QPointF((unwrappedLongitude + 180.0) / 360.0 * Width,
|
||||
(90.0 - point.y()) / 180.0 * Height));
|
||||
}
|
||||
for (int copy = -1; copy <= 1; ++copy) {
|
||||
QPolygonF shifted = projected;
|
||||
shifted.translate(copy * Width, 0.0);
|
||||
painter.drawPolygon(shifted);
|
||||
}
|
||||
}
|
||||
painter.end();
|
||||
return image;
|
||||
}();
|
||||
return mask;
|
||||
}
|
||||
|
||||
bool maskIsLand(double longitude, double latitude)
|
||||
{
|
||||
const QImage &mask = landMask();
|
||||
longitude = std::fmod(longitude + 180.0, 360.0);
|
||||
if (longitude < 0.0) longitude += 360.0;
|
||||
const int x = std::clamp(static_cast<int>(longitude / 360.0 * mask.width()),
|
||||
0, mask.width() - 1);
|
||||
const int y = std::clamp(static_cast<int>((90.0 - latitude) / 180.0 * mask.height()),
|
||||
0, mask.height() - 1);
|
||||
return mask.constScanLine(y)[x] != 0;
|
||||
}
|
||||
|
||||
double smoothStep(double edge0, double edge1, double value)
|
||||
{
|
||||
const double t = std::clamp((value - edge0) / (edge1 - edge0), 0.0, 1.0);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
}
|
||||
|
||||
GlobeWidget::GlobeWidget(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setObjectName(QStringLiteral("globeWidget"));
|
||||
setMinimumSize(420, 360);
|
||||
setMouseTracking(true);
|
||||
setCursor(Qt::OpenHandCursor);
|
||||
setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin"));
|
||||
// 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);
|
||||
}
|
||||
|
||||
void GlobeWidget::setSatellites(const QVector<SatellitePosition> &satellites)
|
||||
{
|
||||
m_satellites = satellites;
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::setSatelliteMotion(const QVector<SatellitePosition> ¤t,
|
||||
const QVector<SatellitePosition> &next,
|
||||
int durationMs)
|
||||
{
|
||||
if (current.isEmpty() || next.isEmpty() || durationMs <= 0) {
|
||||
setSatellites(current);
|
||||
m_satelliteAnimationTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue from the currently rendered points when a fresh ephemeris arrives,
|
||||
// avoiding even a small visual jump at the update boundary.
|
||||
const QVector<SatellitePosition> &candidateStart =
|
||||
m_satellites.isEmpty() ? current : m_satellites;
|
||||
QHash<QString, SatellitePosition> 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();
|
||||
updateSatelliteAnimation();
|
||||
}
|
||||
|
||||
void GlobeWidget::updateSatelliteAnimation()
|
||||
{
|
||||
if (!m_satelliteMotionClock.isValid() || m_satelliteMotionDurationMs <= 0) return;
|
||||
|
||||
const double progress = std::clamp(
|
||||
static_cast<double>(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs,
|
||||
0.0, 1.0);
|
||||
QVector<SatellitePosition> interpolated;
|
||||
interpolated.reserve(m_satelliteMotionStart.size());
|
||||
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;
|
||||
point.z += (target.z - start.z) * progress;
|
||||
point.healthy = target.healthy;
|
||||
interpolated.push_back(point);
|
||||
}
|
||||
if (!interpolated.isEmpty()) m_satellites = interpolated;
|
||||
update();
|
||||
|
||||
if (progress >= 1.0) m_satelliteAnimationTimer.stop();
|
||||
}
|
||||
|
||||
void GlobeWidget::setObservedPrns(const QSet<QString> &prns)
|
||||
{
|
||||
m_observedPrns = prns;
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::setReceiverLocation(double latitude, double longitude)
|
||||
{
|
||||
const bool firstLocation = !m_hasReceiver;
|
||||
m_hasReceiver = true;
|
||||
m_receiverLatitude = latitude;
|
||||
m_receiverLongitude = longitude;
|
||||
if (firstLocation) {
|
||||
// Keep the receiver off-centre so its local zenith is visibly directed
|
||||
// away from Earth. Looking straight down the zenith made the outward
|
||||
// visibility cone appear to be embedded in the globe.
|
||||
m_yaw = Pi / 2.0 - longitude * Pi / 180.0 + InitialLongitudeOffset;
|
||||
m_pitch = std::clamp(-latitude * Pi / 180.0 + InitialLatitudeOffset,
|
||||
-1.15, 1.15);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::clearReceiverLocation()
|
||||
{
|
||||
m_hasReceiver = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::setRinexStatus(const QString &status, bool error)
|
||||
{
|
||||
m_status = status;
|
||||
m_statusError = error;
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::setElevationMask(double degrees)
|
||||
{
|
||||
m_elevationMaskDegrees = std::clamp(degrees, 0.0, 90.0);
|
||||
update();
|
||||
}
|
||||
|
||||
GlobeWidget::ProjectedPoint GlobeWidget::project(double x, double y, double z, double radius) const
|
||||
{
|
||||
const double cy = std::cos(m_yaw);
|
||||
const double sy = std::sin(m_yaw);
|
||||
const double x1 = cy * x - sy * y;
|
||||
const double y1 = sy * x + cy * y;
|
||||
const double cp = std::cos(m_pitch);
|
||||
const double sp = std::sin(m_pitch);
|
||||
const double y2 = cp * y1 - sp * z;
|
||||
const double z2 = sp * y1 + cp * z;
|
||||
const QPointF center(width() * 0.5, height() * 0.52);
|
||||
return {{center.x() + x1 * radius, center.y() - z2 * radius}, y2};
|
||||
}
|
||||
|
||||
void GlobeWidget::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
const qreal devicePixelRatio = devicePixelRatioF();
|
||||
if (m_staticLayer.isNull() || m_staticLayerSize != size()
|
||||
|| !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio)
|
||||
|| m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch
|
||||
|| m_staticLayerZoom != m_zoom) {
|
||||
rebuildStaticLayer();
|
||||
}
|
||||
painter.drawPixmap(0, 0, m_staticLayer);
|
||||
|
||||
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));
|
||||
background.setColorAt(1.0, QColor(2, 8, 17));
|
||||
painter.fillRect(rect(), background);
|
||||
drawStars(painter);
|
||||
|
||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
||||
const QPointF center(width() * 0.5, height() * 0.52);
|
||||
|
||||
QRadialGradient atmosphere(center - QPointF(radius * .25, radius * .32), radius * 1.24);
|
||||
atmosphere.setColorAt(0.66, QColor(19, 113, 177, 0));
|
||||
atmosphere.setColorAt(0.82, QColor(47, 169, 235, 48));
|
||||
atmosphere.setColorAt(0.92, QColor(51, 178, 239, 20));
|
||||
atmosphere.setColorAt(1.0, QColor(35, 126, 205, 0));
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(atmosphere);
|
||||
painter.drawEllipse(center, radius * 1.25, radius * 1.25);
|
||||
|
||||
QRadialGradient earth(center - QPointF(radius * .36, radius * .42), radius * 1.42);
|
||||
earth.setColorAt(0.0, QColor(48, 154, 200));
|
||||
earth.setColorAt(0.37, QColor(18, 91, 144));
|
||||
earth.setColorAt(0.72, QColor(8, 47, 86));
|
||||
earth.setColorAt(1.0, QColor(1, 10, 24));
|
||||
painter.setBrush(earth);
|
||||
painter.setPen(QPen(QColor(89, 196, 240, 150), 1.2));
|
||||
painter.drawEllipse(center, radius, radius);
|
||||
|
||||
painter.save();
|
||||
painter.setClipPath(QPainterPath());
|
||||
QPainterPath sphereClip;
|
||||
sphereClip.addEllipse(center, radius, radius);
|
||||
painter.setClipPath(sphereClip);
|
||||
drawContinents(painter);
|
||||
drawGraticule(painter);
|
||||
painter.restore();
|
||||
|
||||
for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0);
|
||||
painter.end();
|
||||
|
||||
m_staticLayer = std::move(layer);
|
||||
m_staticLayerSize = size();
|
||||
m_staticLayerDevicePixelRatio = devicePixelRatio;
|
||||
m_staticLayerYaw = m_yaw;
|
||||
m_staticLayerPitch = m_pitch;
|
||||
m_staticLayerZoom = m_zoom;
|
||||
}
|
||||
|
||||
void GlobeWidget::drawContinents(QPainter &painter) const
|
||||
{
|
||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
||||
const double pixelRatio = m_dragging ? 0.75 : std::min(devicePixelRatioF(), 1.5);
|
||||
const int textureSize = std::clamp(
|
||||
static_cast<int>(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);
|
||||
if (!m_landTexture.isNull() && m_landTexture.width() == textureSize
|
||||
&& m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch) {
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.drawImage(target, m_landTexture);
|
||||
return;
|
||||
}
|
||||
QImage land(textureSize, textureSize, QImage::Format_ARGB32);
|
||||
land.fill(Qt::transparent);
|
||||
|
||||
const double cy = std::cos(m_yaw);
|
||||
const double sy = std::sin(m_yaw);
|
||||
const double cp = std::cos(m_pitch);
|
||||
const double sp = std::sin(m_pitch);
|
||||
for (int py = 0; py < textureSize; ++py) {
|
||||
QRgb *scanLine = reinterpret_cast<QRgb *>(land.scanLine(py));
|
||||
const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0);
|
||||
for (int px = 0; px < textureSize; ++px) {
|
||||
const double screenX = (px + 0.5) / textureSize * 2.0 - 1.0;
|
||||
const double radiusSquared = screenX * screenX + screenZ * screenZ;
|
||||
if (radiusSquared > 1.0) continue;
|
||||
|
||||
const double depth = std::sqrt(1.0 - radiusSquared);
|
||||
const double yawY = cp * depth + sp * screenZ;
|
||||
const double worldZ = -sp * depth + cp * screenZ;
|
||||
const double worldX = cy * screenX + sy * yawY;
|
||||
const double worldY = -sy * screenX + cy * yawY;
|
||||
const double latitude = std::asin(std::clamp(worldZ, -1.0, 1.0)) * 180.0 / Pi;
|
||||
const double longitude = std::atan2(worldY, worldX) * 180.0 / Pi;
|
||||
const bool onLand = maskIsLand(longitude, latitude);
|
||||
const bool nearLand = maskIsLand(longitude + 0.55, latitude)
|
||||
|| maskIsLand(longitude - 0.55, latitude)
|
||||
|| maskIsLand(longitude, latitude + 0.55)
|
||||
|| maskIsLand(longitude, latitude - 0.55);
|
||||
if (!onLand) {
|
||||
if (nearLand) {
|
||||
const int alpha = static_cast<int>(65.0 * std::clamp(depth * 1.5, 0.0, 1.0));
|
||||
scanLine[px] = qRgba(33, 151, 174, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const double light = std::clamp(0.30 + 0.70
|
||||
* (0.35 - screenX * 0.38 + screenZ * 0.48 + depth * 0.38), 0.18, 1.0);
|
||||
const double lon = longitude * Pi / 180.0;
|
||||
const double lat = latitude * Pi / 180.0;
|
||||
const double broadRelief = 0.50 + 0.24 * std::sin(lon * 5.0 + lat * 2.2)
|
||||
+ 0.16 * std::sin(lon * 13.0 - lat * 7.0)
|
||||
+ 0.10 * std::cos(lon * 29.0 + lat * 17.0);
|
||||
const double relief = std::clamp(broadRelief, 0.0, 1.0);
|
||||
const double aridBelt = std::exp(-std::pow((std::abs(latitude) - 25.0) / 13.0, 2.0));
|
||||
const double moisture = std::clamp(0.62 - aridBelt * 0.48
|
||||
+ 0.18 * std::sin(lon * 3.0 - lat * 4.0), 0.0, 1.0);
|
||||
const double highland = smoothStep(0.67, 0.92, relief);
|
||||
const double snow = smoothStep(63.0, 78.0, std::abs(latitude))
|
||||
* (0.65 + highland * 0.35);
|
||||
|
||||
const double lushRed = 30.0 + relief * 25.0;
|
||||
const double lushGreen = 104.0 + relief * 34.0;
|
||||
const double lushBlue = 63.0 + relief * 20.0;
|
||||
const double dryRed = 151.0 + relief * 24.0;
|
||||
const double dryGreen = 126.0 + relief * 18.0;
|
||||
const double dryBlue = 72.0 + relief * 16.0;
|
||||
double red = dryRed + (lushRed - dryRed) * moisture;
|
||||
double green = dryGreen + (lushGreen - dryGreen) * moisture;
|
||||
double blue = dryBlue + (lushBlue - dryBlue) * moisture;
|
||||
red = red * (1.0 - highland * 0.30) + 104.0 * highland * 0.30;
|
||||
green = green * (1.0 - highland * 0.30) + 91.0 * highland * 0.30;
|
||||
blue = blue * (1.0 - highland * 0.30) + 76.0 * highland * 0.30;
|
||||
red = red * (1.0 - snow) + 224.0 * snow;
|
||||
green = green * (1.0 - snow) + 231.0 * snow;
|
||||
blue = blue * (1.0 - snow) + 224.0 * snow;
|
||||
|
||||
const bool coast = !maskIsLand(longitude + 0.55, latitude)
|
||||
|| !maskIsLand(longitude - 0.55, latitude)
|
||||
|| !maskIsLand(longitude, latitude + 0.55)
|
||||
|| !maskIsLand(longitude, latitude - 0.55);
|
||||
const double shade = light * (0.86 + relief * 0.14);
|
||||
if (coast) {
|
||||
red += 28.0;
|
||||
green += 31.0;
|
||||
blue += 18.0;
|
||||
}
|
||||
scanLine[px] = qRgba(
|
||||
std::clamp(static_cast<int>(red * shade), 0, 255),
|
||||
std::clamp(static_cast<int>(green * shade), 0, 255),
|
||||
std::clamp(static_cast<int>(blue * shade), 0, 255), 238);
|
||||
}
|
||||
}
|
||||
|
||||
m_landTexture = land;
|
||||
m_landTextureYaw = m_yaw;
|
||||
m_landTexturePitch = m_pitch;
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.drawImage(target, m_landTexture);
|
||||
}
|
||||
|
||||
void GlobeWidget::drawStars(QPainter &painter) const
|
||||
{
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (int i = 0; i < 95; ++i) {
|
||||
const int x = (i * 7919 + 37) % std::max(1, width());
|
||||
const int y = (i * 3571 + 71) % std::max(1, height());
|
||||
const int alpha = 45 + (i * 29) % 120;
|
||||
const double size = 0.7 + (i % 4) * 0.35;
|
||||
painter.setBrush(QColor(183, 218, 240, alpha));
|
||||
painter.drawEllipse(QPointF(x, y), size, size);
|
||||
}
|
||||
}
|
||||
|
||||
void GlobeWidget::drawGraticule(QPainter &painter) const
|
||||
{
|
||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
||||
painter.setPen(QPen(QColor(100, 193, 221, 48), 0.8));
|
||||
for (int latitude = -60; latitude <= 60; latitude += 30) {
|
||||
QPainterPath path;
|
||||
bool started = false;
|
||||
for (int longitude = -180; longitude <= 180; longitude += 3) {
|
||||
const Vec3 v = globeVector(latitude, longitude);
|
||||
const auto p = project(v.x, v.y, v.z, radius);
|
||||
if (p.depth >= 0.0) {
|
||||
if (!started) path.moveTo(p.point); else path.lineTo(p.point);
|
||||
started = true;
|
||||
} else started = false;
|
||||
}
|
||||
painter.drawPath(path);
|
||||
}
|
||||
for (int longitude = -150; longitude < 180; longitude += 30) {
|
||||
QPainterPath path;
|
||||
bool started = false;
|
||||
for (int latitude = -90; latitude <= 90; latitude += 2) {
|
||||
const Vec3 v = globeVector(latitude, longitude);
|
||||
const auto p = project(v.x, v.y, v.z, radius);
|
||||
if (p.depth >= 0.0) {
|
||||
if (!started) path.moveTo(p.point); else path.lineTo(p.point);
|
||||
started = true;
|
||||
} else started = false;
|
||||
}
|
||||
painter.drawPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
void GlobeWidget::drawOrbit(QPainter &painter, double inclination, double node) const
|
||||
{
|
||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom * 1.47;
|
||||
const double inc = inclination * Pi / 180.0;
|
||||
const double asc = node * Pi / 180.0;
|
||||
QPainterPath back, front;
|
||||
bool backStarted = false, frontStarted = false;
|
||||
for (int degree = 0; degree <= 360; degree += 2) {
|
||||
const double t = degree * Pi / 180.0;
|
||||
const double ox = std::cos(t);
|
||||
const double oy = std::sin(t) * std::cos(inc);
|
||||
const double oz = std::sin(t) * std::sin(inc);
|
||||
const double x = ox * std::cos(asc) - oy * std::sin(asc);
|
||||
const double y = ox * std::sin(asc) + oy * std::cos(asc);
|
||||
const auto p = project(x, y, oz, radius);
|
||||
QPainterPath &path = p.depth < 0.0 ? back : front;
|
||||
bool &started = p.depth < 0.0 ? backStarted : frontStarted;
|
||||
if (!started) path.moveTo(p.point); else path.lineTo(p.point);
|
||||
started = true;
|
||||
if (p.depth < 0.0) frontStarted = false; else backStarted = false;
|
||||
}
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.setPen(QPen(QColor(86, 130, 160, 44), 0.8, Qt::DashLine));
|
||||
painter.drawPath(back);
|
||||
painter.setPen(QPen(QColor(89, 173, 214, 90), 1.0));
|
||||
painter.drawPath(front);
|
||||
}
|
||||
|
||||
void GlobeWidget::drawVisibilityCone(QPainter &painter) const
|
||||
{
|
||||
if (!m_hasReceiver) return;
|
||||
|
||||
const double earthRadiusPixels = std::min(width(), height()) * 0.255 * m_zoom;
|
||||
constexpr double physicalOrbitRatio = 4.17;
|
||||
constexpr double displayOrbitRatio = 1.47;
|
||||
const Vec3 normal = globeVector(m_receiverLatitude, m_receiverLongitude);
|
||||
|
||||
Vec3 reference = std::abs(normal.z) < 0.85 ? Vec3{0.0, 0.0, 1.0} : Vec3{1.0, 0.0, 0.0};
|
||||
Vec3 tangentA {
|
||||
reference.y * normal.z - reference.z * normal.y,
|
||||
reference.z * normal.x - reference.x * normal.z,
|
||||
reference.x * normal.y - reference.y * normal.x
|
||||
};
|
||||
const double tangentLength = std::sqrt(tangentA.x * tangentA.x
|
||||
+ tangentA.y * tangentA.y
|
||||
+ tangentA.z * tangentA.z);
|
||||
tangentA = {tangentA.x / tangentLength, tangentA.y / tangentLength,
|
||||
tangentA.z / tangentLength};
|
||||
const Vec3 tangentB {
|
||||
normal.y * tangentA.z - normal.z * tangentA.y,
|
||||
normal.z * tangentA.x - normal.x * tangentA.z,
|
||||
normal.x * tangentA.y - normal.y * tangentA.x
|
||||
};
|
||||
|
||||
const double elevation = m_elevationMaskDegrees * Pi / 180.0;
|
||||
const double zenith = Pi / 2.0 - elevation;
|
||||
const double lineDistance = -std::cos(zenith)
|
||||
+ std::sqrt(physicalOrbitRatio * physicalOrbitRatio
|
||||
- std::sin(zenith) * std::sin(zenith));
|
||||
const auto apex = project(normal.x, normal.y, normal.z, earthRadiusPixels * 1.015);
|
||||
const auto zenithPoint = project(normal.x, normal.y, normal.z,
|
||||
earthRadiusPixels * displayOrbitRatio);
|
||||
|
||||
QVector<ProjectedPoint> ring;
|
||||
constexpr int Segments = 48;
|
||||
ring.reserve(Segments + 1);
|
||||
for (int segment = 0; segment <= Segments; ++segment) {
|
||||
const double angle = 2.0 * Pi * segment / Segments;
|
||||
const Vec3 tangent {
|
||||
tangentA.x * std::cos(angle) + tangentB.x * std::sin(angle),
|
||||
tangentA.y * std::cos(angle) + tangentB.y * std::sin(angle),
|
||||
tangentA.z * std::cos(angle) + tangentB.z * std::sin(angle)
|
||||
};
|
||||
const Vec3 direction {
|
||||
normal.x * std::cos(zenith) + tangent.x * std::sin(zenith),
|
||||
normal.y * std::cos(zenith) + tangent.y * std::sin(zenith),
|
||||
normal.z * std::cos(zenith) + tangent.z * std::sin(zenith)
|
||||
};
|
||||
const Vec3 orbitPoint {
|
||||
(normal.x + lineDistance * direction.x) / physicalOrbitRatio,
|
||||
(normal.y + lineDistance * direction.y) / physicalOrbitRatio,
|
||||
(normal.z + lineDistance * direction.z) / physicalOrbitRatio
|
||||
};
|
||||
ring.push_back(project(orbitPoint.x, orbitPoint.y, orbitPoint.z,
|
||||
earthRadiusPixels * displayOrbitRatio));
|
||||
}
|
||||
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (int i = 0; i < Segments; ++i) {
|
||||
const double averageDepth = (apex.depth + ring[i].depth + ring[i + 1].depth) / 3.0;
|
||||
const int alpha = averageDepth >= 0.0 ? 18 : 7;
|
||||
painter.setBrush(QColor(48, 207, 218, alpha));
|
||||
painter.drawPolygon(QPolygonF{apex.point, ring[i].point, ring[i + 1].point});
|
||||
}
|
||||
|
||||
QPainterPath boundary;
|
||||
boundary.moveTo(ring.first().point);
|
||||
for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].point);
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.setPen(QPen(QColor(76, 222, 228, 105), 1.0, Qt::DashLine));
|
||||
painter.drawPath(boundary);
|
||||
for (int i = 0; i < Segments; i += 8) {
|
||||
painter.drawLine(apex.point, ring[i].point);
|
||||
}
|
||||
painter.setPen(QPen(QColor(119, 239, 241, 165), 1.4));
|
||||
painter.drawLine(apex.point, zenithPoint.point);
|
||||
|
||||
const QPointF axis = zenithPoint.point - apex.point;
|
||||
const double axisLength = std::hypot(axis.x(), axis.y());
|
||||
if (axisLength > 1.0) {
|
||||
const QPointF unit = axis / axisLength;
|
||||
const QPointF side(-unit.y(), unit.x());
|
||||
const QPointF arrowTip = zenithPoint.point;
|
||||
painter.drawLine(arrowTip, arrowTip - unit * 8.0 + side * 4.0);
|
||||
painter.drawLine(arrowTip, arrowTip - unit * 8.0 - side * 4.0);
|
||||
}
|
||||
if (apex.depth >= -0.05) {
|
||||
painter.setPen(QColor(119, 239, 241, 190));
|
||||
painter.drawText(apex.point + QPointF(12, 18),
|
||||
QStringLiteral("GORUS KONISI %1°")
|
||||
.arg(m_elevationMaskDegrees, 0, 'f', 1));
|
||||
}
|
||||
}
|
||||
|
||||
void GlobeWidget::drawReceiver(QPainter &painter) const
|
||||
{
|
||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
||||
const Vec3 v = globeVector(m_receiverLatitude, m_receiverLongitude);
|
||||
const auto p = project(v.x, v.y, v.z, radius * 1.015);
|
||||
if (p.depth < -0.05) return;
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 210), 1.5));
|
||||
painter.setBrush(QColor(255, 196, 61));
|
||||
painter.drawEllipse(p.point, 5.5, 5.5);
|
||||
painter.setPen(QPen(QColor(255, 196, 61, 110), 1.2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawEllipse(p.point, 11, 11);
|
||||
painter.setPen(QColor(255, 224, 147));
|
||||
painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI"));
|
||||
}
|
||||
|
||||
int GlobeWidget::drawSatellites(QPainter &painter) const
|
||||
{
|
||||
if (!m_hasReceiver) return 0;
|
||||
const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47;
|
||||
struct Item {
|
||||
SatellitePosition satellite;
|
||||
ProjectedPoint projected;
|
||||
double elevation = -90.0;
|
||||
};
|
||||
QVector<Item> 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),
|
||||
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 = 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) {
|
||||
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);
|
||||
painter.setPen(QPen(QColor(90, 236, 239, sightLine.alpha()), 1.2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawEllipse(item.projected.point, markerRadius + 3.0, markerRadius + 3.0);
|
||||
}
|
||||
painter.setPen(QPen(QColor(235, 248, 255, color.alpha()), 1.0));
|
||||
painter.setBrush(color);
|
||||
painter.drawEllipse(item.projected.point, markerRadius, markerRadius);
|
||||
if (item.projected.depth >= -0.05) {
|
||||
painter.setPen(color.lighter(135));
|
||||
painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn);
|
||||
}
|
||||
}
|
||||
return visibleCount;
|
||||
}
|
||||
|
||||
double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const
|
||||
{
|
||||
if (!m_hasReceiver) return -90.0;
|
||||
constexpr double EarthRadiusMeters = 6378137.0;
|
||||
const Vec3 receiverUnit = globeVector(m_receiverLatitude, m_receiverLongitude);
|
||||
const Vec3 lineOfSight {
|
||||
satellite.x - receiverUnit.x * EarthRadiusMeters,
|
||||
satellite.y - receiverUnit.y * EarthRadiusMeters,
|
||||
satellite.z - receiverUnit.z * EarthRadiusMeters
|
||||
};
|
||||
const double length = std::sqrt(lineOfSight.x * lineOfSight.x
|
||||
+ lineOfSight.y * lineOfSight.y
|
||||
+ lineOfSight.z * lineOfSight.z);
|
||||
if (length <= 0.0) return -90.0;
|
||||
const double sineElevation = (lineOfSight.x * receiverUnit.x
|
||||
+ lineOfSight.y * receiverUnit.y
|
||||
+ lineOfSight.z * receiverUnit.z) / length;
|
||||
return std::asin(std::clamp(sineElevation, -1.0, 1.0)) * 180.0 / Pi;
|
||||
}
|
||||
|
||||
void GlobeWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
m_dragging = true;
|
||||
m_lastMousePosition = event->pos();
|
||||
setCursor(Qt::ClosedHandCursor);
|
||||
}
|
||||
}
|
||||
|
||||
void GlobeWidget::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (!m_dragging) return;
|
||||
const QPoint delta = event->pos() - m_lastMousePosition;
|
||||
m_lastMousePosition = event->pos();
|
||||
m_yaw += delta.x() * 0.008;
|
||||
m_pitch = std::clamp(m_pitch - delta.y() * 0.008, -1.45, 1.45);
|
||||
update();
|
||||
}
|
||||
|
||||
void GlobeWidget::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
m_dragging = false;
|
||||
m_staticLayer = QPixmap();
|
||||
setCursor(Qt::OpenHandCursor);
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void GlobeWidget::wheelEvent(QWheelEvent *event)
|
||||
{
|
||||
m_zoom = std::clamp(m_zoom + event->angleDelta().y() / 1200.0, 0.72, 1.28);
|
||||
update();
|
||||
event->accept();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include "SatelliteData.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QImage>
|
||||
#include <QPoint>
|
||||
#include <QPixmap>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
class GlobeWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit GlobeWidget(QWidget *parent = nullptr);
|
||||
|
||||
void setSatellites(const QVector<SatellitePosition> &satellites);
|
||||
void setSatelliteMotion(const QVector<SatellitePosition> ¤t,
|
||||
const QVector<SatellitePosition> &next,
|
||||
int durationMs);
|
||||
void setObservedPrns(const QSet<QString> &prns);
|
||||
void setReceiverLocation(double latitude, double longitude);
|
||||
void clearReceiverLocation();
|
||||
void setRinexStatus(const QString &status, bool error);
|
||||
[[nodiscard]] QSet<QString> observedPrns() const { return m_observedPrns; }
|
||||
|
||||
public slots:
|
||||
void setElevationMask(double degrees);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void wheelEvent(QWheelEvent *event) override;
|
||||
|
||||
private:
|
||||
struct ProjectedPoint {
|
||||
QPointF point;
|
||||
double depth = 0.0;
|
||||
};
|
||||
|
||||
ProjectedPoint project(double x, double y, double z, double radius) const;
|
||||
void drawStars(QPainter &painter) const;
|
||||
void drawOrbit(QPainter &painter, double inclination, double node) const;
|
||||
void drawContinents(QPainter &painter) const;
|
||||
void drawVisibilityCone(QPainter &painter) const;
|
||||
void drawGraticule(QPainter &painter) const;
|
||||
void drawReceiver(QPainter &painter) const;
|
||||
int drawSatellites(QPainter &painter) const;
|
||||
double satelliteElevation(const SatellitePosition &satellite) const;
|
||||
void updateSatelliteAnimation();
|
||||
void rebuildStaticLayer();
|
||||
|
||||
QVector<SatellitePosition> m_satellites;
|
||||
QVector<SatellitePosition> m_satelliteMotionStart;
|
||||
QVector<SatellitePosition> m_satelliteMotionTarget;
|
||||
QElapsedTimer m_satelliteMotionClock;
|
||||
QTimer m_satelliteAnimationTimer;
|
||||
int m_satelliteMotionDurationMs = 0;
|
||||
mutable QImage m_landTexture;
|
||||
mutable double m_landTextureYaw = 0.0;
|
||||
mutable double m_landTexturePitch = 0.0;
|
||||
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;
|
||||
QSet<QString> m_observedPrns;
|
||||
QString m_status = QStringLiteral("Konum bekleniyor");
|
||||
bool m_statusError = false;
|
||||
bool m_hasReceiver = false;
|
||||
double m_receiverLatitude = 0.0;
|
||||
double m_receiverLongitude = 0.0;
|
||||
double m_yaw = -0.55;
|
||||
double m_pitch = -0.28;
|
||||
double m_zoom = 1.0;
|
||||
double m_elevationMaskDegrees = 10.0;
|
||||
bool m_dragging = false;
|
||||
QPoint m_lastMousePosition;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
#pragma once
|
||||
|
||||
#include "TelemetryPoint.h"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QStringList>
|
||||
#include <QSet>
|
||||
#include <QVector>
|
||||
|
||||
#include <optional>
|
||||
|
||||
class QLabel;
|
||||
class QCheckBox;
|
||||
class QComboBox;
|
||||
class QLineEdit;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
class QTableView;
|
||||
class QTimer;
|
||||
class QCloseEvent;
|
||||
class QFile;
|
||||
|
||||
class MapWidget;
|
||||
class GlobeWidget;
|
||||
class RinexNavigation;
|
||||
class SerialReader;
|
||||
class TelemetryTableModel;
|
||||
class UartReplay;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QWidget *parent = nullptr);
|
||||
bool loadTelemetryLog(const QString &filePath);
|
||||
bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200,
|
||||
int sensorIntervalMs = 50);
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void toggleSerial();
|
||||
void clearPoints();
|
||||
void handleSerialLine(const QString &line);
|
||||
void handleSerialStatus(const QString &status);
|
||||
void handleSerialError(const QString &message);
|
||||
void handleLocationTimeout();
|
||||
void trimToMaxPoints();
|
||||
void updateReferenceLocation();
|
||||
void flushUiUpdates();
|
||||
void openTelemetryLog();
|
||||
void openUartReplay();
|
||||
void stopUartReplay();
|
||||
void refreshSerialPorts();
|
||||
bool startUartLogging(const QString &port, int baudRate);
|
||||
void stopUartLogging();
|
||||
|
||||
private:
|
||||
void buildUi();
|
||||
void updateConnectionControls();
|
||||
void addTelemetryPoint(const ParsedTelemetry &telemetry);
|
||||
void refreshTable();
|
||||
void updateSummaryLabels();
|
||||
void updateDopLabels();
|
||||
void updateAnalysis();
|
||||
void appendLogLine(const QString &line, bool parsed);
|
||||
void mergePendingTelemetry(const ParsedTelemetry &telemetry);
|
||||
void commitPendingTelemetry();
|
||||
void resetTelemetryState();
|
||||
void scheduleTelemetryRefresh();
|
||||
void refreshTelemetryViews();
|
||||
void flushPendingLogLines();
|
||||
void appendUartLogLine(const QString &line);
|
||||
int selectedBaudRate() const;
|
||||
|
||||
SerialReader *m_serial = nullptr;
|
||||
UartReplay *m_replay = nullptr;
|
||||
MapWidget *m_map = nullptr;
|
||||
GlobeWidget *m_globe = nullptr;
|
||||
RinexNavigation *m_rinex = nullptr;
|
||||
QComboBox *m_portCombo = nullptr;
|
||||
QComboBox *m_baudCombo = nullptr;
|
||||
QSpinBox *m_maxPointsSpin = nullptr;
|
||||
QLineEdit *m_referenceLatEdit = nullptr;
|
||||
QLineEdit *m_referenceLonEdit = nullptr;
|
||||
QPushButton *m_connectButton = nullptr;
|
||||
QPushButton *m_clearButton = nullptr;
|
||||
QPushButton *m_openLogButton = nullptr;
|
||||
QPushButton *m_replayButton = nullptr;
|
||||
QPushButton *m_refreshRinexButton = nullptr;
|
||||
QCheckBox *m_autoFollowCheck = nullptr;
|
||||
QCheckBox *m_showTelemetryCheck = nullptr;
|
||||
QCheckBox *m_showRecentPointsCheck = nullptr;
|
||||
QLabel *m_statusLabel = nullptr;
|
||||
QLabel *m_satelliteLabel = nullptr;
|
||||
QLabel *m_satelliteQualityLabel = nullptr;
|
||||
QLabel *m_gdopLabel = nullptr;
|
||||
QLabel *m_hdopLabel = nullptr;
|
||||
QLabel *m_vdopLabel = nullptr;
|
||||
QLabel *m_tdopLabel = nullptr;
|
||||
QLabel *m_countLabel = nullptr;
|
||||
QLabel *m_meanLocationLabel = nullptr;
|
||||
QLabel *m_lastDeviationLabel = nullptr;
|
||||
QLabel *m_meanDeviationLabel = nullptr;
|
||||
QLabel *m_rmsScatterLabel = nullptr;
|
||||
QLabel *m_maxScatterLabel = nullptr;
|
||||
QLabel *m_cepLabel = nullptr;
|
||||
QCheckBox *m_showAverageCheck = nullptr;
|
||||
QTableView *m_table = nullptr;
|
||||
TelemetryTableModel *m_tableModel = nullptr;
|
||||
QPlainTextEdit *m_log = nullptr;
|
||||
QTimer *m_locationTimeoutTimer = nullptr;
|
||||
QTimer *m_uiRefreshTimer = nullptr;
|
||||
QFile *m_uartLogFile = nullptr;
|
||||
QString m_uartLogPath;
|
||||
|
||||
QVector<TelemetryPoint> m_points;
|
||||
QStringList m_pendingLogLines;
|
||||
std::optional<int> m_lastSatelliteCount;
|
||||
QSet<QString> m_observedPrns;
|
||||
std::optional<double> m_referenceLatitude;
|
||||
std::optional<double> m_referenceLongitude;
|
||||
DopValues m_lastDop;
|
||||
ParsedTelemetry m_pendingTelemetry;
|
||||
bool m_telemetryRefreshPending = false;
|
||||
};
|
||||
@@ -0,0 +1,727 @@
|
||||
#include "MapWidget.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QLabel>
|
||||
#include <QLineF>
|
||||
#include <QMouseEvent>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkDiskCache>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QResizeEvent>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int TileSize = 256;
|
||||
constexpr int MinZoom = 2;
|
||||
constexpr int MaxZoom = 19;
|
||||
constexpr int MaxMemoryTileCount = 512;
|
||||
constexpr double Pi = 3.14159265358979323846;
|
||||
constexpr double WebMercatorRadiusMeters = 6378137.0;
|
||||
constexpr double MaxMercatorLatitude = 85.05112878;
|
||||
|
||||
QString tileKey(int zoom, int tileX, int tileY)
|
||||
{
|
||||
return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY);
|
||||
}
|
||||
|
||||
double clampLatitude(double latitude)
|
||||
{
|
||||
return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude);
|
||||
}
|
||||
|
||||
int positiveModulo(int value, int modulo)
|
||||
{
|
||||
const int result = value % modulo;
|
||||
return result < 0 ? result + modulo : result;
|
||||
}
|
||||
|
||||
double metersPerPixel(double latitude, int zoom)
|
||||
{
|
||||
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
|
||||
const double latitudeScale = std::cos(latitude * Pi / 180.0);
|
||||
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
|
||||
}
|
||||
|
||||
int preferredCurrentPointIndex(const QVector<TelemetryPoint> &points)
|
||||
{
|
||||
constexpr qint64 CorrectedPreferenceWindowMs = 1000;
|
||||
const int newestIndex = points.size() - 1;
|
||||
for (int index = points.size() - 1; index >= 0; --index) {
|
||||
if (points[index].source == LocationSource::ImuCorrected) {
|
||||
const qint64 ageMs = points[index].receivedAt.msecsTo(
|
||||
points[newestIndex].receivedAt);
|
||||
return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex;
|
||||
}
|
||||
}
|
||||
return newestIndex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MapWidget::MapWidget(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
setMinimumSize(640, 420);
|
||||
setMouseTracking(true);
|
||||
|
||||
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
if (!cacheRoot.isEmpty()) {
|
||||
auto *diskCache = new QNetworkDiskCache(m_network);
|
||||
const QString cacheDirectory = cacheRoot + QStringLiteral("/map-tiles");
|
||||
QDir().mkpath(cacheDirectory);
|
||||
diskCache->setCacheDirectory(cacheDirectory);
|
||||
diskCache->setMaximumCacheSize(100 * 1024 * 1024);
|
||||
m_network->setCache(diskCache);
|
||||
}
|
||||
|
||||
m_attributionLabel = new QLabel(this);
|
||||
m_attributionLabel->setText(
|
||||
QStringLiteral("<a style=\"color:#174b7a;text-decoration:none\" "
|
||||
"href=\"https://www.openstreetmap.org/copyright\">")
|
||||
+ QString::fromUtf8("\xC2\xA9 OpenStreetMap contributors")
|
||||
+ QStringLiteral("</a>"));
|
||||
m_attributionLabel->setOpenExternalLinks(true);
|
||||
m_attributionLabel->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background: rgba(255, 255, 255, 220); color: #26313d; "
|
||||
"border: 1px solid rgba(120, 130, 140, 90); padding: 3px 6px; }"));
|
||||
m_attributionLabel->adjustSize();
|
||||
}
|
||||
|
||||
void MapWidget::setPoints(const QVector<TelemetryPoint> &points)
|
||||
{
|
||||
const bool wasEmpty = m_points.isEmpty();
|
||||
m_points = points;
|
||||
|
||||
if (!m_points.isEmpty() && m_autoFollow) {
|
||||
const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)];
|
||||
m_centerLat = current.latitude;
|
||||
m_centerLon = current.longitude;
|
||||
if (wasEmpty) {
|
||||
m_zoom = MaxZoom;
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::clearPoints()
|
||||
{
|
||||
m_points.clear();
|
||||
m_hasAverageMarker = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::fitToPoints()
|
||||
{
|
||||
if (m_points.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double minimumLatitude = m_points.first().latitude;
|
||||
double maximumLatitude = minimumLatitude;
|
||||
double minimumLongitude = m_points.first().longitude;
|
||||
double maximumLongitude = minimumLongitude;
|
||||
for (const TelemetryPoint &point : m_points) {
|
||||
minimumLatitude = std::min(minimumLatitude, point.latitude);
|
||||
maximumLatitude = std::max(maximumLatitude, point.latitude);
|
||||
minimumLongitude = std::min(minimumLongitude, point.longitude);
|
||||
maximumLongitude = std::max(maximumLongitude, point.longitude);
|
||||
}
|
||||
m_centerLat = 0.5 * (minimumLatitude + maximumLatitude);
|
||||
m_centerLon = 0.5 * (minimumLongitude + maximumLongitude);
|
||||
const double availableWidth = std::max(100.0, width() * 0.82);
|
||||
const double availableHeight = std::max(100.0, height() * 0.82);
|
||||
for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) {
|
||||
const QPointF topLeft = coordinateToWorld(maximumLatitude, minimumLongitude, zoom);
|
||||
const QPointF bottomRight = coordinateToWorld(minimumLatitude, maximumLongitude, zoom);
|
||||
if (std::fabs(bottomRight.x() - topLeft.x()) <= availableWidth
|
||||
&& std::fabs(bottomRight.y() - topLeft.y()) <= availableHeight) {
|
||||
m_zoom = zoom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (m_autoFollow) {
|
||||
m_autoFollow = false;
|
||||
emit autoFollowChanged(false);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::setAverageMarker(double latitude, double longitude, double radiusMeters)
|
||||
{
|
||||
m_hasAverageMarker = true;
|
||||
m_averageLat = latitude;
|
||||
m_averageLon = longitude;
|
||||
m_averageRadiusMeters = std::max(0.0, radiusMeters);
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::clearAverageMarker()
|
||||
{
|
||||
if (!m_hasAverageMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_hasAverageMarker = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::setCepMarker(double latitude, double longitude,
|
||||
const std::optional<double> &radiusMeters)
|
||||
{
|
||||
m_hasCepMarker = true;
|
||||
m_cepLatitude = latitude;
|
||||
m_cepLongitude = longitude;
|
||||
if (m_points.isEmpty()) {
|
||||
m_centerLat = latitude;
|
||||
m_centerLon = longitude;
|
||||
m_zoom = MaxZoom;
|
||||
}
|
||||
if (radiusMeters.has_value()) {
|
||||
m_cepRadiusMeters = std::max(0.0, *radiusMeters);
|
||||
} else {
|
||||
m_cepRadiusMeters.reset();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::clearCepMarker()
|
||||
{
|
||||
if (!m_hasCepMarker) {
|
||||
return;
|
||||
}
|
||||
m_hasCepMarker = false;
|
||||
m_cepRadiusMeters.reset();
|
||||
update();
|
||||
}
|
||||
|
||||
QPointF MapWidget::centerCoordinate() const
|
||||
{
|
||||
return QPointF(m_centerLat, m_centerLon);
|
||||
}
|
||||
|
||||
void MapWidget::setAutoFollow(bool enabled)
|
||||
{
|
||||
if (m_autoFollow == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_autoFollow = enabled;
|
||||
if (m_autoFollow && !m_points.isEmpty()) {
|
||||
const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)];
|
||||
m_centerLat = current.latitude;
|
||||
m_centerLon = current.longitude;
|
||||
update();
|
||||
}
|
||||
emit autoFollowChanged(m_autoFollow);
|
||||
}
|
||||
|
||||
void MapWidget::setAverageOverlayEnabled(bool enabled)
|
||||
{
|
||||
if (m_showAverageOverlay == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_showAverageOverlay = enabled;
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::setTelemetryOverlayEnabled(bool enabled)
|
||||
{
|
||||
if (m_showTelemetryOverlay == enabled) {
|
||||
return;
|
||||
}
|
||||
m_showTelemetryOverlay = enabled;
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::setRecentPointsEnabled(bool enabled)
|
||||
{
|
||||
if (m_showRecentPoints == enabled) {
|
||||
return;
|
||||
}
|
||||
m_showRecentPoints = enabled;
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::zoomIn()
|
||||
{
|
||||
setZoomKeepingPoint(m_zoom + 1, QPointF(width() / 2.0, height() / 2.0));
|
||||
}
|
||||
|
||||
void MapWidget::zoomOut()
|
||||
{
|
||||
setZoomKeepingPoint(m_zoom - 1, QPointF(width() / 2.0, height() / 2.0));
|
||||
}
|
||||
|
||||
void MapWidget::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.fillRect(rect(), QColor(219, 225, 220));
|
||||
drawTiles(painter);
|
||||
drawTelemetry(painter);
|
||||
drawLegend(painter);
|
||||
|
||||
if (m_points.isEmpty()) {
|
||||
drawEmptyState(painter);
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::wheelEvent(QWheelEvent *event)
|
||||
{
|
||||
const int delta = event->angleDelta().y();
|
||||
if (delta == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
disableAutoFollowFromUserAction();
|
||||
setZoomKeepingPoint(m_zoom + (delta > 0 ? 1 : -1), event->position());
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void MapWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::RightButton) {
|
||||
const QPointF coordinate = screenToCoordinate(event->position());
|
||||
double longitude = std::fmod(coordinate.y() + 180.0, 360.0);
|
||||
if (longitude < 0.0) {
|
||||
longitude += 360.0;
|
||||
}
|
||||
longitude -= 180.0;
|
||||
emit referenceLocationSelected(coordinate.x(), longitude);
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
disableAutoFollowFromUserAction();
|
||||
m_dragging = true;
|
||||
m_lastMousePos = event->pos();
|
||||
setCursor(Qt::ClosedHandCursor);
|
||||
event->accept();
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (!m_dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QPoint delta = event->pos() - m_lastMousePos;
|
||||
m_lastMousePos = event->pos();
|
||||
|
||||
const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom);
|
||||
const QPointF newCenterWorld = centerWorld - QPointF(delta);
|
||||
const QPointF coordinate = worldToCoordinate(newCenterWorld, m_zoom);
|
||||
m_centerLat = coordinate.x();
|
||||
m_centerLon = coordinate.y();
|
||||
update();
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void MapWidget::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && m_dragging) {
|
||||
m_dragging = false;
|
||||
unsetCursor();
|
||||
event->accept();
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::resizeEvent(QResizeEvent *event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
if (m_attributionLabel != nullptr) {
|
||||
m_attributionLabel->adjustSize();
|
||||
m_attributionLabel->move(width() - m_attributionLabel->width() - 8,
|
||||
height() - m_attributionLabel->height() - 8);
|
||||
m_attributionLabel->raise();
|
||||
}
|
||||
}
|
||||
|
||||
QPointF MapWidget::coordinateToWorld(double latitude, double longitude, int zoom) const
|
||||
{
|
||||
latitude = clampLatitude(latitude);
|
||||
const double sinLat = std::sin(latitude * Pi / 180.0);
|
||||
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
|
||||
|
||||
const double x = (longitude + 180.0) / 360.0 * scale;
|
||||
const double y = (0.5 - std::log((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Pi)) * scale;
|
||||
return QPointF(x, y);
|
||||
}
|
||||
|
||||
QPointF MapWidget::worldToCoordinate(const QPointF &world, int zoom) const
|
||||
{
|
||||
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
|
||||
const double longitude = world.x() / scale * 360.0 - 180.0;
|
||||
const double n = Pi - 2.0 * Pi * world.y() / scale;
|
||||
const double latitude = 180.0 / Pi * std::atan(0.5 * (std::exp(n) - std::exp(-n)));
|
||||
return QPointF(clampLatitude(latitude), longitude);
|
||||
}
|
||||
|
||||
QPointF MapWidget::coordinateToScreen(double latitude, double longitude) const
|
||||
{
|
||||
const QPointF world = coordinateToWorld(latitude, longitude, m_zoom);
|
||||
const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom);
|
||||
const double worldWidth = static_cast<double>(TileSize) * static_cast<double>(1 << m_zoom);
|
||||
double deltaX = world.x() - centerWorld.x();
|
||||
if (deltaX > worldWidth / 2.0) {
|
||||
deltaX -= worldWidth;
|
||||
} else if (deltaX < -worldWidth / 2.0) {
|
||||
deltaX += worldWidth;
|
||||
}
|
||||
return QPointF(width() / 2.0 + deltaX, height() / 2.0 + world.y() - centerWorld.y());
|
||||
}
|
||||
|
||||
QPointF MapWidget::screenToCoordinate(const QPointF &screen) const
|
||||
{
|
||||
return worldToCoordinate(topLeftWorld() + screen, m_zoom);
|
||||
}
|
||||
|
||||
QPointF MapWidget::topLeftWorld() const
|
||||
{
|
||||
return coordinateToWorld(m_centerLat, m_centerLon, m_zoom)
|
||||
- QPointF(width() / 2.0, height() / 2.0);
|
||||
}
|
||||
|
||||
void MapWidget::drawTiles(QPainter &painter)
|
||||
{
|
||||
const int tileCount = 1 << m_zoom;
|
||||
const QPointF topLeft = topLeftWorld();
|
||||
const int firstTileX = static_cast<int>(std::floor(topLeft.x() / TileSize));
|
||||
const int lastTileX = static_cast<int>(std::floor((topLeft.x() + width()) / TileSize));
|
||||
const int firstTileY = static_cast<int>(std::floor(topLeft.y() / TileSize));
|
||||
const int lastTileY = static_cast<int>(std::floor((topLeft.y() + height()) / TileSize));
|
||||
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, false);
|
||||
|
||||
for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) {
|
||||
if (tileY < 0 || tileY >= tileCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) {
|
||||
const int wrappedTileX = positiveModulo(tileX, tileCount);
|
||||
const QString key = tileKey(m_zoom, wrappedTileX, tileY);
|
||||
const QRectF target(
|
||||
tileX * TileSize - topLeft.x(),
|
||||
tileY * TileSize - topLeft.y(),
|
||||
TileSize,
|
||||
TileSize);
|
||||
|
||||
const auto tileIt = m_tileCache.constFind(key);
|
||||
if (tileIt != m_tileCache.constEnd()) {
|
||||
painter.drawPixmap(target.toRect(), *tileIt);
|
||||
} else {
|
||||
painter.fillRect(target, QColor(230, 234, 230));
|
||||
painter.setPen(QColor(198, 205, 200));
|
||||
painter.drawRect(target);
|
||||
requestTile(wrappedTileX, tileY, m_zoom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::drawTelemetry(QPainter &painter)
|
||||
{
|
||||
if (m_points.isEmpty() || !m_showTelemetryOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
drawCepOverlay(painter);
|
||||
|
||||
const auto drawSourcePath = [this, &painter](LocationSource source, const QColor &color) {
|
||||
QPainterPath path;
|
||||
bool havePoint = false;
|
||||
int pointCount = 0;
|
||||
for (const TelemetryPoint &point : m_points) {
|
||||
if (point.source != source) {
|
||||
continue;
|
||||
}
|
||||
const QPointF screen = coordinateToScreen(point.latitude, point.longitude);
|
||||
havePoint ? path.lineTo(screen) : path.moveTo(screen);
|
||||
havePoint = true;
|
||||
++pointCount;
|
||||
}
|
||||
if (m_showRecentPoints && pointCount > 1) {
|
||||
QPen pathPen(color, 3.0);
|
||||
pathPen.setCapStyle(Qt::RoundCap);
|
||||
pathPen.setJoinStyle(Qt::RoundJoin);
|
||||
painter.setPen(pathPen);
|
||||
painter.drawPath(path);
|
||||
}
|
||||
};
|
||||
drawSourcePath(LocationSource::Trajectory, QColor(22, 163, 74));
|
||||
drawSourcePath(LocationSource::Generic, QColor(210, 105, 30));
|
||||
drawSourcePath(LocationSource::Gps, QColor(37, 99, 235));
|
||||
drawSourcePath(LocationSource::ImuCorrected, QColor(220, 38, 38));
|
||||
|
||||
drawAverageOverlay(painter);
|
||||
drawCepMarker(painter);
|
||||
|
||||
const bool drawNumbers = m_points.size() <= 150;
|
||||
const int currentPointIndex = preferredCurrentPointIndex(m_points);
|
||||
const int firstPoint = m_showRecentPoints ? 0 : currentPointIndex;
|
||||
const int endPoint = m_showRecentPoints ? m_points.size() : currentPointIndex + 1;
|
||||
for (int i = firstPoint; i < endPoint; ++i) {
|
||||
const bool isLast = i == currentPointIndex;
|
||||
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
|
||||
const double radius = isLast ? 8.0 : 5.0;
|
||||
|
||||
const QColor pointColor = m_points[i].source == LocationSource::ImuCorrected
|
||||
? QColor(220, 38, 38)
|
||||
: (m_points[i].source == LocationSource::Gps
|
||||
? QColor(37, 99, 235)
|
||||
: (m_points[i].source == LocationSource::Trajectory
|
||||
? QColor(22, 163, 74)
|
||||
: (isLast ? QColor(210, 105, 30) : QColor(234, 88, 12))));
|
||||
painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0));
|
||||
painter.setBrush(pointColor);
|
||||
painter.drawEllipse(screen, radius, radius);
|
||||
|
||||
if (drawNumbers) {
|
||||
painter.setPen(Qt::white);
|
||||
painter.setFont(QFont(painter.font().family(), 8, QFont::Bold));
|
||||
painter.drawText(QRectF(screen.x() - 13, screen.y() - 9, 26, 18), Qt::AlignCenter,
|
||||
QString::number(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
const TelemetryPoint &last = m_points[currentPointIndex];
|
||||
const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude);
|
||||
QStringList lines;
|
||||
const QString latitudeText = last.latitudeText.isEmpty()
|
||||
? QString::number(last.latitude, 'f', 15) : last.latitudeText;
|
||||
const QString longitudeText = last.longitudeText.isEmpty()
|
||||
? QString::number(last.longitude, 'f', 15) : last.longitudeText;
|
||||
lines << QStringLiteral("Lat: %1").arg(latitudeText);
|
||||
lines << QStringLiteral("Long: %1").arg(longitudeText);
|
||||
lines << QStringLiteral("Kaynak: %1").arg(locationSourceText(last.source));
|
||||
if (last.hheight.has_value()) {
|
||||
lines << QStringLiteral("HHeight: %1").arg(*last.hheight, 0, 'f', 2);
|
||||
}
|
||||
if (last.satellites.has_value()) {
|
||||
lines << QStringLiteral("Aktif uydu: %1").arg(*last.satellites);
|
||||
}
|
||||
if (m_cepRadiusMeters.has_value()) {
|
||||
lines << QStringLiteral("CEP50: %1 m").arg(*m_cepRadiusMeters, 0, 'f', 2);
|
||||
}
|
||||
|
||||
painter.setFont(QFont(painter.font().family(), 10));
|
||||
const QString label = lines.join('\n');
|
||||
const QRect textRect = painter.fontMetrics().boundingRect(QRect(0, 0, 300, 150),
|
||||
Qt::TextWordWrap, label);
|
||||
QRectF bubble(lastScreen + QPointF(14, -textRect.height() - 12),
|
||||
QSizeF(textRect.width() + 18, textRect.height() + 14));
|
||||
if (bubble.right() > width() - 8) {
|
||||
bubble.moveRight(width() - 8);
|
||||
}
|
||||
if (bubble.top() < 8) {
|
||||
bubble.moveTop(8);
|
||||
}
|
||||
|
||||
painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0));
|
||||
painter.setBrush(QColor(255, 255, 255, 232));
|
||||
painter.drawRoundedRect(bubble, 6, 6);
|
||||
painter.setPen(QColor(25, 32, 38));
|
||||
painter.drawText(bubble.adjusted(9, 7, -9, -7), Qt::AlignLeft | Qt::AlignVCenter, label);
|
||||
}
|
||||
|
||||
void MapWidget::drawLegend(QPainter &painter)
|
||||
{
|
||||
if (m_points.isEmpty() || !m_showTelemetryOverlay) {
|
||||
return;
|
||||
}
|
||||
const struct {
|
||||
const char *text;
|
||||
QColor color;
|
||||
} entries[] = {
|
||||
{"Trajectory (truth)", QColor(22, 163, 74)},
|
||||
{"GPS (4 satellite)", QColor(37, 99, 235)},
|
||||
{"IMU + ESKF corrected", QColor(220, 38, 38)}
|
||||
};
|
||||
const QRectF box(12.0, 12.0, 190.0, 76.0);
|
||||
painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0));
|
||||
painter.setBrush(QColor(255, 255, 255, 232));
|
||||
painter.drawRoundedRect(box, 6.0, 6.0);
|
||||
painter.setFont(QFont(painter.font().family(), 9, QFont::DemiBold));
|
||||
for (int index = 0; index < 3; ++index) {
|
||||
const double y = 27.0 + index * 22.0;
|
||||
painter.setPen(QPen(entries[index].color, 3.0));
|
||||
painter.drawLine(QPointF(23.0, y), QPointF(43.0, y));
|
||||
painter.setBrush(entries[index].color);
|
||||
painter.drawEllipse(QPointF(33.0, y), 4.0, 4.0);
|
||||
painter.setPen(QColor(25, 32, 38));
|
||||
painter.drawText(QRectF(50.0, y - 9.0, 142.0, 18.0),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
QString::fromLatin1(entries[index].text));
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::drawCepOverlay(QPainter &painter)
|
||||
{
|
||||
if (!m_hasCepMarker || !m_cepRadiusMeters.has_value() || *m_cepRadiusMeters <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude);
|
||||
const double resolution = metersPerPixel(m_cepLatitude, m_zoom);
|
||||
if (!std::isfinite(resolution) || resolution <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const double radiusPixels = std::min(*m_cepRadiusMeters / resolution, 2500.0);
|
||||
if (radiusPixels < 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
painter.setPen(QPen(QColor(8, 111, 104, 210), 2.0));
|
||||
painter.setBrush(QColor(13, 148, 136, 42));
|
||||
painter.drawEllipse(screen, radiusPixels, radiusPixels);
|
||||
}
|
||||
|
||||
void MapWidget::drawCepMarker(QPainter &painter)
|
||||
{
|
||||
if (!m_hasCepMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude);
|
||||
painter.setPen(QPen(QColor(8, 111, 104), 2.5));
|
||||
painter.setBrush(QColor(255, 255, 255, 230));
|
||||
painter.drawEllipse(screen, 7.0, 7.0);
|
||||
painter.drawLine(QPointF(screen.x() - 11.0, screen.y()),
|
||||
QPointF(screen.x() + 11.0, screen.y()));
|
||||
painter.drawLine(QPointF(screen.x(), screen.y() - 11.0),
|
||||
QPointF(screen.x(), screen.y() + 11.0));
|
||||
|
||||
const bool overlapsLast = !m_points.isEmpty()
|
||||
&& QLineF(screen, coordinateToScreen(m_points.last().latitude,
|
||||
m_points.last().longitude)).length() < 30.0;
|
||||
if (!overlapsLast) {
|
||||
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
||||
painter.setPen(QColor(20, 83, 78));
|
||||
painter.drawText(QRectF(screen + QPointF(12.0, -10.0), QSizeF(110.0, 20.0)),
|
||||
Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Bilinen konum"));
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::drawAverageOverlay(QPainter &painter)
|
||||
{
|
||||
if (!m_showAverageOverlay || !m_hasAverageMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QPointF screen = coordinateToScreen(m_averageLat, m_averageLon);
|
||||
const double resolution = metersPerPixel(m_averageLat, m_zoom);
|
||||
if (std::isfinite(resolution) && resolution > 0.0 && m_averageRadiusMeters > 0.0) {
|
||||
const double radiusPixels = std::min(m_averageRadiusMeters / resolution, 2500.0);
|
||||
if (radiusPixels >= 2.0) {
|
||||
painter.setPen(QPen(QColor(124, 58, 237, 150), 2.0, Qt::DashLine));
|
||||
painter.setBrush(QColor(124, 58, 237, 30));
|
||||
painter.drawEllipse(screen, radiusPixels, radiusPixels);
|
||||
}
|
||||
}
|
||||
|
||||
painter.setPen(QPen(Qt::white, 3.0));
|
||||
painter.setBrush(QColor(124, 58, 237));
|
||||
painter.drawEllipse(screen, 9.0, 9.0);
|
||||
|
||||
painter.setPen(QPen(QColor(25, 32, 38), 2.0));
|
||||
painter.drawLine(QPointF(screen.x() - 12.0, screen.y()), QPointF(screen.x() + 12.0, screen.y()));
|
||||
painter.drawLine(QPointF(screen.x(), screen.y() - 12.0), QPointF(screen.x(), screen.y() + 12.0));
|
||||
|
||||
const bool overlapsLast = !m_points.isEmpty()
|
||||
&& QLineF(screen, coordinateToScreen(m_points.last().latitude,
|
||||
m_points.last().longitude)).length() < 30.0;
|
||||
if (!overlapsLast) {
|
||||
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
||||
const QRectF labelRect(screen + QPointF(12.0, 8.0), QSizeF(72.0, 20.0));
|
||||
painter.setPen(QColor(25, 32, 38));
|
||||
painter.drawText(labelRect, Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Ortalama"));
|
||||
}
|
||||
}
|
||||
|
||||
void MapWidget::drawEmptyState(QPainter &painter)
|
||||
{
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setPen(QColor(83, 96, 110));
|
||||
painter.setFont(QFont(painter.font().family(), 13));
|
||||
painter.drawText(rect(), Qt::AlignCenter,
|
||||
QStringLiteral("Seri porttan lat / long / hheight bekleniyor"));
|
||||
}
|
||||
|
||||
void MapWidget::requestTile(int tileX, int tileY, int zoom)
|
||||
{
|
||||
const QString key = tileKey(zoom, tileX, tileY);
|
||||
if (m_tileCache.contains(key) || m_pendingTiles.contains(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingTiles.insert(key);
|
||||
const QUrl url(QStringLiteral("https://tile.openstreetmap.org/%1/%2/%3.png")
|
||||
.arg(zoom)
|
||||
.arg(tileX)
|
||||
.arg(tileY));
|
||||
QNetworkRequest request(url);
|
||||
request.setRawHeader("User-Agent", "RocketLocationPlotter/0.2 (Qt desktop application)");
|
||||
QNetworkReply *reply = m_network->get(request);
|
||||
reply->setProperty("tileKey", key);
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
const QString key = reply->property("tileKey").toString();
|
||||
m_pendingTiles.remove(key);
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
QPixmap pixmap;
|
||||
if (pixmap.loadFromData(reply->readAll())) {
|
||||
if (!m_tileCache.contains(key) && m_tileCache.size() >= MaxMemoryTileCount) {
|
||||
m_tileCache.erase(m_tileCache.begin());
|
||||
}
|
||||
m_tileCache.insert(key, pixmap);
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor)
|
||||
{
|
||||
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
|
||||
if (newZoom == m_zoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QPointF coordinateAtAnchor = screenToCoordinate(screenAnchor);
|
||||
m_zoom = newZoom;
|
||||
const QPointF anchorWorld = coordinateToWorld(coordinateAtAnchor.x(), coordinateAtAnchor.y(), m_zoom);
|
||||
const QPointF newCenterWorld = anchorWorld - (screenAnchor - QPointF(width() / 2.0, height() / 2.0));
|
||||
const QPointF newCenter = worldToCoordinate(newCenterWorld, m_zoom);
|
||||
m_centerLat = newCenter.x();
|
||||
m_centerLon = newCenter.y();
|
||||
update();
|
||||
}
|
||||
|
||||
void MapWidget::disableAutoFollowFromUserAction()
|
||||
{
|
||||
if (!m_autoFollow) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_autoFollow = false;
|
||||
emit autoFollowChanged(false);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include "TelemetryPoint.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QPixmap>
|
||||
#include <QPoint>
|
||||
#include <QSet>
|
||||
#include <QWidget>
|
||||
|
||||
#include <optional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QLabel;
|
||||
|
||||
class MapWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MapWidget(QWidget *parent = nullptr);
|
||||
|
||||
void setPoints(const QVector<TelemetryPoint> &points);
|
||||
void fitToPoints();
|
||||
void clearPoints();
|
||||
void setAverageMarker(double latitude, double longitude, double radiusMeters);
|
||||
void clearAverageMarker();
|
||||
void setCepMarker(double latitude, double longitude,
|
||||
const std::optional<double> &radiusMeters);
|
||||
void clearCepMarker();
|
||||
[[nodiscard]] QPointF centerCoordinate() const;
|
||||
|
||||
public slots:
|
||||
void setAutoFollow(bool enabled);
|
||||
void setTelemetryOverlayEnabled(bool enabled);
|
||||
void setAverageOverlayEnabled(bool enabled);
|
||||
void setRecentPointsEnabled(bool enabled);
|
||||
void zoomIn();
|
||||
void zoomOut();
|
||||
|
||||
signals:
|
||||
void autoFollowChanged(bool enabled);
|
||||
void referenceLocationSelected(double latitude, double longitude);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void wheelEvent(QWheelEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
|
||||
private:
|
||||
QPointF coordinateToWorld(double latitude, double longitude, int zoom) const;
|
||||
QPointF worldToCoordinate(const QPointF &world, int zoom) const;
|
||||
QPointF coordinateToScreen(double latitude, double longitude) const;
|
||||
QPointF screenToCoordinate(const QPointF &screen) const;
|
||||
QPointF topLeftWorld() const;
|
||||
|
||||
void drawTiles(QPainter &painter);
|
||||
void drawTelemetry(QPainter &painter);
|
||||
void drawLegend(QPainter &painter);
|
||||
void drawAverageOverlay(QPainter &painter);
|
||||
void drawCepOverlay(QPainter &painter);
|
||||
void drawCepMarker(QPainter &painter);
|
||||
void drawEmptyState(QPainter &painter);
|
||||
void requestTile(int tileX, int tileY, int zoom);
|
||||
void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor);
|
||||
void disableAutoFollowFromUserAction();
|
||||
|
||||
QVector<TelemetryPoint> m_points;
|
||||
QNetworkAccessManager *m_network = nullptr;
|
||||
QLabel *m_attributionLabel = nullptr;
|
||||
QHash<QString, QPixmap> m_tileCache;
|
||||
QSet<QString> m_pendingTiles;
|
||||
|
||||
double m_centerLat = 39.0;
|
||||
double m_centerLon = 35.0;
|
||||
int m_zoom = 6;
|
||||
bool m_autoFollow = true;
|
||||
bool m_showTelemetryOverlay = true;
|
||||
bool m_showAverageOverlay = true;
|
||||
bool m_showRecentPoints = true;
|
||||
bool m_hasAverageMarker = false;
|
||||
double m_averageLat = 0.0;
|
||||
double m_averageLon = 0.0;
|
||||
double m_averageRadiusMeters = 0.0;
|
||||
bool m_hasCepMarker = false;
|
||||
double m_cepLatitude = 0.0;
|
||||
double m_cepLongitude = 0.0;
|
||||
std::optional<double> m_cepRadiusMeters;
|
||||
bool m_dragging = false;
|
||||
QPoint m_lastMousePos;
|
||||
};
|
||||
@@ -0,0 +1,374 @@
|
||||
#include "RinexNavigation.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
|
||||
#include <zlib.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double GpsMu = 3.986005e14;
|
||||
constexpr double EarthRotation = 7.2921151467e-5;
|
||||
constexpr double GpsWeekSeconds = 604800.0;
|
||||
constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0;
|
||||
constexpr double CurrentGpsUtcOffsetSeconds = 18.0;
|
||||
constexpr int PositionUpdateIntervalMs = 60000;
|
||||
|
||||
double parseField(const QString &line, int field)
|
||||
{
|
||||
QString text = line.mid(4 + field * 19, 19).trimmed();
|
||||
text.replace(QChar('D'), QChar('E'), Qt::CaseInsensitive);
|
||||
bool ok = false;
|
||||
const double value = text.toDouble(&ok);
|
||||
return ok ? value : std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
bool finiteFields(std::initializer_list<double> values)
|
||||
{
|
||||
return std::all_of(values.begin(), values.end(), [](double value) {
|
||||
return std::isfinite(value);
|
||||
});
|
||||
}
|
||||
|
||||
QByteArray gunzip(const QByteArray &input)
|
||||
{
|
||||
if (input.size() < 2 || static_cast<unsigned char>(input[0]) != 0x1f
|
||||
|| static_cast<unsigned char>(input[1]) != 0x8b) {
|
||||
return input;
|
||||
}
|
||||
|
||||
z_stream stream{};
|
||||
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(input.constData()));
|
||||
stream.avail_in = static_cast<uInt>(input.size());
|
||||
if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QByteArray output;
|
||||
char buffer[64 * 1024];
|
||||
int result = Z_OK;
|
||||
while (result == Z_OK) {
|
||||
stream.next_out = reinterpret_cast<Bytef *>(buffer);
|
||||
stream.avail_out = sizeof(buffer);
|
||||
result = inflate(&stream, Z_NO_FLUSH);
|
||||
output.append(buffer, static_cast<int>(sizeof(buffer) - stream.avail_out));
|
||||
}
|
||||
inflateEnd(&stream);
|
||||
return result == Z_STREAM_END ? output : QByteArray{};
|
||||
}
|
||||
|
||||
QDateTime gpsEpoch()
|
||||
{
|
||||
return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC);
|
||||
}
|
||||
|
||||
} // 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<Ephemeris> ephemerides = parseEphemerides(rinex, &error);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
const QVector<SatellitePosition> positions = propagateEphemerides(
|
||||
selectEphemerides(ephemerides, now), now);
|
||||
if (positions.isEmpty()) {
|
||||
if (fallbackIndex < 2) {
|
||||
requestDay(date.addDays(-1), fallbackIndex + 1);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
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")
|
||||
.arg(positions.size())
|
||||
.arg(date.toString(QStringLiteral("dd.MM.yyyy")))
|
||||
.arg(now.toLocalTime().toString(
|
||||
QStringLiteral("HH:mm:ss t"))), false);
|
||||
}
|
||||
|
||||
bool RinexNavigation::loadCache()
|
||||
{
|
||||
QFile cache(cachePath());
|
||||
if (!cache.open(QIODevice::ReadOnly)) return false;
|
||||
QString error;
|
||||
QVector<Ephemeris> ephemerides = parseEphemerides(cache.readAll(), &error);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
const QVector<SatellitePosition> 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")
|
||||
.arg(positions.size())
|
||||
.arg(now.toLocalTime().toString(
|
||||
QStringLiteral("HH:mm:ss t"))), true);
|
||||
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<Ephemeris> selected = selectEphemerides(m_ephemerides, now);
|
||||
const QVector<SatellitePosition> current = propagateEphemerides(selected, now);
|
||||
if (current.isEmpty()) return;
|
||||
|
||||
const QVector<SatellitePosition> 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::Ephemeris> 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<Ephemeris> 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<int>(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<int>(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::Ephemeris> RinexNavigation::selectEphemerides(
|
||||
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc)
|
||||
{
|
||||
QHash<QString, Ephemeris> closest;
|
||||
QHash<QString, qint64> closestAge;
|
||||
// GPS navigation epochs are labelled in GPS system time, not UTC.
|
||||
const QDateTime whenGps = whenUtc.addSecs(
|
||||
static_cast<qint64>(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<Ephemeris> 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<SatellitePosition> RinexNavigation::propagateEphemerides(
|
||||
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc)
|
||||
{
|
||||
QVector<SatellitePosition> 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;
|
||||
const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe;
|
||||
double tk = gpsSeconds - absoluteToe;
|
||||
while (tk > HalfGpsWeek) tk -= GpsWeekSeconds;
|
||||
while (tk < -HalfGpsWeek) tk += GpsWeekSeconds;
|
||||
|
||||
const double a = eph.sqrtA * eph.sqrtA;
|
||||
const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN;
|
||||
const double meanAnomaly = eph.m0 + meanMotion * tk;
|
||||
double eccentricAnomaly = meanAnomaly;
|
||||
for (int iteration = 0; iteration < 12; ++iteration) {
|
||||
eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly);
|
||||
}
|
||||
|
||||
const double trueAnomaly = std::atan2(
|
||||
std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly),
|
||||
std::cos(eccentricAnomaly) - eph.eccentricity);
|
||||
const double phi = trueAnomaly + eph.omega;
|
||||
const double twoPhi = 2.0 * phi;
|
||||
const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi);
|
||||
const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly))
|
||||
+ eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi);
|
||||
const double inclination = eph.i0 + eph.idot * tk
|
||||
+ eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi);
|
||||
const double orbitalX = radius * std::cos(u);
|
||||
const double orbitalY = radius * std::sin(u);
|
||||
const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk
|
||||
- EarthRotation * eph.toe;
|
||||
|
||||
SatellitePosition result;
|
||||
result.prn = eph.prn;
|
||||
result.x = orbitalX * std::cos(ascendingNode)
|
||||
- orbitalY * std::cos(inclination) * std::sin(ascendingNode);
|
||||
result.y = orbitalX * std::sin(ascendingNode)
|
||||
+ orbitalY * std::cos(inclination) * std::cos(ascendingNode);
|
||||
result.z = orbitalY * std::sin(inclination);
|
||||
result.healthy = eph.healthy;
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<SatellitePosition> RinexNavigation::parseGpsNavigation(
|
||||
const QByteArray &rinex, const QDateTime &whenUtc, QString *error)
|
||||
{
|
||||
const QVector<Ephemeris> ephemerides = parseEphemerides(rinex, error);
|
||||
return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
#include "../src/MainWindow.h"
|
||||
#include "../src/GlobeWidget.h"
|
||||
#include "../src/MapWidget.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMetaObject>
|
||||
#include <QSpinBox>
|
||||
#include <QPushButton>
|
||||
#include <QRegularExpression>
|
||||
#include <QTableView>
|
||||
#include <QTabWidget>
|
||||
#include <QSplitter>
|
||||
#include <QTemporaryFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
namespace {
|
||||
|
||||
void sendLine(MainWindow &window, const QString &line)
|
||||
{
|
||||
const bool invoked = QMetaObject::invokeMethod(
|
||||
&window, "handleSerialLine", Qt::DirectConnection, Q_ARG(QString, line));
|
||||
assert(invoked);
|
||||
}
|
||||
|
||||
void flushUpdates(MainWindow &window)
|
||||
{
|
||||
const bool invoked = QMetaObject::invokeMethod(
|
||||
&window, "flushUiUpdates", Qt::DirectConnection);
|
||||
assert(invoked);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
qputenv("LOCATION_PLOTTER_PORT", "/dev/test-usb-uart");
|
||||
MainWindow window;
|
||||
qunsetenv("LOCATION_PLOTTER_PORT");
|
||||
|
||||
{
|
||||
QTemporaryDir uartLogDirectory;
|
||||
assert(uartLogDirectory.isValid());
|
||||
qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8());
|
||||
bool loggingStarted = false;
|
||||
assert(QMetaObject::invokeMethod(
|
||||
&window, "startUartLogging", Qt::DirectConnection,
|
||||
Q_RETURN_ARG(bool, loggingStarted),
|
||||
Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800)));
|
||||
assert(loggingStarted);
|
||||
sendLine(window, QStringLiteral("raw uart test line"));
|
||||
assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection));
|
||||
qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR");
|
||||
|
||||
const QStringList logFiles = QDir(uartLogDirectory.path()).entryList(
|
||||
{QStringLiteral("uart_*.log")}, QDir::Files);
|
||||
assert(logFiles.size() == 1);
|
||||
QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst()));
|
||||
assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text));
|
||||
const QString contents = QString::fromUtf8(logFile.readAll());
|
||||
assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800")));
|
||||
assert(contents.contains(QRegularExpression(
|
||||
QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)"))));
|
||||
assert(contents.contains(QStringLiteral("[SESSION END]")));
|
||||
}
|
||||
|
||||
auto *table = window.findChild<QTableView *>();
|
||||
auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput"));
|
||||
auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput"));
|
||||
auto *cepValue = window.findChild<QLabel *>(QStringLiteral("cepValue"));
|
||||
auto *maxPointsInput = window.findChild<QSpinBox *>(QStringLiteral("maxPointsInput"));
|
||||
auto *elevationMaskSpin = window.findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin"));
|
||||
auto *showTelemetry = window.findChild<QCheckBox *>(QStringLiteral("showTelemetryCheck"));
|
||||
auto *showRecentPoints = window.findChild<QCheckBox *>(QStringLiteral("showRecentPointsCheck"));
|
||||
auto *showAverage = window.findChild<QCheckBox *>(QStringLiteral("showAverageCheck"));
|
||||
auto *portSelector = window.findChild<QComboBox *>(QStringLiteral("portSelector"));
|
||||
auto *refreshRinexButton =
|
||||
window.findChild<QPushButton *>(QStringLiteral("refreshRinexButton"));
|
||||
auto *map = window.findChild<MapWidget *>();
|
||||
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
|
||||
auto *sideSplitter = window.findChild<QSplitter *>(QStringLiteral("sideSplitter"));
|
||||
assert(table != nullptr);
|
||||
assert(referenceLatInput != nullptr);
|
||||
assert(referenceLonInput != nullptr);
|
||||
assert(cepValue != nullptr);
|
||||
assert(maxPointsInput != nullptr);
|
||||
assert(elevationMaskSpin != nullptr);
|
||||
assert(elevationMaskSpin->value() == 10);
|
||||
assert(elevationMaskSpin->minimum() == 0);
|
||||
assert(elevationMaskSpin->maximum() == 90.0);
|
||||
elevationMaskSpin->setValue(12.5);
|
||||
assert(elevationMaskSpin->value() == 12.5);
|
||||
assert(showTelemetry != nullptr);
|
||||
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"));
|
||||
for (int i = 0; i < portSelector->count(); ++i) {
|
||||
assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/")));
|
||||
assert(!portSelector->itemText(i).trimmed().isEmpty());
|
||||
}
|
||||
assert(map != nullptr);
|
||||
assert(mainViewTabs != nullptr);
|
||||
assert(mainViewTabs->count() == 2);
|
||||
assert(sideSplitter != nullptr);
|
||||
assert(sideSplitter->orientation() == Qt::Vertical);
|
||||
|
||||
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
|
||||
assert(map != nullptr);
|
||||
auto *globe = window.findChild<GlobeWidget *>();
|
||||
assert(globe != nullptr);
|
||||
assert(globe->observedPrns()
|
||||
== QSet<QString>({QStringLiteral("G03"), QStringLiteral("G08")}));
|
||||
sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]"));
|
||||
assert(globe->observedPrns() == QSet<QString>({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"));
|
||||
assert(referenceLonInput->text() == QStringLiteral("29.500000000000000"));
|
||||
referenceLatInput->clear();
|
||||
referenceLonInput->clear();
|
||||
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
|
||||
showTelemetry->setChecked(false);
|
||||
assert(!showRecentPoints->isEnabled());
|
||||
assert(!showAverage->isEnabled());
|
||||
showTelemetry->setChecked(true);
|
||||
assert(showRecentPoints->isEnabled());
|
||||
assert(showAverage->isEnabled());
|
||||
|
||||
sendLine(window, QStringLiteral("lattitude : 40.78925034214206846173"));
|
||||
sendLine(window, QStringLiteral("longitude : 29.46277530841727809729"));
|
||||
assert(table->model()->rowCount() == 0);
|
||||
|
||||
sendLine(window, QStringLiteral("NAV solve done"));
|
||||
flushUpdates(window);
|
||||
assert(table->model()->rowCount() == 1);
|
||||
assert(table->model()->data(table->model()->index(0, 2)).toString()
|
||||
== QStringLiteral("40.78925034214206846173"));
|
||||
assert(table->model()->data(table->model()->index(0, 3)).toString()
|
||||
== QStringLiteral("29.46277530841727809729"));
|
||||
|
||||
sendLine(window, QStringLiteral(
|
||||
"[LOCATION] source=gps lat=40.1 lon=29.1 height=100 gdop=2 pdop=1.5 "
|
||||
"hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5"));
|
||||
sendLine(window, QStringLiteral(
|
||||
"[LOCATION] source=imu_corrected lat=40.10001 lon=29.10001 height=100.2 "
|
||||
"gdop=2 pdop=1.5 hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5"));
|
||||
flushUpdates(window);
|
||||
assert(table->model()->rowCount() == 3);
|
||||
assert(table->model()->data(table->model()->index(1, 1)).toString()
|
||||
== QStringLiteral("GPS"));
|
||||
assert(table->model()->data(table->model()->index(2, 1)).toString()
|
||||
== QStringLiteral("IMU corrected"));
|
||||
sendLine(window, QStringLiteral(
|
||||
"[LOCATION] source=trajectory lat=40.10002 lon=29.10002 height=100 tracked=4 used=4"));
|
||||
flushUpdates(window);
|
||||
assert(table->model()->rowCount() == 4);
|
||||
assert(table->model()->data(table->model()->index(3, 1)).toString()
|
||||
== QStringLiteral("Trajectory (truth)"));
|
||||
|
||||
QTemporaryFile replayLog;
|
||||
assert(replayLog.open());
|
||||
replayLog.write(
|
||||
"[LOCATION] source=trajectory lat=40.0 lon=29.0 height=100 tracked=4 used=4\n"
|
||||
"[LOCATION] source=gps lat=40.0001 lon=29.0001 height=104 tracked=4 used=4\n"
|
||||
"[LOCATION] source=imu_corrected lat=40.00002 lon=29.00002 height=101 tracked=4 used=4\n");
|
||||
replayLog.flush();
|
||||
assert(window.loadTelemetryLog(replayLog.fileName()));
|
||||
assert(table->model()->rowCount() == 3);
|
||||
assert(table->model()->data(table->model()->index(0, 1)).toString()
|
||||
== QStringLiteral("Trajectory (truth)"));
|
||||
|
||||
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
|
||||
sendLine(window, QStringLiteral(
|
||||
"lat=40.78925034214206846173 long=29.46277530841727809729"));
|
||||
flushUpdates(window);
|
||||
assert(table->model()->rowCount() == 1);
|
||||
|
||||
referenceLatInput->setText(QStringLiteral("40.78925034214206846173"));
|
||||
referenceLonInput->setText(QStringLiteral("29.46277530841727809729"));
|
||||
const bool referenceUpdated = QMetaObject::invokeMethod(
|
||||
&window, "updateReferenceLocation", Qt::DirectConnection);
|
||||
assert(referenceUpdated);
|
||||
assert(cepValue->text().startsWith(QStringLiteral("0.00 m")));
|
||||
assert(cepValue->text().contains(QStringLiteral("N=1")));
|
||||
|
||||
const bool cleared = QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection);
|
||||
assert(cleared);
|
||||
assert(table->model()->rowCount() == 0);
|
||||
assert(referenceLatInput->text() == QStringLiteral("40.78925034214206846173"));
|
||||
assert(referenceLonInput->text() == QStringLiteral("29.46277530841727809729"));
|
||||
assert(cepValue->text() == QStringLiteral("-"));
|
||||
|
||||
referenceLatInput->setText(QStringLiteral("40.0"));
|
||||
referenceLonInput->setText(QStringLiteral("29.0"));
|
||||
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
|
||||
sendLine(window, QStringLiteral("lat=40.000 long=29.000"));
|
||||
sendLine(window, QStringLiteral("lat=40.001 long=29.000"));
|
||||
sendLine(window, QStringLiteral("lat=40.002 long=29.000"));
|
||||
flushUpdates(window);
|
||||
assert(cepValue->text().startsWith(QStringLiteral("111 m")));
|
||||
assert(cepValue->text().contains(QStringLiteral("N=3")));
|
||||
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
|
||||
|
||||
referenceLatInput->setText(QStringLiteral("40.78925034214206846173"));
|
||||
referenceLonInput->setText(QStringLiteral("29.46277530841727809729"));
|
||||
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
|
||||
|
||||
maxPointsInput->setValue(10000);
|
||||
for (int i = 0; i < 10000; ++i) {
|
||||
sendLine(window, QStringLiteral("lat=40.78925 long=29.46277 sats=7"));
|
||||
}
|
||||
flushUpdates(window);
|
||||
assert(table->model()->rowCount() == 10000);
|
||||
assert(cepValue->text().contains(QStringLiteral("N=10000")));
|
||||
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"));
|
||||
sendLine(window, QStringLiteral("tracking PRNs: G03, G08, G14, G21, G27"));
|
||||
sendLine(window, QStringLiteral(
|
||||
"lat=40.78925034214206846173 long=29.46277530841727809729 "
|
||||
"hheight=-89.05779508221894502640 sats=7 PDOP=1.235"));
|
||||
flushUpdates(window);
|
||||
mainViewTabs->setCurrentIndex(1);
|
||||
window.show();
|
||||
app.processEvents();
|
||||
window.repaint();
|
||||
app.processEvents();
|
||||
assert(window.grab().save(QString::fromLocal8Bit(screenshotPath), "PNG"));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(location_plotter VERSION 0.3.0 LANGUAGES CXX)
|
||||
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "macos/ yalnizca macOS icin yapilandirilabilir")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
@@ -51,10 +55,6 @@ qt_add_resources(location_plotter "app_resources"
|
||||
FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png
|
||||
)
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
||||
endif()
|
||||
|
||||
enable_testing()
|
||||
|
||||
add_executable(location_parser_tests
|
||||
@@ -116,9 +116,6 @@ qt_add_resources(location_main_window_tests "test_app_resources"
|
||||
PREFIX "/"
|
||||
FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png
|
||||
)
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
||||
endif()
|
||||
add_test(NAME location_main_window_tests COMMAND location_main_window_tests)
|
||||
set_tests_properties(location_main_window_tests PROPERTIES
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache")
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# Location Plotter
|
||||
|
||||
Qt6/C++ GUI that listens to a USB UART on macOS, parses normal debug lines,
|
||||
and plots live locations on a map.
|
||||
|
||||
The application also includes an interactive 3D Earth/orbit view. It downloads the current
|
||||
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
|
||||
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
|
||||
file is cached for offline startup.
|
||||
|
||||
## Parsed fields
|
||||
|
||||
The parser accepts labeled values in the same line or in separate lines:
|
||||
|
||||
```text
|
||||
lat=41.0082 long=28.9784 hheight=123.45 sats=7
|
||||
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7
|
||||
active_sats=8
|
||||
navigation software running with 5 satellites
|
||||
GDOP : 3.9576744550
|
||||
PDOP : 3.2540598660
|
||||
HDOP : 2.3631161789
|
||||
VDOP : 2.2370935468
|
||||
TDOP : 2.2526165852
|
||||
tracking PRNs: G03, G08, 14, 21
|
||||
SV=G27
|
||||
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
|
||||
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4
|
||||
lattitude : 40.78925034214206846173
|
||||
longitude : 29.46277530841727809729
|
||||
height : -89.05779508221894502640
|
||||
```
|
||||
|
||||
Accepted location labels:
|
||||
|
||||
- Latitude: `lat`, `latitude`, `lattitude`
|
||||
- Longitude: `long`, `longitude`, `lon`, `lng`
|
||||
- Height: `hheight`, `height`, `alt`, `altitude`
|
||||
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
|
||||
`activeSats`, `satellites_active`, `num_sats`, `uydu`
|
||||
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
|
||||
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
|
||||
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
|
||||
- `tracked` is accepted as the active satellite count; `used` remains visible in
|
||||
the raw UART line and denotes satellites used by the WLS solution.
|
||||
|
||||
If a satellite count arrives without a location, the active satellite display is updated.
|
||||
If a location arrives without a satellite count, the latest known satellite count is attached
|
||||
to that point.
|
||||
If DOP values arrive before a location, the latest known values stay visible in the DOP
|
||||
section and are attached to the next plotted point.
|
||||
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
|
||||
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 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. 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.
|
||||
|
||||
## UART log replay
|
||||
|
||||
Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded
|
||||
UART `.log`/`.txt` file back through the same parser used by the live serial port.
|
||||
Choose the raw GPS update interval (default `200 ms`) and sensor/fused update
|
||||
interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop.
|
||||
|
||||
Recorder timestamps are optional and do not control replay speed. The replayer
|
||||
removes a leading timestamp when present, identifies `source=gps` as raw GPS and
|
||||
`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the
|
||||
two update timelines while retaining the original UART line order. Other UART
|
||||
diagnostic lines are forwarded with the surrounding location update.
|
||||
|
||||
## Sentetik ESKF senaryolarini açma
|
||||
|
||||
GPS deposundan 48 kombinasyonun replay loglarini üretin:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps
|
||||
make -C soc/software/fusion/tests scenario-logs
|
||||
```
|
||||
|
||||
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
|
||||
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
|
||||
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
|
||||
doğrudan açılabilir:
|
||||
|
||||
```bash
|
||||
cd location_plotter/macos
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
./build/location_plotter --replay \
|
||||
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
|
||||
```
|
||||
|
||||
Tek bir özel kombinasyon üretmek için:
|
||||
|
||||
```bash
|
||||
cd /home/onur/Desktop/gps/soc/software/fusion/tests
|
||||
make platform_scenario_matrix_test
|
||||
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
|
||||
```
|
||||
|
||||
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
|
||||
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
|
||||
|
||||
## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel)
|
||||
|
||||
Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan:
|
||||
|
||||
```bash
|
||||
cd location_plotter/macos
|
||||
chmod +x setup-macos.sh run.sh
|
||||
./setup-macos.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin
|
||||
`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine
|
||||
ekler ve testleri calistirir.
|
||||
|
||||
macOS USB UART aygitlarini genellikle iki adla gosterir:
|
||||
|
||||
```text
|
||||
/dev/cu.usbserial-XXXXXXXX
|
||||
/dev/tty.usbserial-XXXXXXXX
|
||||
```
|
||||
|
||||
veya bir CDC aygitinda:
|
||||
|
||||
```text
|
||||
/dev/cu.usbmodemXXXX
|
||||
/dev/tty.usbmodemXXXX
|
||||
```
|
||||
|
||||
Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta
|
||||
disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama
|
||||
acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik
|
||||
algilanmayan bir yol port alanina elle de yazilabilir.
|
||||
|
||||
Istenen port baslatmadan once ortam degiskeniyle de secilebilir:
|
||||
|
||||
```bash
|
||||
LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh
|
||||
```
|
||||
|
||||
Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup
|
||||
olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART
|
||||
donaniminizin macOS surucusunu kontrol edin.
|
||||
|
||||
## Elle derleme
|
||||
|
||||
Homebrew ile `cmake` ve `qt` kurulduktan sonra derleme ve test:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -DCMAKE_PREFIX_PATH="$(brew --prefix qt)"
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Uygulama Qt tabanli bir macOS masaustu arayuzudur. Harita karolari ve guncel
|
||||
RINEX verisi icin internet erisimi gerekir; son gecerli RINEX verisi ve harita
|
||||
onbellegiyle cevrimdisi kullanim da desteklenir. Port secici USB ile ilgili
|
||||
`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa
|
||||
`/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak gosterilir.
|
||||
|
||||
## Notes
|
||||
|
||||
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
|
||||
points are still drawn over a fallback grid.
|
||||
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
|
||||
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
|
||||
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
|
||||
Drag to rotate the globe and use the mouse wheel to zoom.
|
||||
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
|
||||
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
|
||||
(10 degrees by default, press Enter after typing);
|
||||
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
|
||||
receiver marker without resetting a globe orientation chosen by dragging.
|
||||
- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble
|
||||
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 10 FPS.
|
||||
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
|
||||
line shows the system-local time of the last successful download or cache load.
|
||||
- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the
|
||||
previous connected-PRN set, so satellites that were lost no longer remain green. Firmware
|
||||
channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`).
|
||||
- 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
|
||||
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
|
||||
be enlarged substantially.
|
||||
- `Max` controls how many recent points stay visible and are included in location
|
||||
analysis. The default is 10. When the limit is reached, older points are removed
|
||||
automatically.
|
||||
- Location analysis shows the average of the plotted samples, the last point's deviation
|
||||
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
|
||||
marker can be toggled on the map.
|
||||
- The map layers panel can hide all telemetry overlays, show only the newest location instead
|
||||
of the recent path, and independently toggle the average marker.
|
||||
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
|
||||
path are red. Synthetic ground-truth trajectory is green; the locations table
|
||||
shows the parsed source in its `Kaynak` column.
|
||||
- The application calculates empirical CEP50 as the median horizontal distance of the retained
|
||||
measurements from their spherical mean, then draws the circle around that measurement center.
|
||||
This is the radius containing 50 percent of the retained position fixes and is independent of
|
||||
the optional user reference.
|
||||
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
|
||||
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
|
||||
- Latitude and longitude tokens are preserved exactly as received for the table and map
|
||||
label. Calculations and map projection use IEEE 754 `double` precision.
|
||||
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
|
||||
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
|
||||
but the background tiles cannot add accuracy to the GNSS measurement.
|
||||
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
|
||||
cache in the application's standard cache directory.
|
||||
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
|
||||
rebuild the complete interface for every received line. The locations table uses a virtual
|
||||
model and remains usable with a high `Max` value.
|
||||
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
|
||||
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
|
||||
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
|
||||
- If no new plotted location arrives for 10 seconds while listening, current location
|
||||
points and analysis values are cleared.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
File diff suppressed because one or more lines are too long
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
print_qt_install_hint() {
|
||||
echo
|
||||
echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:"
|
||||
echo " brew install cmake qt"
|
||||
echo "veya dogrudan: ./setup-macos.sh"
|
||||
}
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "Bu calistirma betigi macOS icindir." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_dir="$(pwd -P)"
|
||||
|
||||
if [[ -f ./build/CMakeCache.txt ]]; then
|
||||
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
|
||||
if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then
|
||||
echo "Build klasoru farkli bir proje yoluna ait. 'macos/build' klasorunu silip tekrar deneyin." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cmake_args=(-S . -B build)
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)")
|
||||
fi
|
||||
|
||||
if ! cmake "${cmake_args[@]}"; then
|
||||
print_qt_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build build
|
||||
|
||||
exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@"
|
||||
@@ -22,8 +22,8 @@ parallel_jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)"
|
||||
if [[ -f build/CMakeCache.txt ]]; then
|
||||
cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)"
|
||||
if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then
|
||||
echo "Baska bir sisteme ait build klasoru yeniden olusturuluyor."
|
||||
rm -rf build
|
||||
echo "Build klasoru macOS disinda bir sisteme ait. 'macos/build' klasorunu silip tekrar deneyin." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#include "LocationParser.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
|
||||
double toDouble(QString value, bool *ok)
|
||||
{
|
||||
value.replace(',', '.');
|
||||
return value.toDouble(ok);
|
||||
}
|
||||
|
||||
QString normalizedCoordinateText(QString value)
|
||||
{
|
||||
value.replace(',', '.');
|
||||
if (value.startsWith('+')) {
|
||||
value.remove(0, 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
QString normalizeKey(QString key)
|
||||
{
|
||||
key = key.toLower();
|
||||
key.remove('_');
|
||||
key.remove('-');
|
||||
key.remove(' ');
|
||||
|
||||
if (key == "lat" || key == "latitude" || key == "lattitude") {
|
||||
return "latitude";
|
||||
}
|
||||
|
||||
if (key == "long" || key == "longitude" || key == "lon" || key == "lng") {
|
||||
return "longitude";
|
||||
}
|
||||
|
||||
if (key == "hheight" || key == "height" || key == "alt" || key == "altitude") {
|
||||
return "hheight";
|
||||
}
|
||||
|
||||
if (key == "sat" || key == "sats" || key == "satellite" || key == "satellites"
|
||||
|| key == "activesat" || key == "activesats" || key == "activesatellite"
|
||||
|| key == "activesatellites" || key == "numsat" || key == "numsats"
|
||||
|| key == "numbersat" || key == "numbersats" || key == "satactive"
|
||||
|| key == "satsactive" || key == "satelliteactive" || key == "satellitesactive"
|
||||
|| key == "uydu" || key == "uydular") {
|
||||
return "satellites";
|
||||
}
|
||||
|
||||
if (key == "tracked") {
|
||||
return "satellites";
|
||||
}
|
||||
|
||||
if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") {
|
||||
return key;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool isValidLatitude(double latitude)
|
||||
{
|
||||
return std::isfinite(latitude) && latitude >= -90.0 && latitude <= 90.0;
|
||||
}
|
||||
|
||||
bool isValidLongitude(double longitude)
|
||||
{
|
||||
return std::isfinite(longitude) && longitude >= -180.0 && longitude <= 180.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<ParsedTelemetry> LocationParser::parseLine(const QString &line)
|
||||
{
|
||||
const QString trimmed = line.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
static const QRegularExpression labeledValueRegex(
|
||||
QStringLiteral(R"((^|[^A-Za-z0-9_])(?<key>lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|tracked|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?<value>[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
static const QRegularExpression satelliteCountPhraseRegex(
|
||||
QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?<value>\d+)\s+(?:active\s+)?(?:sat(?:ellite)?s?|uydu(?:lar)?)(?=$|[\s,.;)\]]))"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
|
||||
ParsedTelemetry telemetry;
|
||||
telemetry.rawLine = trimmed;
|
||||
if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) {
|
||||
telemetry.source = LocationSource::ImuCorrected;
|
||||
}
|
||||
static const QRegularExpression sourceRegex(
|
||||
QStringLiteral(R"((?:^|\s)source\s*=\s*(?<source>gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const QRegularExpressionMatch sourceMatch = sourceRegex.match(trimmed);
|
||||
if (sourceMatch.hasMatch()) {
|
||||
const QString source = sourceMatch.captured(QStringLiteral("source"));
|
||||
if (source.startsWith(QStringLiteral("imu"), Qt::CaseInsensitive)) {
|
||||
telemetry.source = LocationSource::ImuCorrected;
|
||||
} else if (source.startsWith(QStringLiteral("trajectory"), Qt::CaseInsensitive)
|
||||
|| source.startsWith(QStringLiteral("ground"), Qt::CaseInsensitive)) {
|
||||
telemetry.source = LocationSource::Trajectory;
|
||||
} else {
|
||||
telemetry.source = LocationSource::Gps;
|
||||
}
|
||||
}
|
||||
static const QRegularExpression fusionModeRegex(
|
||||
QStringLiteral(R"((?:^|\s)mode\s*=\s*(?<mode>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*(?<age>\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;
|
||||
|
||||
auto matches = labeledValueRegex.globalMatch(trimmed);
|
||||
while (matches.hasNext()) {
|
||||
const QRegularExpressionMatch match = matches.next();
|
||||
const QString field = normalizeKey(match.captured(QStringLiteral("key")));
|
||||
if (field.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const double value = toDouble(match.captured(QStringLiteral("value")), &ok);
|
||||
if (!ok || !std::isfinite(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field == "latitude") {
|
||||
hadCoordinateField = true;
|
||||
hadLatitudeField = true;
|
||||
telemetry.latitude = value;
|
||||
telemetry.latitudeText = normalizedCoordinateText(
|
||||
match.captured(QStringLiteral("value")));
|
||||
} else if (field == "longitude") {
|
||||
hadCoordinateField = true;
|
||||
hadLongitudeField = true;
|
||||
telemetry.longitude = value;
|
||||
telemetry.longitudeText = normalizedCoordinateText(
|
||||
match.captured(QStringLiteral("value")));
|
||||
} else if (field == "hheight") {
|
||||
telemetry.hheight = value;
|
||||
} else if (field == "satellites") {
|
||||
const int count = static_cast<int>(std::llround(value));
|
||||
if (count >= 0) {
|
||||
telemetry.satellites = count;
|
||||
}
|
||||
} else if (field == "gdop") {
|
||||
if (value >= 0.0) {
|
||||
telemetry.dop.gdop = value;
|
||||
}
|
||||
} else if (field == "pdop") {
|
||||
if (value >= 0.0) {
|
||||
telemetry.dop.pdop = value;
|
||||
}
|
||||
} else if (field == "hdop") {
|
||||
if (value >= 0.0) {
|
||||
telemetry.dop.hdop = value;
|
||||
}
|
||||
} else if (field == "vdop") {
|
||||
if (value >= 0.0) {
|
||||
telemetry.dop.vdop = value;
|
||||
}
|
||||
} else if (field == "tdop") {
|
||||
if (value >= 0.0) {
|
||||
telemetry.dop.tdop = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto satellitePhraseMatches = satelliteCountPhraseRegex.globalMatch(trimmed);
|
||||
while (satellitePhraseMatches.hasNext()) {
|
||||
const QRegularExpressionMatch match = satellitePhraseMatches.next();
|
||||
bool ok = false;
|
||||
const int count = match.captured(QStringLiteral("value")).toInt(&ok);
|
||||
if (ok && count >= 0) {
|
||||
telemetry.satellites = count;
|
||||
}
|
||||
}
|
||||
|
||||
if (telemetry.latitude.has_value() && !isValidLatitude(*telemetry.latitude)) {
|
||||
telemetry.latitude.reset();
|
||||
telemetry.latitudeText.clear();
|
||||
}
|
||||
if (telemetry.longitude.has_value() && !isValidLongitude(*telemetry.longitude)) {
|
||||
telemetry.longitude.reset();
|
||||
telemetry.longitudeText.clear();
|
||||
}
|
||||
|
||||
if (hadLatitudeField && hadLongitudeField
|
||||
&& (!telemetry.hasLocation()
|
||||
|| !isValidCoordinate(*telemetry.latitude, *telemetry.longitude))) {
|
||||
telemetry.latitude.reset();
|
||||
telemetry.longitude.reset();
|
||||
telemetry.latitudeText.clear();
|
||||
telemetry.longitudeText.clear();
|
||||
telemetry.hheight.reset();
|
||||
} else if (hadCoordinateField && !telemetry.latitude.has_value()
|
||||
&& !telemetry.longitude.has_value()) {
|
||||
telemetry.hheight.reset();
|
||||
}
|
||||
|
||||
if (!telemetry.hasAnyValue()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
bool LocationParser::isValidCoordinate(double latitude, double longitude)
|
||||
{
|
||||
return std::isfinite(latitude) && std::isfinite(longitude)
|
||||
&& latitude >= -90.0 && latitude <= 90.0
|
||||
&& longitude >= -180.0 && longitude <= 180.0;
|
||||
}
|
||||
|
||||
QSet<QString> LocationParser::parseGpsPrns(const QString &line)
|
||||
{
|
||||
QSet<QString> result;
|
||||
const auto addPrn = [&result](QString value, bool zeroBased = false) {
|
||||
value = value.trimmed().toUpper();
|
||||
const bool explicitGpsPrn = value.startsWith(QLatin1Char('G'));
|
||||
if (explicitGpsPrn) value.remove(0, 1);
|
||||
bool ok = false;
|
||||
int number = value.toInt(&ok);
|
||||
if (ok && zeroBased && !explicitGpsPrn) ++number;
|
||||
if (ok && number >= 1 && number <= 63) {
|
||||
result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0')));
|
||||
}
|
||||
};
|
||||
|
||||
static const QRegularExpression gpsToken(
|
||||
QStringLiteral(R"((?:^|[^A-Za-z0-9])G(?<prn>\d{1,2})(?=$|[^0-9]))"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
auto gpsMatches = gpsToken.globalMatch(line);
|
||||
while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn")));
|
||||
|
||||
// Current firmware versions called their zero-based hardware channel values
|
||||
// "prns" in complete tracking snapshots. Keep accepting those messages while
|
||||
// newer firmware uses the unambiguous "prn_indices" label.
|
||||
static const QRegularExpression legacyTrackingIndices(
|
||||
QStringLiteral(R"(\btracked\s+satellites\s*=\s*\d+\s+prns?\s*=)"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch();
|
||||
|
||||
static const QRegularExpression labeledList(
|
||||
QStringLiteral(R"((?<label>\bPRN[_ -]?INDICES\b|\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
static const QRegularExpression number(QStringLiteral(R"(G?\d{1,2})"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
auto listMatches = labeledList.globalMatch(line);
|
||||
while (listMatches.hasNext()) {
|
||||
const QRegularExpressionMatch listMatch = listMatches.next();
|
||||
QString label = listMatch.captured(QStringLiteral("label")).toUpper();
|
||||
label.remove(QLatin1Char('_'));
|
||||
label.remove(QLatin1Char('-'));
|
||||
label.remove(QLatin1Char(' '));
|
||||
const bool zeroBased = label.startsWith(QStringLiteral("SVN"))
|
||||
|| label == QStringLiteral("PRNINDICES")
|
||||
|| (legacyZeroBasedPrns && label.startsWith(QStringLiteral("PRN")));
|
||||
auto values = number.globalMatch(listMatch.captured(QStringLiteral("list")));
|
||||
while (values.hasNext()) addPrn(values.next().captured(0), zeroBased);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "TelemetryPoint.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QSet>
|
||||
|
||||
#include <optional>
|
||||
|
||||
class LocationParser {
|
||||
public:
|
||||
static std::optional<ParsedTelemetry> parseLine(const QString &line);
|
||||
static QSet<QString> parseGpsPrns(const QString &line);
|
||||
|
||||
private:
|
||||
static bool isValidCoordinate(double latitude, double longitude);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include "SatelliteData.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
#include <QTimer>
|
||||
|
||||
class RinexNavigation : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit RinexNavigation(QObject *parent = nullptr);
|
||||
|
||||
void refresh();
|
||||
[[nodiscard]] bool isLoading() const { return m_loading; }
|
||||
|
||||
static QVector<SatellitePosition> parseGpsNavigation(
|
||||
const QByteArray &rinex, const QDateTime &whenUtc, QString *error = nullptr);
|
||||
|
||||
signals:
|
||||
void satellitesUpdated(const QVector<SatellitePosition> &satellites);
|
||||
void satelliteMotionUpdated(const QVector<SatellitePosition> ¤t,
|
||||
const QVector<SatellitePosition> &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<Ephemeris> parseEphemerides(
|
||||
const QByteArray &rinex, QString *error = nullptr);
|
||||
static QVector<Ephemeris> selectEphemerides(
|
||||
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc);
|
||||
static QVector<SatellitePosition> propagateEphemerides(
|
||||
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc);
|
||||
static SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc);
|
||||
|
||||
QNetworkAccessManager m_network;
|
||||
QTimer m_positionTimer;
|
||||
QVector<Ephemeris> m_ephemerides;
|
||||
bool m_loading = false;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct SatellitePosition {
|
||||
QString prn;
|
||||
double x = 0.0; // Earth-centred, Earth-fixed metres
|
||||
double y = 0.0;
|
||||
double z = 0.0;
|
||||
bool healthy = true;
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
#include "SerialReader.h"
|
||||
|
||||
#include <QSocketNotifier>
|
||||
#include <QTimer>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef Q_OS_MACOS
|
||||
#include <IOKit/serial/ioss.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr qsizetype MaxBytesPerReadCycle = 64 * 1024;
|
||||
|
||||
bool applyBaudRate(termios &options, int baudRate)
|
||||
{
|
||||
speed_t speed = 0;
|
||||
switch (baudRate) {
|
||||
case 1200:
|
||||
speed = B1200;
|
||||
break;
|
||||
case 2400:
|
||||
speed = B2400;
|
||||
break;
|
||||
case 4800:
|
||||
speed = B4800;
|
||||
break;
|
||||
case 9600:
|
||||
speed = B9600;
|
||||
break;
|
||||
case 19200:
|
||||
speed = B19200;
|
||||
break;
|
||||
case 38400:
|
||||
speed = B38400;
|
||||
break;
|
||||
case 57600:
|
||||
speed = B57600;
|
||||
break;
|
||||
case 115200:
|
||||
speed = B115200;
|
||||
break;
|
||||
#ifdef B230400
|
||||
case 230400:
|
||||
speed = B230400;
|
||||
break;
|
||||
#endif
|
||||
#ifdef B460800
|
||||
case 460800:
|
||||
speed = B460800;
|
||||
break;
|
||||
#endif
|
||||
#ifdef B921600
|
||||
case 921600:
|
||||
speed = B921600;
|
||||
break;
|
||||
#endif
|
||||
default:
|
||||
#ifdef Q_OS_MACOS
|
||||
// IOSSIOSPEED below applies the exact requested rate. A valid termios
|
||||
// placeholder is still required before that ioctl can be issued.
|
||||
speed = B9600;
|
||||
break;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
return ::cfsetispeed(&options, speed) == 0
|
||||
&& ::cfsetospeed(&options, speed) == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SerialReader::SerialReader(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_pollTimer = new QTimer(this);
|
||||
m_pollTimer->setInterval(20);
|
||||
connect(m_pollTimer, &QTimer::timeout, this, &SerialReader::handleReadyRead);
|
||||
|
||||
m_partialLineTimer = new QTimer(this);
|
||||
m_partialLineTimer->setSingleShot(true);
|
||||
m_partialLineTimer->setInterval(250);
|
||||
connect(m_partialLineTimer, &QTimer::timeout, this, &SerialReader::flushPartialLine);
|
||||
}
|
||||
|
||||
SerialReader::~SerialReader()
|
||||
{
|
||||
closePort();
|
||||
}
|
||||
|
||||
bool SerialReader::isOpen() const
|
||||
{
|
||||
return m_fd >= 0;
|
||||
}
|
||||
|
||||
QString SerialReader::portName() const
|
||||
{
|
||||
return m_portName;
|
||||
}
|
||||
|
||||
bool SerialReader::openPort(const QString &path, int baudRate)
|
||||
{
|
||||
closePort();
|
||||
|
||||
const QByteArray localPath = path.toLocal8Bit();
|
||||
m_fd = ::open(localPath.constData(), O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||
if (m_fd < 0) {
|
||||
emitSystemError(QStringLiteral("Port acilamadi"));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_portName = path;
|
||||
if (!configurePort(baudRate)) {
|
||||
closePort();
|
||||
return false;
|
||||
}
|
||||
setModemControlLines();
|
||||
|
||||
m_notifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this);
|
||||
connect(m_notifier, &QSocketNotifier::activated, this, &SerialReader::handleReadyRead);
|
||||
m_pollTimer->start();
|
||||
|
||||
emit statusChanged(QStringLiteral("%1 dinleniyor (%2 baud)").arg(path).arg(baudRate));
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialReader::closePort()
|
||||
{
|
||||
if (m_notifier != nullptr) {
|
||||
m_notifier->setEnabled(false);
|
||||
m_notifier->deleteLater();
|
||||
m_notifier = nullptr;
|
||||
}
|
||||
m_pollTimer->stop();
|
||||
m_partialLineTimer->stop();
|
||||
|
||||
if (m_fd >= 0) {
|
||||
::close(m_fd);
|
||||
m_fd = -1;
|
||||
}
|
||||
|
||||
m_buffer.clear();
|
||||
if (!m_portName.isEmpty()) {
|
||||
emit statusChanged(QStringLiteral("%1 kapatildi").arg(m_portName));
|
||||
}
|
||||
m_portName.clear();
|
||||
}
|
||||
|
||||
void SerialReader::handleReadyRead()
|
||||
{
|
||||
if (m_fd < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char data[4096];
|
||||
qsizetype bytesProcessed = 0;
|
||||
while (true) {
|
||||
const ssize_t bytesRead = ::read(m_fd, data, sizeof(data));
|
||||
if (bytesRead > 0) {
|
||||
bytesProcessed += static_cast<qsizetype>(bytesRead);
|
||||
m_buffer.append(data, static_cast<qsizetype>(bytesRead));
|
||||
processBuffer();
|
||||
|
||||
if (m_buffer.size() > 64 * 1024) {
|
||||
m_buffer.clear();
|
||||
emit errorOccurred(QStringLiteral("Satir sonu gelmeyen seri veri tamponu temizlendi"));
|
||||
}
|
||||
if (bytesProcessed >= MaxBytesPerReadCycle) {
|
||||
QTimer::singleShot(0, this, &SerialReader::handleReadyRead);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bytesRead == 0 || errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitSystemError(QStringLiteral("Seri port okuma hatasi"));
|
||||
closePort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SerialReader::flushPartialLine()
|
||||
{
|
||||
if (m_buffer.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray line = m_buffer;
|
||||
m_buffer.clear();
|
||||
while (line.endsWith('\r') || line.endsWith('\n')) {
|
||||
line.chop(1);
|
||||
}
|
||||
if (!line.isEmpty()) {
|
||||
emit lineReceived(QString::fromUtf8(line));
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialReader::configurePort(int baudRate)
|
||||
{
|
||||
termios options {};
|
||||
if (::tcgetattr(m_fd, &options) != 0) {
|
||||
emitSystemError(QStringLiteral("Port ayarlari okunamadi"));
|
||||
return false;
|
||||
}
|
||||
|
||||
::cfmakeraw(&options);
|
||||
if (!applyBaudRate(options, baudRate)) {
|
||||
emit errorOccurred(QStringLiteral("Bu sistemde desteklenmeyen baud rate: %1").arg(baudRate));
|
||||
return false;
|
||||
}
|
||||
|
||||
options.c_cflag |= CLOCAL | CREAD;
|
||||
options.c_cflag &= ~HUPCL;
|
||||
options.c_cflag &= ~PARENB;
|
||||
options.c_cflag &= ~CSTOPB;
|
||||
options.c_cflag &= ~CSIZE;
|
||||
options.c_cflag |= CS8;
|
||||
options.c_cc[VMIN] = 0;
|
||||
options.c_cc[VTIME] = 0;
|
||||
|
||||
if (::tcsetattr(m_fd, TCSANOW, &options) != 0) {
|
||||
emitSystemError(QStringLiteral("Port ayarlari uygulanamadi"));
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef Q_OS_MACOS
|
||||
speed_t requestedSpeed = static_cast<speed_t>(baudRate);
|
||||
if (::ioctl(m_fd, IOSSIOSPEED, &requestedSpeed) != 0) {
|
||||
emitSystemError(QStringLiteral("macOS baud rate uygulanamadi"));
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
::tcflush(m_fd, TCIFLUSH);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialReader::processBuffer()
|
||||
{
|
||||
bool emittedLine = false;
|
||||
|
||||
while (true) {
|
||||
qsizetype delimiterIndex = -1;
|
||||
for (qsizetype i = 0; i < m_buffer.size(); ++i) {
|
||||
if (m_buffer.at(i) == '\n' || m_buffer.at(i) == '\r') {
|
||||
delimiterIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (delimiterIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
QByteArray line = m_buffer.left(delimiterIndex);
|
||||
qsizetype removeCount = delimiterIndex + 1;
|
||||
while (removeCount < m_buffer.size()
|
||||
&& (m_buffer.at(removeCount) == '\n' || m_buffer.at(removeCount) == '\r')) {
|
||||
++removeCount;
|
||||
}
|
||||
m_buffer.remove(0, removeCount);
|
||||
|
||||
if (!line.isEmpty()) {
|
||||
emittedLine = true;
|
||||
emit lineReceived(QString::fromUtf8(line));
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_buffer.isEmpty()) {
|
||||
m_partialLineTimer->start();
|
||||
} else if (emittedLine) {
|
||||
m_partialLineTimer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void SerialReader::setModemControlLines()
|
||||
{
|
||||
int status = 0;
|
||||
if (::ioctl(m_fd, TIOCMGET, &status) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
status |= TIOCM_DTR;
|
||||
status |= TIOCM_RTS;
|
||||
::ioctl(m_fd, TIOCMSET, &status);
|
||||
}
|
||||
|
||||
void SerialReader::emitSystemError(const QString &prefix)
|
||||
{
|
||||
emit errorOccurred(QStringLiteral("%1: %2").arg(prefix, QString::fromLocal8Bit(std::strerror(errno))));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class QSocketNotifier;
|
||||
class QTimer;
|
||||
|
||||
class SerialReader : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SerialReader(QObject *parent = nullptr);
|
||||
~SerialReader() override;
|
||||
|
||||
[[nodiscard]] bool isOpen() const;
|
||||
[[nodiscard]] QString portName() const;
|
||||
|
||||
public slots:
|
||||
bool openPort(const QString &path, int baudRate);
|
||||
void closePort();
|
||||
|
||||
signals:
|
||||
void lineReceived(const QString &line);
|
||||
void statusChanged(const QString &status);
|
||||
void errorOccurred(const QString &message);
|
||||
|
||||
private slots:
|
||||
void handleReadyRead();
|
||||
void flushPartialLine();
|
||||
|
||||
private:
|
||||
bool configurePort(int baudRate);
|
||||
void processBuffer();
|
||||
void setModemControlLines();
|
||||
void emitSystemError(const QString &prefix);
|
||||
|
||||
int m_fd = -1;
|
||||
QSocketNotifier *m_notifier = nullptr;
|
||||
QTimer *m_pollTimer = nullptr;
|
||||
QTimer *m_partialLineTimer = nullptr;
|
||||
QByteArray m_buffer;
|
||||
QString m_portName;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
enum class LocationSource {
|
||||
Generic,
|
||||
Gps,
|
||||
ImuCorrected,
|
||||
Trajectory
|
||||
};
|
||||
|
||||
enum class FusionMode {
|
||||
Unknown,
|
||||
GpsAided,
|
||||
DeadReckoning
|
||||
};
|
||||
|
||||
inline QString locationSourceText(LocationSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case LocationSource::Gps:
|
||||
return QStringLiteral("GPS");
|
||||
case LocationSource::ImuCorrected:
|
||||
return QStringLiteral("IMU corrected");
|
||||
case LocationSource::Trajectory:
|
||||
return QStringLiteral("Trajectory (truth)");
|
||||
case LocationSource::Generic:
|
||||
default:
|
||||
return QStringLiteral("Generic");
|
||||
}
|
||||
}
|
||||
|
||||
struct DopValues {
|
||||
std::optional<double> gdop;
|
||||
std::optional<double> pdop;
|
||||
std::optional<double> hdop;
|
||||
std::optional<double> vdop;
|
||||
std::optional<double> tdop;
|
||||
|
||||
[[nodiscard]] bool hasAnyValue() const
|
||||
{
|
||||
return gdop.has_value() || pdop.has_value() || hdop.has_value()
|
||||
|| vdop.has_value() || tdop.has_value();
|
||||
}
|
||||
};
|
||||
|
||||
struct TelemetryPoint {
|
||||
LocationSource source = LocationSource::Generic;
|
||||
FusionMode fusionMode = FusionMode::Unknown;
|
||||
std::optional<qint64> gpsAgeMs;
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
QString latitudeText;
|
||||
QString longitudeText;
|
||||
std::optional<double> hheight;
|
||||
std::optional<int> satellites;
|
||||
DopValues dop;
|
||||
QDateTime receivedAt = QDateTime::currentDateTimeUtc();
|
||||
QString rawLine;
|
||||
};
|
||||
|
||||
struct ParsedTelemetry {
|
||||
LocationSource source = LocationSource::Generic;
|
||||
FusionMode fusionMode = FusionMode::Unknown;
|
||||
std::optional<qint64> gpsAgeMs;
|
||||
std::optional<double> latitude;
|
||||
std::optional<double> longitude;
|
||||
QString latitudeText;
|
||||
QString longitudeText;
|
||||
std::optional<double> hheight;
|
||||
std::optional<int> satellites;
|
||||
DopValues dop;
|
||||
QString rawLine;
|
||||
|
||||
[[nodiscard]] bool hasLocation() const
|
||||
{
|
||||
return latitude.has_value() && longitude.has_value();
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasAnyValue() const
|
||||
{
|
||||
return latitude.has_value() || longitude.has_value() || hheight.has_value()
|
||||
|| satellites.has_value() || dop.hasAnyValue();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
#include "UartReplay.h"
|
||||
|
||||
#include "LocationParser.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
UartReplay::UartReplay(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_timer(new QTimer(this))
|
||||
{
|
||||
m_timer->setSingleShot(true);
|
||||
connect(m_timer, &QTimer::timeout, this, &UartReplay::emitNext);
|
||||
}
|
||||
|
||||
bool UartReplay::isRunning() const
|
||||
{
|
||||
return m_running;
|
||||
}
|
||||
|
||||
int UartReplay::locationCount() const
|
||||
{
|
||||
return m_locationCount;
|
||||
}
|
||||
|
||||
bool UartReplay::start(const QString &filePath, int gpsIntervalMs, int sensorIntervalMs)
|
||||
{
|
||||
stop();
|
||||
|
||||
if (gpsIntervalMs < 1 || sensorIntervalMs < 1) {
|
||||
emit errorOccurred(QStringLiteral("Replay araliklari en az 1 ms olmalidir"));
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
emit errorOccurred(QStringLiteral("UART replay logu acilamadi: %1")
|
||||
.arg(file.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
qint64 nextGpsMs = 0;
|
||||
qint64 nextSensorMs = 0;
|
||||
qint64 lastDueMs = 0;
|
||||
bool pendingLatitude = false;
|
||||
bool pendingLongitude = false;
|
||||
LocationSource pendingSource = LocationSource::Generic;
|
||||
const QList<QByteArray> rawLines = file.readAll().split('\n');
|
||||
m_lines.reserve(rawLines.size());
|
||||
|
||||
for (const QByteArray &rawLine : rawLines) {
|
||||
const QString line = removeRecorderTimestamp(QString::fromUtf8(rawLine));
|
||||
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))
|
||||
|| line.startsWith(QStringLiteral("[SESSION START]"))
|
||||
|| line.startsWith(QStringLiteral("[SESSION END]"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Stream stream = classifyLine(line);
|
||||
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
|
||||
if (telemetry.has_value() && telemetry->hasLocation()) {
|
||||
pendingLatitude = false;
|
||||
pendingLongitude = false;
|
||||
pendingSource = LocationSource::Generic;
|
||||
} else if (telemetry.has_value()) {
|
||||
if (telemetry->latitude.has_value()) {
|
||||
if (pendingLatitude) {
|
||||
pendingLongitude = false;
|
||||
}
|
||||
pendingLatitude = true;
|
||||
pendingSource = telemetry->source;
|
||||
}
|
||||
if (telemetry->longitude.has_value()) {
|
||||
pendingLongitude = true;
|
||||
if (telemetry->source != LocationSource::Generic) {
|
||||
pendingSource = telemetry->source;
|
||||
}
|
||||
}
|
||||
if (pendingLatitude && pendingLongitude) {
|
||||
stream = pendingSource == LocationSource::ImuCorrected
|
||||
|| pendingSource == LocationSource::Trajectory
|
||||
? Stream::Sensor : Stream::Gps;
|
||||
pendingLatitude = false;
|
||||
pendingLongitude = false;
|
||||
pendingSource = LocationSource::Generic;
|
||||
}
|
||||
}
|
||||
qint64 dueMs = lastDueMs;
|
||||
if (stream == Stream::Gps) {
|
||||
dueMs = std::max(lastDueMs, nextGpsMs);
|
||||
nextGpsMs = dueMs + gpsIntervalMs;
|
||||
++m_locationCount;
|
||||
} else if (stream == Stream::Sensor) {
|
||||
dueMs = std::max(lastDueMs, nextSensorMs);
|
||||
nextSensorMs = dueMs + sensorIntervalMs;
|
||||
++m_locationCount;
|
||||
}
|
||||
|
||||
ReplayLine replayLine;
|
||||
replayLine.text = line;
|
||||
replayLine.delayBeforeMs = static_cast<int>(std::min<qint64>(
|
||||
dueMs - lastDueMs, std::numeric_limits<int>::max()));
|
||||
m_lines.push_back(std::move(replayLine));
|
||||
lastDueMs = dueMs;
|
||||
}
|
||||
|
||||
if (m_locationCount == 0) {
|
||||
m_lines.clear();
|
||||
emit errorOccurred(QStringLiteral(
|
||||
"UART replay logunda GPS veya fused konum kaydi bulunamadi"));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_running = true;
|
||||
emit replayStarted(m_lines.size(), m_locationCount);
|
||||
m_timer->start(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void UartReplay::stop()
|
||||
{
|
||||
if (!m_running && m_lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
m_timer->stop();
|
||||
m_running = false;
|
||||
m_lines.clear();
|
||||
m_index = 0;
|
||||
m_locationCount = 0;
|
||||
}
|
||||
|
||||
void UartReplay::emitNext()
|
||||
{
|
||||
if (!m_running) {
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr int MaxImmediateLines = 250;
|
||||
int emitted = 0;
|
||||
while (m_index < m_lines.size() && emitted < MaxImmediateLines) {
|
||||
const ReplayLine &line = m_lines.at(m_index);
|
||||
if (line.delayBeforeMs > 0) {
|
||||
if (emitted > 0) {
|
||||
emit progressChanged(m_index, m_lines.size());
|
||||
}
|
||||
const int delay = line.delayBeforeMs;
|
||||
m_lines[m_index].delayBeforeMs = 0;
|
||||
m_timer->start(delay);
|
||||
return;
|
||||
}
|
||||
|
||||
emit lineReady(line.text);
|
||||
++m_index;
|
||||
++emitted;
|
||||
}
|
||||
|
||||
emit progressChanged(m_index, m_lines.size());
|
||||
if (m_index >= m_lines.size()) {
|
||||
finish();
|
||||
} else {
|
||||
m_timer->start(0);
|
||||
}
|
||||
}
|
||||
|
||||
QString UartReplay::removeRecorderTimestamp(const QString &line)
|
||||
{
|
||||
QString result = line.trimmed();
|
||||
static const QRegularExpression timestampPrefix(
|
||||
QStringLiteral(R"(^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?\]\s*)"));
|
||||
result.remove(timestampPrefix);
|
||||
return result.trimmed();
|
||||
}
|
||||
|
||||
UartReplay::Stream UartReplay::classifyLine(const QString &line)
|
||||
{
|
||||
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
|
||||
if (!telemetry.has_value() || !telemetry->hasLocation()) {
|
||||
return Stream::Diagnostic;
|
||||
}
|
||||
|
||||
switch (telemetry->source) {
|
||||
case LocationSource::Gps:
|
||||
return Stream::Gps;
|
||||
case LocationSource::ImuCorrected:
|
||||
case LocationSource::Trajectory:
|
||||
return Stream::Sensor;
|
||||
case LocationSource::Generic:
|
||||
default:
|
||||
// Older firmware used a [FUSION] record without an explicit source.
|
||||
return line.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)
|
||||
? Stream::Sensor : Stream::Gps;
|
||||
}
|
||||
}
|
||||
|
||||
void UartReplay::finish()
|
||||
{
|
||||
m_timer->stop();
|
||||
m_running = false;
|
||||
m_lines.clear();
|
||||
m_index = 0;
|
||||
m_locationCount = 0;
|
||||
emit replayFinished();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class QTimer;
|
||||
|
||||
class UartReplay final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit UartReplay(QObject *parent = nullptr);
|
||||
|
||||
[[nodiscard]] bool isRunning() const;
|
||||
[[nodiscard]] int locationCount() const;
|
||||
|
||||
public slots:
|
||||
bool start(const QString &filePath, int gpsIntervalMs = 200,
|
||||
int sensorIntervalMs = 50);
|
||||
void stop();
|
||||
|
||||
signals:
|
||||
void lineReady(const QString &line);
|
||||
void replayStarted(int lineCount, int locationCount);
|
||||
void progressChanged(int linesProcessed, int lineCount);
|
||||
void replayFinished();
|
||||
void errorOccurred(const QString &message);
|
||||
|
||||
private slots:
|
||||
void emitNext();
|
||||
|
||||
private:
|
||||
enum class Stream {
|
||||
Diagnostic,
|
||||
Gps,
|
||||
Sensor
|
||||
};
|
||||
|
||||
struct ReplayLine {
|
||||
QString text;
|
||||
int delayBeforeMs = 0;
|
||||
};
|
||||
|
||||
static QString removeRecorderTimestamp(const QString &line);
|
||||
static Stream classifyLine(const QString &line);
|
||||
void finish();
|
||||
|
||||
QTimer *m_timer = nullptr;
|
||||
QVector<ReplayLine> m_lines;
|
||||
int m_index = 0;
|
||||
int m_locationCount = 0;
|
||||
bool m_running = false;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFileInfo>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName(QStringLiteral("location_plotter"));
|
||||
QApplication::setOrganizationName(QStringLiteral("rocket"));
|
||||
|
||||
MainWindow window;
|
||||
if (argc == 3 && QString::fromLocal8Bit(argv[1]) == QStringLiteral("--replay")) {
|
||||
window.loadTelemetryLog(QFileInfo(QString::fromLocal8Bit(argv[2])).absoluteFilePath());
|
||||
}
|
||||
window.show();
|
||||
|
||||
return QApplication::exec();
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#include "../src/LocationParser.h"
|
||||
#include "../src/RinexNavigation.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
|
||||
bool closeTo(double left, double right, double tolerance = 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<double> 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<double>(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
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("DBG lat=41.0082000 long=28.9784000 hheight=123.45 sats=7"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->hasLocation());
|
||||
assert(closeTo(*parsed->latitude, 41.0082));
|
||||
assert(closeTo(*parsed->longitude, 28.9784));
|
||||
assert(closeTo(*parsed->hheight, 123.45));
|
||||
assert(parsed->satellites == 7);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("latitude: -12,5 longitude: 33,25 height: 99 activeSats: 4"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->hasLocation());
|
||||
assert(closeTo(*parsed->latitude, -12.5));
|
||||
assert(closeTo(*parsed->longitude, 33.25));
|
||||
assert(parsed->latitudeText == QStringLiteral("-12.5"));
|
||||
assert(parsed->longitudeText == QStringLiteral("33.25"));
|
||||
assert(closeTo(*parsed->hheight, 99.0));
|
||||
assert(parsed->satellites == 4);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral("satellites_active=11"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(parsed->satellites == 11);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("lat=200 long=28 hheight=100 active_sats=8"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(parsed->latitudeText.isEmpty());
|
||||
assert(parsed->longitudeText.isEmpty());
|
||||
assert(!parsed->hheight.has_value());
|
||||
assert(parsed->satellites == 8);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("lattitude : 40.78925034214206846173"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(closeTo(*parsed->latitude, 40.78925034214206846173));
|
||||
assert(parsed->latitudeText == QStringLiteral("40.78925034214206846173"));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("longitude : 29.46277530841727809729"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(closeTo(*parsed->longitude, 29.46277530841727809729));
|
||||
assert(parsed->longitudeText == QStringLiteral("29.46277530841727809729"));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("height : -89.05779508221894502640 "));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(closeTo(*parsed->hheight, -89.05779508221894502640));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("NAV solve start sats:4 mode:periodic abs_span_ms:2.176"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(parsed->satellites == 4);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("[NAV] navigation software running with 5 satellites"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(parsed->satellites == 5);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral("GDOP : 3.9576744550"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(closeTo(*parsed->dop.gdop, 3.9576744550));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(
|
||||
QStringLiteral("PDOP : 3.2540598660 HDOP : 2.3631161789 VDOP : 2.2370935468 TDOP : 2.2526165852"));
|
||||
assert(parsed.has_value());
|
||||
assert(!parsed->hasLocation());
|
||||
assert(closeTo(*parsed->dop.pdop, 3.2540598660));
|
||||
assert(closeTo(*parsed->dop.hdop, 2.3631161789));
|
||||
assert(closeTo(*parsed->dop.vdop, 2.2370935468));
|
||||
assert(closeTo(*parsed->dop.tdop, 2.2526165852));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral("plain debug noise"));
|
||||
assert(!parsed.has_value());
|
||||
}
|
||||
|
||||
{
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||
QStringLiteral("tracking PRNs: 3, G08, 14; SV=G21 SVID: 27"));
|
||||
assert(prns.contains(QStringLiteral("G03")));
|
||||
assert(prns.contains(QStringLiteral("G08")));
|
||||
assert(prns.contains(QStringLiteral("G14")));
|
||||
assert(prns.contains(QStringLiteral("G21")));
|
||||
assert(prns.contains(QStringLiteral("G27")));
|
||||
assert(prns.size() == 5);
|
||||
}
|
||||
|
||||
{
|
||||
const QString line = QStringLiteral(
|
||||
"[OK] [TRK] tracked satellites=5 svn=[2,3,5,8,30]");
|
||||
const auto parsed = LocationParser::parseLine(line);
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->satellites == 5);
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(line);
|
||||
assert(prns == QSet<QString>({QStringLiteral("G03"), QStringLiteral("G04"),
|
||||
QStringLiteral("G06"), QStringLiteral("G09"),
|
||||
QStringLiteral("G31")}));
|
||||
}
|
||||
|
||||
{
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||
QStringLiteral("[TRK] tracked satellites=2 svn=[0,31]"));
|
||||
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")}));
|
||||
}
|
||||
|
||||
{
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||
QStringLiteral("[TRK] tracked satellites=3 prn_indices=[0,7,31]"));
|
||||
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08"),
|
||||
QStringLiteral("G32")}));
|
||||
}
|
||||
|
||||
{
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||
QStringLiteral("[TRK] tracked satellites=2 prns=[0,7]"));
|
||||
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
|
||||
}
|
||||
|
||||
{
|
||||
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||
QStringLiteral("[TRK] tracked satellites=2 prn_indices=[0,G08]"));
|
||||
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 "
|
||||
"HDOP=2.9819528958 VDOP=4.7794348204 TDOP=4.3063035967"));
|
||||
assert(parsed.has_value());
|
||||
assert(closeTo(*parsed->dop.gdop, 7.0907891622));
|
||||
assert(closeTo(*parsed->dop.pdop, 5.6333862175));
|
||||
assert(closeTo(*parsed->dop.hdop, 2.9819528958));
|
||||
assert(closeTo(*parsed->dop.vdop, 4.7794348204));
|
||||
assert(closeTo(*parsed->dop.tdop, 4.3063035967));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[OK] [NAV] position lat=40.77831900791127850425 "
|
||||
"lon=29.43579105837637044374 height=198.80186179187148809433"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->hasLocation());
|
||||
assert(closeTo(*parsed->latitude, 40.77831900791127850425));
|
||||
assert(closeTo(*parsed->longitude, 29.43579105837637044374));
|
||||
assert(closeTo(*parsed->hheight, 198.80186179187148809433));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[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));
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[LOCATION] source=gps lat=40.7357 lon=31.5986 height=811 tracked=5 used=5"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->source == LocationSource::Gps);
|
||||
assert(parsed->satellites == 5);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[FUSION] state=moving lat=40.7357 lon=31.5986 height=811"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->hasLocation());
|
||||
assert(parsed->source == LocationSource::ImuCorrected);
|
||||
}
|
||||
|
||||
{
|
||||
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||
"[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4"));
|
||||
assert(parsed.has_value());
|
||||
assert(parsed->hasLocation());
|
||||
assert(parsed->source == LocationSource::Trajectory);
|
||||
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<qint64>(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));
|
||||
|
||||
// A local wall-clock representation must not move the satellites.
|
||||
// 11:48 at UTC+03 and 08:48 UTC represent the same physical instant.
|
||||
const auto localTimeSatellites = RinexNavigation::parseGpsNavigation(
|
||||
syntheticGpsNavigation(gpsLabel, gpsWeek, toe),
|
||||
matchingUtc.toOffsetFromUtc(3 * 60 * 60), &error);
|
||||
assert(error.isEmpty());
|
||||
assert(localTimeSatellites.size() == satellites.size());
|
||||
assert(closeTo(localTimeSatellites[0].x, satellites[0].x, 0.001));
|
||||
assert(closeTo(localTimeSatellites[0].y, satellites[0].y, 0.001));
|
||||
assert(closeTo(localTimeSatellites[0].z, satellites[0].z, 0.001));
|
||||
}
|
||||
|
||||
const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX");
|
||||
if (!rinexPath.isEmpty()) {
|
||||
QFile file(rinexPath);
|
||||
assert(file.open(QIODevice::ReadOnly));
|
||||
QString error;
|
||||
const auto satellites = RinexNavigation::parseGpsNavigation(
|
||||
file.readAll(), QDateTime::currentDateTimeUtc(), &error);
|
||||
assert(satellites.size() >= 20);
|
||||
for (const auto &satellite : satellites) {
|
||||
assert(satellite.prn.startsWith(QStringLiteral("G")));
|
||||
assert(std::isfinite(satellite.x));
|
||||
assert(std::isfinite(satellite.y));
|
||||
assert(std::isfinite(satellite.z));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "../src/UartReplay.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QElapsedTimer>
|
||||
#include <QEventLoop>
|
||||
#include <QTemporaryFile>
|
||||
#include <QTimer>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
QTemporaryFile log;
|
||||
assert(log.open());
|
||||
log.write(
|
||||
"[2026-08-12 10:00:00.000] [SESSION START] port=/dev/test baud=460800\n"
|
||||
"[2026-08-12 10:00:00.001] [LOCATION] source=gps lat=40.0 lon=29.0\n"
|
||||
"diagnostic line without timestamp\n"
|
||||
"[LOCATION] source=imu_corrected lat=40.1 lon=29.1\n"
|
||||
"[FUSION] lat=40.2 lon=29.2 height=10\n"
|
||||
"[LOCATION] source=gps lat=40.3 lon=29.3\n"
|
||||
"[2026-08-12 10:00:00.999] [SESSION END]\n");
|
||||
log.flush();
|
||||
|
||||
UartReplay replay;
|
||||
QStringList received;
|
||||
QVector<qint64> receivedAt;
|
||||
QElapsedTimer elapsed;
|
||||
QEventLoop loop;
|
||||
QObject::connect(&replay, &UartReplay::lineReady, &loop, [&](const QString &line) {
|
||||
received.push_back(line);
|
||||
receivedAt.push_back(elapsed.elapsed());
|
||||
});
|
||||
QObject::connect(&replay, &UartReplay::replayFinished, &loop, &QEventLoop::quit);
|
||||
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
|
||||
|
||||
elapsed.start();
|
||||
assert(replay.start(log.fileName(), 20, 10));
|
||||
assert(replay.locationCount() == 4);
|
||||
loop.exec();
|
||||
|
||||
assert(!replay.isRunning());
|
||||
assert(received.size() == 5);
|
||||
assert(received.first().startsWith(QStringLiteral("[LOCATION] source=gps")));
|
||||
assert(received.at(1) == QStringLiteral("diagnostic line without timestamp"));
|
||||
assert(received.at(3).startsWith(QStringLiteral("[FUSION]")));
|
||||
assert(receivedAt.last() >= 15);
|
||||
|
||||
QTemporaryFile splitLog;
|
||||
assert(splitLog.open());
|
||||
splitLog.write("latitude: 40.25\nlongitude: 29.50\n");
|
||||
splitLog.flush();
|
||||
received.clear();
|
||||
receivedAt.clear();
|
||||
elapsed.restart();
|
||||
assert(replay.start(splitLog.fileName(), 1, 1));
|
||||
assert(replay.locationCount() == 1);
|
||||
loop.exec();
|
||||
assert(received.size() == 2);
|
||||
|
||||
QTemporaryFile invalidLog;
|
||||
assert(invalidLog.open());
|
||||
invalidLog.write("only diagnostics\n");
|
||||
invalidLog.flush();
|
||||
assert(!replay.start(invalidLog.fileName(), 200, 50));
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
print_qt_install_hint() {
|
||||
echo
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:"
|
||||
echo " brew install cmake qt"
|
||||
echo "veya doğrudan: ./setup-macos.sh"
|
||||
else
|
||||
echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:"
|
||||
echo " sudo apt update"
|
||||
echo " sudo apt install build-essential cmake qt6-base-dev"
|
||||
fi
|
||||
}
|
||||
|
||||
project_dir="$(pwd -P)"
|
||||
|
||||
if [[ -f ./build/CMakeCache.txt ]]; then
|
||||
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
|
||||
cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' ./build/CMakeCache.txt)"
|
||||
host_system="$(uname -s)"
|
||||
if [[ ( -n "$cached_source" && "$cached_source" != "$project_dir" ) \
|
||||
|| ( -n "$cached_system" && "$cached_system" != "$host_system" ) ]]; then
|
||||
echo "Build klasoru farkli bir proje yolu veya sisteme ait, yeniden olusturuluyor."
|
||||
rm -rf ./build
|
||||
fi
|
||||
fi
|
||||
|
||||
cmake_args=(-S . -B build)
|
||||
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
|
||||
cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)")
|
||||
fi
|
||||
|
||||
if ! cmake "${cmake_args[@]}"; then
|
||||
print_qt_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build build
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@"
|
||||
fi
|
||||
|
||||
missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)"
|
||||
if [[ -n "$missing_libs" ]]; then
|
||||
echo "Eksik paylasimli kutuphaneler:"
|
||||
echo "$missing_libs" | sed 's/^/ /'
|
||||
print_qt_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if id -nG | tr ' ' '\n' | grep -qx dialout; then
|
||||
exec ./build/location_plotter "$@"
|
||||
fi
|
||||
|
||||
if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then
|
||||
exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")"
|
||||
fi
|
||||
|
||||
echo "Bu kullanici dialout grubunda degil. Eklemek icin:"
|
||||
echo " sudo usermod -a -G dialout $(id -un)"
|
||||
echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir."
|
||||
exit 1
|
||||
Reference in New Issue
Block a user