diff --git a/Apps/SatelliteTracker/CMakeLists.txt b/Apps/SatelliteTracker/CMakeLists.txt new file mode 100644 index 0000000..0bd8fac --- /dev/null +++ b/Apps/SatelliteTracker/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(satellitetracker) +project(satellitetracker) +tactility_project_post(satellitetracker) diff --git a/Apps/SatelliteTracker/README.md b/Apps/SatelliteTracker/README.md new file mode 100644 index 0000000..02586ad --- /dev/null +++ b/Apps/SatelliteTracker/README.md @@ -0,0 +1,144 @@ +# Satellite Tracker + +Shows satellites on a world map with their ground track, visibility footprint and the day and night areas, and predicts when they pass over your location. +Orbit data comes from [CelesTrak](https://celestrak.org) and the positions are calculated on the device with SGP4. + +## Requirements + +- **Clock:** the positions depend on the time, so the clock must be set. Connect to Wi-Fi once so Tactility can sync the time. Until then the map shows no satellites and the info line says that the clock is not set. +- **Wi-Fi:** needed to download orbit data, groups and search results. Without it the app keeps working with the data it downloaded before. Satellites without any orbit data yet (for example on the first start) are listed but not shown on the map until their data arrives. +- **Location:** the app follows Tactility's location services (GPS or network based) when they are turned on in Tactility's settings and have a fix. Otherwise it uses the location from the app's settings, which is New York City until you enter your own. If you clear that location and there is no device location, there are no pass predictions, no elevation values and no automatic selection of the next pass. + +## Map screen + +The main screen shows the map, a legend and an info area. + +### Map + +- World map (Natural Earth land outlines) with a 30 degree grid. +- Night side shaded darker, with a lighter band for twilight (sun up to 6 degrees below the horizon). +- Your location as an X: the device location, or the one from the app's settings (New York City until you set your own). +- Every tracked satellite as a marker with its own shape: six filled shapes, then the same six as outlines. On color screens each also has its own color. +- Markers that would cover each other (for example the POISK module docked to the ISS) are spread around their common spot. +- Satellites without a position (no orbit data yet, or orbit data that can no longer be used) have no marker and cannot be tapped on the map. +- The selected satellite gets a circle around its marker, its ground track and its footprint (the area where it is above the horizon, dotted). The track behind it is solid and covers half an orbit, the track ahead is dashed and covers one full orbit. For orbits longer than 6 hours (GNSS, geostationary) the track is limited to 3 hours behind and 6 hours ahead. +- With "Tracks for all satellites" in the settings, every satellite gets its track and footprint in its own color. + +### Legend + +One entry per satellite: marker, short name and a value: + +- `up 34°`: the satellite is above your horizon now, at that elevation. +- `20:31`: the time when the next pass starts (passes lower than 5 degrees are skipped). +- `--`: no pass higher than 5 degrees in the next 36 hours, no orbit data yet, or orbit data that cannot be used any more. +- `418 km`: the altitude, shown instead of the above when no location is set. + +The selected satellite has a solid frame, a satellite selected automatically has a dashed frame. + +### Info area + +- Name, catalog number, position, altitude, speed and whether the satellite is sunlit or in Earth's shadow. For a satellite without a position it says that its orbit data is still missing or can no longer be used. +- The pass: above the horizon now (elevation, direction, set time), the next pass (start and end time, highest elevation, rise and set direction, "tomorrow" when it is on the next day, the weekday when it is later), always above the horizon (geostationary satellites), no pass higher than 5 degrees in 36 hours, or "Set location in settings". +- The status line: when the map was updated and how old the orbit data is, or a message while downloading or after a failed download. When the clock was moved back after a download, the age is unknown and not shown. + +### Layout + +The app picks the layout that gives the biggest map: + +- Legend in a grid below the map (narrow screens and most wide screens). +- Legend, details and status in a column next to the map (short, wide screens). The details and status keep their place at the bottom of the column, and the legend scrolls when there are more satellites than fit. + +On screens at least 560 pixels wide the details are longer (for example the coordinates and speed). + +### Selecting a satellite + +- Tap a marker or a legend entry to select that satellite. Tap it again to unselect it. +- With a keyboard or trackball: when the map has the focus, left and right step through the satellites, including "nothing selected". +- With an encoder or the arrow keys: move the focus to a legend entry and press it to select or unselect that satellite. + +### Automatic selection of the next pass + +When no satellite is selected and "Select the next pass automatically" is on in the settings (default), the app shows the satellite whose pass is in progress or starts first. + +- Only passes higher than 5 degrees within the next 36 hours count. +- When the pass ends, the next one is chosen at the next map update (on e-paper that can take up to the update interval). +- **Without a location** (no device location and the one in the settings cleared) there are no passes, so nothing is selected automatically. +- When no satellite has a pass in the next 36 hours, nothing is selected and the info area says so. +- Geostationary satellites that are always above the horizon are not chosen. +- Selecting a satellite yourself overrides the automatic choice until you unselect it. + +### Bigger map + +Tap an empty part of the map, or press the button (Enter) while the map has the focus, to hide the toolbar and make the map bigger. Do the same again to bring the toolbar back. The choice is remembered. + +### Map updates + +The map, legend and info area are updated every 5 seconds on color screens and every 60 seconds on e-paper. The interval can be changed in the settings. A tap or a new selection updates the map at once. + +## Next passes screen + +The calendar button lists the passes of all tracked satellites over your location in the next 24 hours, sorted by time. + +- Each row shows the start and end time, the highest elevation, the rise and set direction and the satellite. Passes on the next day show the weekday. +- Passes lower than 5 degrees are skipped. At most 10 passes per satellite and 40 rows are listed. +- `* visible` marks passes where the satellite is in sunlight while your sky is dark (sun more than 6 degrees below the horizon). Whether you can see it also depends on its brightness and the weather, which the app does not know. +- Satellites that are always above the horizon are listed separately. +- The list is calculated when the screen opens and does not update by itself. +- It needs a location and a set clock, otherwise the screen says what is missing. + +## Satellites screen + +The list button opens the tracked satellites. + +- Each row shows the marker, the short name and the full name from the orbit data, or the catalog number and "waiting for orbit data" while that is missing. +- **Rename:** sets the short name used in the legend (up to 15 characters). "Automatic" restores the name from the orbit data without the part in brackets (for example "ISS" for "ISS (ZARYA)"). Saving an empty name does the same. Both need the orbit data, so until it arrives "Automatic" is not offered and an empty name keeps the current one. +- **Remove:** stops tracking the satellite. Removing all satellites leaves an empty list, the default satellites are only added on the first start. +- **Add from group:** CelesTrak groups such as space stations, brightest, weather, NOAA, GOES, amateur radio, GPS, Galileo, GLONASS, BeiDou, science and CubeSats. Tap a satellite to add or remove it. + - The list is sorted by name. Large groups are cut to the first 400 satellites read (150 on devices without external RAM) and 250 shown, use the search for others. + - A group is downloaded again only when the saved copy is older than 24 hours. When that download fails, the saved copy is shown. + - The groups are defined by CelesTrak. "Space stations", for example, also contains docked spacecraft and CubeSats launched from the ISS. +- **Search:** type a name (or part of it, for example "NOAA") or a NORAD catalog number, up to 31 characters. Up to 100 results (60 on devices without external RAM), sorted by name. + +How many satellites can be tracked depends on the screen: twice the number of legend columns below the map, or the number of legend rows next to the map, up to 12 (6 on a 320 pixel wide screen). A list saved with more satellites (for example on a bigger screen) is kept, but no more can be added until some are removed. + +On devices without a keyboard the on-screen keyboard opens for the text fields. + +## Settings + +- **Use the device location (GPS or network) when available:** on by default. While the app runs it keeps Tactility's location services active, which starts a GPS if the device has one. Movements below about 1 km are ignored, so the passes are not recalculated with every GPS update. The line below the switch says which location is in use: the device location with its accuracy and source, "waiting" while there is no fix yet, or that location services are off in Tactility's settings. Approximate location (about 3 km) is precise enough for passes. +- **Location:** used when the device location is off or not available. Latitude and longitude in decimal degrees, north and east positive, south and west negative. A comma works as decimal separator. Altitude in meters is optional. New York City (40.7128, -74.0060, 10 m) is used until you save your own. Clear latitude and longitude for no location. +- **Map update interval** in seconds, 1 to 3600. Empty means the default (5 seconds on color screens, 60 on e-paper). +- **Tracks for all satellites:** draws the track and footprint of every satellite, not only the selected one. +- **Select the next pass automatically when no satellite is selected:** see above. +- **Update orbit data:** downloads the orbit data of the tracked satellites again. It only works when the data is older than 2 hours, because CelesTrak asks clients not to download more often. Satellites that have no orbit data yet are downloaded in any case. + +## Orbit data downloads + +- **First start:** the app tracks the ISS, the Chinese space station (CSS), the Hubble Space Telescope and the XMM-Newton X-ray observatory. The list is saved at once, the orbit data is downloaded as soon as Wi-Fi and the clock are available. +- **Missing orbit data** (the first start, or a list without cached data) is downloaded as soon as possible. A failed download is tried again after 30 seconds, up to 3 times in total, then every 15 minutes while the app is open. When CelesTrak has no data for a satellite (re-entered, or a wrong number), it is asked again after a day. +- **Updates:** when the app starts and the data is older than 2 hours, it is downloaded again. While the app stays open, it is refreshed once a day. After a failed update the app keeps the old data and tries again after 15 minutes. +- A satellite removed while its download is running is not added back when the download finishes. +- Downloads need a set clock, because the server certificate cannot be checked without it. +- Orbit data gets less accurate with age. After a few weeks the predictions can be off by minutes, and for satellites that have re-entered the calculation fails and the legend shows `--`. + +## Stored data + +The app keeps its files in its user data folder (`user/app/tactility.satellitetracker`). Reinstalling or updating the app keeps them. + +- `satellites.txt`: the tracked satellites, one per line as catalog number, marker slot and short name (`25544|0|ISS`). Written only when you add, remove or rename a satellite. +- `orbits.tle`: the latest orbit data of the tracked satellites in the usual three line format. Written once the downloads of a refresh are done and when the app closes, not after every single download. +- `settings.properties`: the settings and the time of the last orbit data download. +- `group-.tle` and `search.tle`: the last group lists and search results. A download goes to a `.part` file first, so a failed download keeps the previous copy. +- Without `satellites.txt` the app starts with the default satellites. An empty `satellites.txt` means no satellites. Damaged lines are skipped, a satellite without usable cached orbit data is downloaded again. + +## Limitations + +- The passes assume a flat, free horizon. Mountains, buildings and atmospheric refraction are not taken into account. +- Times are shown in the device's time zone. + +## Data and code from other sources + +- Orbit data: [CelesTrak](https://celestrak.org). Please keep the download limits: the app downloads a group at most once a day and the tracked satellites at most every 2 hours. +- Map: [Natural Earth](https://www.naturalearthdata.com) 1:110m land polygons, public domain. `tools/gen_world_map.py` creates `main/Source/WorldMap.c` from `ne_110m_land.geojson`. +- SGP4: C port by aholinch (https://github.com/aholinch/sgp4) of the code from "Revisiting Spacetrack Report #3" by Vallado et al., released under the Unlicense. See `main/Source/sgp4`. +- `assets/celestrak.pem` contains the root certificates that sign celestrak.org (USERTrust RSA Certification Authority and Sectigo Public Server Authentication Root R46). diff --git a/Apps/SatelliteTracker/assets/celestrak.pem b/Apps/SatelliteTracker/assets/celestrak.pem new file mode 100644 index 0000000..574316a --- /dev/null +++ b/Apps/SatelliteTracker/assets/celestrak.pem @@ -0,0 +1,66 @@ +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB +iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl +cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV +BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw +MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV +BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B +3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY +tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/ +Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2 +VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT +79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6 +c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT +Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l +c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee +UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE +Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF +Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO +VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3 +ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs +8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR +iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze +Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ +XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/ +qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB +VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB +L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG +jjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- diff --git a/Apps/SatelliteTracker/main/CMakeLists.txt b/Apps/SatelliteTracker/main/CMakeLists.txt new file mode 100644 index 0000000..10ca3e3 --- /dev/null +++ b/Apps/SatelliteTracker/main/CMakeLists.txt @@ -0,0 +1,9 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register(SRCS ${SOURCE_FILES} REQUIRES http-module) + +# The posix SDK (simulator) ignores REQUIRES and only adds a fixed list of include directories +if (NOT IDF_TARGET) + target_include_directories(${PROJECT_NAME} PRIVATE $ENV{TACTILITY_SDK_PATH}/Modules/http-module/include) +endif () diff --git a/Apps/SatelliteTracker/main/Source/DeviceLocation.cpp b/Apps/SatelliteTracker/main/Source/DeviceLocation.cpp new file mode 100644 index 0000000..38b6e50 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/DeviceLocation.cpp @@ -0,0 +1,28 @@ +#include "DeviceLocation.h" + +void DeviceLocation::update(TaskEventGroup* eventGroup, bool wanted) { + bool shouldRun = wanted && location_is_enabled(); + if (shouldRun && !subscribed) { + subscription = LocationSubscription {}; + subscribed = location_subscribe(&subscription, eventGroup) == ERROR_NONE; + } else if (!shouldRun && subscribed) { + stop(); + } +} + +void DeviceLocation::stop() { + if (subscribed) { + location_unsubscribe(&subscription); + subscribed = false; + } + lastUptimeMs = 0; +} + +bool DeviceLocation::poll(Location& out) { + if (!subscribed) return false; + Location fix {}; + if (location_get(&fix) != ERROR_NONE || fix.uptime_ms == lastUptimeMs) return false; + lastUptimeMs = fix.uptime_ms; + out = fix; + return true; +} diff --git a/Apps/SatelliteTracker/main/Source/DeviceLocation.h b/Apps/SatelliteTracker/main/Source/DeviceLocation.h new file mode 100644 index 0000000..9e139ee --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/DeviceLocation.h @@ -0,0 +1,26 @@ +#pragma once + +#include "LocationApi.h" + +#include + +/** Follows Tactility's location service. Only used on the app's main task. */ +class DeviceLocation { + +public: + + /** Subscribes while wanted and location services are enabled, so the providers (GPS, network) run. */ + void update(TaskEventGroup* eventGroup, bool wanted); + void stop(); + + bool isActive() const { return subscribed; } + + /** Returns true when the service has a fix that was not returned before. */ + bool poll(Location& out); + +private: + + LocationSubscription subscription {}; + bool subscribed = false; + uint64_t lastUptimeMs = 0; +}; diff --git a/Apps/SatelliteTracker/main/Source/Downloads.cpp b/Apps/SatelliteTracker/main/Source/Downloads.cpp new file mode 100644 index 0000000..e8824d8 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Downloads.cpp @@ -0,0 +1,193 @@ +#include "Downloads.h" + +#include "Storage.h" + +#include +#include +#include + +namespace { + +constexpr const char* BASE_URL = "https://celestrak.org/NORAD/elements/gp.php"; +constexpr const char* CERTIFICATE_FILE = "celestrak.pem"; + +bool userFile(const char* name, char* out, size_t outSize) { + return storageEnsureDirectory() && storageUserPath(name, out, outSize); +} + +void partFile(const char* target, char* out, size_t outSize) { + snprintf(out, outSize, "%s.part", target); +} + +/** Downloads go to a ".part" file first, so a failed download keeps the previous copy. */ +bool finishFile(const char* target) { + char partPath[sizeof(DownloadJob::target) + 8]; + partFile(target, partPath, sizeof(partPath)); + remove(target); + return rename(partPath, target) == 0; +} + +} + +bool Downloader::init(TaskEventGroup* eventGroup) { + group = eventGroup; + if (task_event_group_claim_bit(group, &wakeBit) != ERROR_NONE) return false; + mutex = xSemaphoreCreateMutex(); + if (mutex == nullptr) return false; + storageAssetPath(CERTIFICATE_FILE, certificatePath, sizeof(certificatePath)); + return true; +} + +void Downloader::deinit() { + if (running) { + http_download_cancel(&subscription); + http_download_unsubscribe(&subscription); + running = false; + } + if (mutex != nullptr) { + vSemaphoreDelete(mutex); + mutex = nullptr; + } + if (group != nullptr && wakeBit != 0) { + task_event_group_release_bit(group, wakeBit); + wakeBit = 0; + } +} + +bool Downloader::enqueue(const DownloadJob& job) { + if (mutex == nullptr) return false; + xSemaphoreTake(mutex, portMAX_DELAY); + queue.push_back(job); + xSemaphoreGive(mutex); + task_event_group_signal(group, wakeBit); + return true; +} + +bool Downloader::isBusy() { + if (mutex == nullptr) return false; + xSemaphoreTake(mutex, portMAX_DELAY); + bool busy = running || !queue.empty(); + xSemaphoreGive(mutex); + return busy; +} + +bool Downloader::hasQueued(DownloadKind kind, const char* tag) { + if (mutex == nullptr) return false; + xSemaphoreTake(mutex, portMAX_DELAY); + bool found = running && current.kind == kind && strcmp(current.tag, tag) == 0; + for (const auto& job : queue) { + if (job.kind == kind && strcmp(job.tag, tag) == 0) found = true; + } + xSemaphoreGive(mutex); + return found; +} + +bool Downloader::process(DownloadResult& out) { + if (running) { + HttpDownloadEvent event; + if (http_download_poll(&subscription, &event) != ERROR_NONE) return false; + http_download_unsubscribe(&subscription); + xSemaphoreTake(mutex, portMAX_DELAY); + running = false; + out.job = current; + xSemaphoreGive(mutex); + out.success = event.type == HTTP_DOWNLOAD_EVENT_SUCCESS && finishFile(current.target); + out.statusCode = event.status_code; + out.message[0] = '\0'; + if (event.type == HTTP_DOWNLOAD_EVENT_ERROR) { + strncpy(out.message, event.error.message, sizeof(out.message) - 1); + out.message[sizeof(out.message) - 1] = '\0'; + } else if (!out.success) { + snprintf(out.message, sizeof(out.message), "could not save the file"); + } + task_event_group_signal(group, wakeBit); + return true; + } + + xSemaphoreTake(mutex, portMAX_DELAY); + if (queue.empty()) { + xSemaphoreGive(mutex); + return false; + } + current = queue.front(); + queue.pop_front(); + running = true; + xSemaphoreGive(mutex); + + subscription = HttpDownloadSubscription {}; + bool started = http_download_subscribe(&subscription, group) == ERROR_NONE; + char partPath[sizeof(current.target) + 8]; + partFile(current.target, partPath, sizeof(partPath)); + if (started && http_download_start(current.url, certificatePath, partPath, &subscription) != ERROR_NONE) { + http_download_unsubscribe(&subscription); + started = false; + } + if (!started) { + xSemaphoreTake(mutex, portMAX_DELAY); + running = false; + out.job = current; + xSemaphoreGive(mutex); + out.success = false; + out.statusCode = 0; + strncpy(out.message, "Could not start the download", sizeof(out.message) - 1); + out.message[sizeof(out.message) - 1] = '\0'; + task_event_group_signal(group, wakeBit); + return true; + } + return false; +} + +bool Downloader::buildCatalogJob(uint32_t catalogNumber, DownloadJob& out) { + out = DownloadJob {}; + out.kind = DownloadKind::Tracked; + snprintf(out.tag, sizeof(out.tag), "%u", (unsigned)catalogNumber); + snprintf(out.url, sizeof(out.url), "%s?CATNR=%u&FORMAT=tle", BASE_URL, (unsigned)catalogNumber); + char name[32]; + snprintf(name, sizeof(name), "sat-%u.tle", (unsigned)catalogNumber); + return userFile(name, out.target, sizeof(out.target)); +} + +bool Downloader::buildGroupJob(const char* groupId, DownloadJob& out) { + out = DownloadJob {}; + out.kind = DownloadKind::Group; + snprintf(out.tag, sizeof(out.tag), "%s", groupId); + snprintf(out.url, sizeof(out.url), "%s?GROUP=%s&FORMAT=tle", BASE_URL, groupId); + char name[48]; + snprintf(name, sizeof(name), "group-%s.tle", groupId); + return userFile(name, out.target, sizeof(out.target)); +} + +bool Downloader::buildSearchJob(const char* text, DownloadJob& out) { + out = DownloadJob {}; + out.kind = DownloadKind::Search; + while (*text == ' ') text++; + if (*text == '\0') return false; + // The tag identifies the search when its result arrives, so the URL is built from the tag and both describe the same text. + if (snprintf(out.tag, sizeof(out.tag), "%s", text) >= (int)sizeof(out.tag)) return false; + + bool numeric = true; + for (const char* p = out.tag; *p; p++) { + if (!isdigit((unsigned char)*p)) numeric = false; + } + constexpr const char* SUFFIX = "&FORMAT=tle"; + int written; + if (numeric) { + written = snprintf(out.url, sizeof(out.url), "%s?CATNR=%s%s", BASE_URL, out.tag, SUFFIX); + } else { + written = snprintf(out.url, sizeof(out.url), "%s?NAME=", BASE_URL); + int reserved = (int)strlen(SUFFIX) + 1; + for (const char* p = out.tag; *p; p++) { + // An encoded character takes up to 3 bytes. + if (written + 3 + reserved > (int)sizeof(out.url)) return false; + unsigned char c = (unsigned char)*p; + if (isalnum(c) || c == '-' || c == '.' || c == '_') { + out.url[written++] = (char)c; + } else { + written += snprintf(out.url + written, sizeof(out.url) - written, "%%%02X", c); + } + } + written += snprintf(out.url + written, sizeof(out.url) - written, "%s", SUFFIX); + } + if (written >= (int)sizeof(out.url)) return false; + return userFile("search.tle", out.target, sizeof(out.target)); +} diff --git a/Apps/SatelliteTracker/main/Source/Downloads.h b/Apps/SatelliteTracker/main/Source/Downloads.h new file mode 100644 index 0000000..ccc469a --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Downloads.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +enum class DownloadKind { + Tracked, + Group, + Search, +}; + +struct DownloadJob { + DownloadKind kind; + char url[200]; + char target[200]; + char tag[32]; +}; + +struct DownloadResult { + DownloadJob job; + bool success; + int32_t statusCode; + char message[HTTP_DOWNLOAD_ERROR_MESSAGE_MAX_LEN]; +}; + +/** + * Runs CelesTrak downloads one after another on the app's main task. + * enqueue() and the status getters may be called from any task. + */ +class Downloader { + +public: + + bool init(TaskEventGroup* eventGroup); + void deinit(); + + /** Returns false when the downloader could not be initialized, the job is then dropped. */ + bool enqueue(const DownloadJob& job); + bool isBusy(); + bool hasQueued(DownloadKind kind, const char* tag); + + /** Main task only: starts queued work and collects a finished download. Returns true when out holds a result. */ + bool process(DownloadResult& out); + + static bool buildCatalogJob(uint32_t catalogNumber, DownloadJob& out); + static bool buildGroupJob(const char* groupId, DownloadJob& out); + static bool buildSearchJob(const char* text, DownloadJob& out); + +private: + + TaskEventGroup* group = nullptr; + uint32_t wakeBit = 0; + SemaphoreHandle_t mutex = nullptr; + std::deque queue; + bool running = false; + DownloadJob current {}; + HttpDownloadSubscription subscription {}; + char certificatePath[200] {}; +}; diff --git a/Apps/SatelliteTracker/main/Source/LargeMemory.cpp b/Apps/SatelliteTracker/main/Source/LargeMemory.cpp new file mode 100644 index 0000000..4d1279d --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/LargeMemory.cpp @@ -0,0 +1,19 @@ +#include "LargeMemory.h" + +#include +#include + +void* largeAlloc(size_t size) { + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + return memory_alloc_with_policy(size, &policy); +} + +void* largeAllocOrCrash(size_t size) { + void* memory = largeAlloc(size); + check(memory != nullptr, "Satellite Tracker is out of memory"); + return memory; +} + +void largeFree(void* pointer) { + memory_free(pointer); +} diff --git a/Apps/SatelliteTracker/main/Source/LargeMemory.h b/Apps/SatelliteTracker/main/Source/LargeMemory.h new file mode 100644 index 0000000..7611d01 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/LargeMemory.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +/** Allocates big buffers in external RAM (PSRAM) when the device has it. Returns nullptr when out of memory. */ +void* largeAlloc(size_t size); +/** Like largeAlloc(), but stops the app with an error message when out of memory, for callers that cannot handle nullptr. */ +void* largeAllocOrCrash(size_t size); +void largeFree(void* pointer); + +/** std::vector allocator that keeps the elements in external RAM when available. */ +template +struct LargeAllocator { + using value_type = T; + + LargeAllocator() = default; + + template + LargeAllocator(const LargeAllocator&) {} + + /** std::vector cannot handle nullptr and exceptions are disabled, so running out of memory stops the app here. */ + T* allocate(size_t count) { return static_cast(largeAllocOrCrash(count * sizeof(T))); } + void deallocate(T* pointer, size_t) { largeFree(pointer); } + + template + bool operator==(const LargeAllocator&) const { return true; } + template + bool operator!=(const LargeAllocator&) const { return false; } +}; diff --git a/Apps/SatelliteTracker/main/Source/LocationApi.h b/Apps/SatelliteTracker/main/Source/LocationApi.h new file mode 100644 index 0000000..e4d2ead --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/LocationApi.h @@ -0,0 +1,57 @@ +#pragma once + +#if __has_include() +#include +#else + +// Declarations from Tactility's location-module (Modules/location-module/include/location/location.h, Apache-2.0), +// used while the app SDK does not ship that header yet. They must match the firmware. + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LOCATION_PROVIDER_ID_MAX_LENGTH 15 + +struct Location { + double latitude; + double longitude; + float horizontal_accuracy; + float altitude; + float ellipsoidal_altitude; + float vertical_accuracy; + float speed; + float speed_accuracy; + float course; + float course_accuracy; + uint8_t satellites; + int64_t time_ms; + uint64_t uptime_ms; + char provider[LOCATION_PROVIDER_ID_MAX_LENGTH + 1]; +}; + +struct LocationSubscription { + uint32_t bit; + + struct { + struct TaskEventGroup* event_group; + struct LocationSubscription* next; + } internal; +}; + +bool location_is_enabled(void); +error_t location_get(struct Location* location); +error_t location_subscribe(struct LocationSubscription* sub, struct TaskEventGroup* event_group); +error_t location_unsubscribe(struct LocationSubscription* sub); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Apps/SatelliteTracker/main/Source/MapRenderer.cpp b/Apps/SatelliteTracker/main/Source/MapRenderer.cpp new file mode 100644 index 0000000..2c805bc --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/MapRenderer.cpp @@ -0,0 +1,471 @@ +#include "MapRenderer.h" + +#include "LargeMemory.h" +#include "Orbit.h" +#include "WorldMap.h" + +#include +#include +#include +#include + +namespace map { + +namespace { + +constexpr float PI_F = 3.14159265f; +constexpr float DEG_F = PI_F / 180.0f; +constexpr float TWILIGHT_LIMIT = -0.1045f; + +const Theme COLOR_THEME = { + .ocean = { 0x12, 0x2B, 0x45 }, + .land = { 0x3F, 0x5E, 0x34 }, + .coast = { 0x7D, 0x9A, 0x6A }, + .grid = { 0x22, 0x40, 0x60 }, + .observer = { 0xFF, 0xFF, 0xFF }, + .markerOutline = { 0x00, 0x00, 0x00 }, + .focus = { 0xFF, 0xFF, 0xFF }, + .satellite = { + { 0xF5, 0xC5, 0x42 }, + { 0xFF, 0x6B, 0x6B }, + { 0x7F, 0xD1, 0xFF }, + { 0xC0, 0x9C, 0xFF }, + { 0x6E, 0xE7, 0xA8 }, + { 0xFF, 0x9F, 0x43 }, + }, + .nightDarken = 120, + .twilightDarken = 60, +}; + +const Theme GRAY_THEME = { + .ocean = { 0xFF, 0xFF, 0xFF }, + .land = { 0xD0, 0xD0, 0xD0 }, + .coast = { 0x60, 0x60, 0x60 }, + .grid = { 0xE4, 0xE4, 0xE4 }, + .observer = { 0x00, 0x00, 0x00 }, + .markerOutline = { 0xFF, 0xFF, 0xFF }, + .focus = { 0x00, 0x00, 0x00 }, + .satellite = { + { 0x00, 0x00, 0x00 }, + { 0x00, 0x00, 0x00 }, + { 0x00, 0x00, 0x00 }, + { 0x00, 0x00, 0x00 }, + { 0x00, 0x00, 0x00 }, + { 0x00, 0x00, 0x00 }, + }, + .nightDarken = 70, + .twilightDarken = 35, +}; + +uint8_t toGray(Rgb c) { + return (uint8_t)((c.r * 77 + c.g * 150 + c.b * 29) >> 8); +} + +uint16_t toRgb565(Rgb c) { + return (uint16_t)(((c.r >> 3) << 11) | ((c.g >> 2) << 5) | (c.b >> 3)); +} + +bool insideShape(MarkerShape shape, float dx, float dy, float r, float grow) { + switch (shape) { + case MarkerShape::Circle: + return dx * dx + dy * dy <= (r + grow) * (r + grow); + case MarkerShape::Square: { + float half = r * 0.8f + grow; + return std::fabs(dx) <= half && std::fabs(dy) <= half; + } + case MarkerShape::Triangle: { + if (dy - 0.75f * r > grow) return false; + const float nx = 1.75f, ny = -0.95f; + const float len = std::sqrt(nx * nx + ny * ny); + float right = (dx * nx + (dy + r) * ny) / len; + float left = (-dx * nx + (dy + r) * ny) / len; + return right <= grow && left <= grow; + } + case MarkerShape::Diamond: + return std::fabs(dx) + std::fabs(dy) <= r + grow * 1.414f; + case MarkerShape::Plus: { + float arm = r * 0.42f + grow; + float reach = r + grow; + return (std::fabs(dx) <= arm && std::fabs(dy) <= reach) || (std::fabs(dy) <= arm && std::fabs(dx) <= reach); + } + case MarkerShape::TriangleDown: + return insideShape(MarkerShape::Triangle, dx, -dy, r, grow); + } + return false; +} + +float hollowWall(MarkerShape shape, float r) { + float wall = shape == MarkerShape::Plus ? r * 0.19f : r * 0.3f; + return wall < 1.5f ? 1.5f : wall; +} + +} + +const Theme& colorTheme() { return COLOR_THEME; } +const Theme& grayTheme() { return GRAY_THEME; } + +Surface::Surface(void* data, int width, int height, int strideBytes, PixelFormat format) + : bytes(static_cast(data)), w(width), h(height), stride(strideBytes), pixelFormat(format) {} + +void Surface::fill(Rgb color) { + for (int y = 0; y < h; y++) { + if (pixelFormat == PixelFormat::Gray8) { + memset(rowPointer(y), toGray(color), w); + } else { + auto* row = reinterpret_cast(rowPointer(y)); + uint16_t value = toRgb565(color); + for (int x = 0; x < w; x++) row[x] = value; + } + } +} + +void Surface::setPixel(int x, int y, Rgb color) { + if (x < 0 || y < 0 || x >= w || y >= h) return; + if (pixelFormat == PixelFormat::Gray8) { + rowPointer(y)[x] = toGray(color); + } else { + reinterpret_cast(rowPointer(y))[x] = toRgb565(color); + } +} + +void Surface::darkenPixel(int x, int y, uint8_t amount) { + if (x < 0 || y < 0 || x >= w || y >= h) return; + uint32_t keep = 255 - amount; + if (pixelFormat == PixelFormat::Gray8) { + uint8_t& v = rowPointer(y)[x]; + v = (uint8_t)((v * keep) / 255); + } else { + uint16_t& v = reinterpret_cast(rowPointer(y))[x]; + uint32_t r = ((v >> 11) & 0x1F) * keep / 255; + uint32_t g = ((v >> 5) & 0x3F) * keep / 255; + uint32_t b = (v & 0x1F) * keep / 255; + v = (uint16_t)((r << 11) | (g << 5) | b); + } +} + +void Surface::line(int x0, int y0, int x1, int y1, Rgb color, int thickness, int dashOn, int dashOff, int* dashPhase) { + int dx = std::abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + int dy = -std::abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + int offset = (thickness - 1) / 2; + int step = dashPhase != nullptr ? *dashPhase : 0; + int period = dashOn + dashOff; + while (true) { + if (period == 0 || dashOff == 0 || (step % period) < dashOn) { + for (int ty = 0; ty < thickness; ty++) { + for (int tx = 0; tx < thickness; tx++) { + setPixel(x0 + tx - offset, y0 + ty - offset, color); + } + } + } + if (x0 == x1 && y0 == y1) break; + step++; + int e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } + if (dashPhase != nullptr && period > 0) { + *dashPhase = step % period; + } +} + +void Surface::marker(int cx, int cy, int radius, MarkerShape shape, bool hollow, Rgb fillColor, Rgb outlineColor) { + float r = (float)radius; + float wall = hollow ? hollowWall(shape, r) : 0.0f; + int extent = radius + 2; + for (int dy = -extent; dy <= extent; dy++) { + for (int dx = -extent; dx <= extent; dx++) { + float x = (float)dx, y = (float)dy; + bool inside = insideShape(shape, x, y, r, 0.0f); + if (inside && hollow && insideShape(shape, x, y, r, -wall)) { + if (!insideShape(shape, x, y, r, -wall - 1.2f)) setPixel(cx + dx, cy + dy, outlineColor); + } else if (inside) { + setPixel(cx + dx, cy + dy, fillColor); + } else if (insideShape(shape, x, y, r, 1.2f)) { + setPixel(cx + dx, cy + dy, outlineColor); + } + } + } +} + +void Surface::circle(int cx, int cy, int radius, int thickness, Rgb color) { + float outer = (float)radius + 0.5f; + float inner = outer - (float)thickness; + for (int dy = -radius - 1; dy <= radius + 1; dy++) { + for (int dx = -radius - 1; dx <= radius + 1; dx++) { + float d = std::sqrt((float)(dx * dx + dy * dy)); + if (d <= outer && d > inner) setPixel(cx + dx, cy + dy, color); + } + } +} + +void Surface::copyFrom(const Surface& other) { + int rowBytes = w * (pixelFormat == PixelFormat::Gray8 ? 1 : 2); + for (int y = 0; y < h && y < other.h; y++) { + memcpy(rowPointer(y), other.rowPointer(y), rowBytes); + } +} + +MapRenderer::~MapRenderer() { + largeFree(baseData); +} + +bool MapRenderer::setup(int width, int height, PixelFormat format, const Theme& theme) { + largeFree(baseData); + baseData = nullptr; + mapWidth = width; + mapHeight = height; + pixelFormat = format; + bytesPerPixel = (format == PixelFormat::Gray8) ? 1 : 2; + activeTheme = &theme; + baseData = static_cast(largeAlloc((size_t)width * height * bytesPerPixel)); + if (baseData == nullptr) return false; + Surface base(baseData, width, height, width * bytesPerPixel, format); + drawBase(base); + return true; +} + +int MapRenderer::markerRadius() const { + int r = mapWidth / 70; + return r < 5 ? 5 : r; +} + +void MapRenderer::project(GeoPoint point, float& x, float& y) const { + x = (point.lon + 180.0f) / 360.0f * (float)mapWidth; + y = (90.0f - point.lat) / 180.0f * (float)mapHeight; +} + +void MapRenderer::drawBase(Surface& surface) const { + const Theme& theme = *activeTheme; + surface.fill(theme.ocean); + + for (int lon = -150; lon < 180; lon += 30) { + int x = (int)((lon + 180) / 360.0f * mapWidth); + surface.line(x, 0, x, mapHeight - 1, theme.grid, 1, 0, 0); + } + for (int lat = -60; lat <= 60; lat += 30) { + int y = (int)((90 - lat) / 180.0f * mapHeight); + surface.line(0, y, mapWidth - 1, y, theme.grid, 1, 0, 0); + } + + std::vector crossings; + crossings.reserve(64); + for (int y = 0; y < mapHeight; y++) { + float lat = (90.0f - ((float)y + 0.5f) * 180.0f / (float)mapHeight) * 100.0f; + crossings.clear(); + const int16_t* points = WORLD_MAP_POINTS; + for (int ring = 0; ring < WORLD_MAP_RING_COUNT; ring++) { + int count = WORLD_MAP_RING_LENGTHS[ring]; + for (int i = 0; i < count; i++) { + const int16_t* a = points + i * 2; + const int16_t* b = points + ((i + 1) % count) * 2; + float latA = a[1], latB = b[1]; + if ((latA > lat) != (latB > lat)) { + float lon = a[0] + (lat - latA) * (float)(b[0] - a[0]) / (latB - latA); + crossings.push_back((lon / 100.0f + 180.0f) / 360.0f * (float)mapWidth); + } + } + points += count * 2; + } + std::sort(crossings.begin(), crossings.end()); + for (size_t i = 0; i + 1 < crossings.size(); i += 2) { + int start = (int)std::ceil(crossings[i] - 0.5f); + int end = (int)std::floor(crossings[i + 1] - 0.5f); + for (int x = start; x <= end; x++) { + surface.setPixel(x, y, theme.land); + } + } + } + + const int16_t* points = WORLD_MAP_POINTS; + for (int ring = 0; ring < WORLD_MAP_RING_COUNT; ring++) { + int count = WORLD_MAP_RING_LENGTHS[ring]; + for (int i = 0; i < count; i++) { + const int16_t* a = points + i * 2; + const int16_t* b = points + ((i + 1) % count) * 2; + bool onDateLine = std::abs(a[0]) >= 17999 && std::abs(b[0]) >= 17999 && ((a[0] > 0) == (b[0] > 0)); + bool onSouthEdge = a[1] <= -8999 && b[1] <= -8999; + if (onDateLine || onSouthEdge) continue; + float ax, ay, bx, by; + project(GeoPoint { a[1] / 100.0f, a[0] / 100.0f }, ax, ay); + project(GeoPoint { b[1] / 100.0f, b[0] / 100.0f }, bx, by); + surface.line((int)ax, (int)ay, (int)bx, (int)by, theme.coast, 1, 0, 0); + } + points += count * 2; + } +} + +void MapRenderer::drawNight(Surface& surface, const Scene& scene) const { + const Theme& theme = *activeTheme; + float sinDec = std::sin(scene.subsolar.lat * DEG_F); + float cosDec = std::cos(scene.subsolar.lat * DEG_F); + std::vector columnCos(mapWidth); + for (int x = 0; x < mapWidth; x++) { + float lon = ((float)x + 0.5f) / (float)mapWidth * 360.0f - 180.0f; + columnCos[x] = std::cos((lon - scene.subsolar.lon) * DEG_F) * cosDec; + } + for (int y = 0; y < mapHeight; y++) { + float lat = (90.0f - ((float)y + 0.5f) * 180.0f / (float)mapHeight) * DEG_F; + float a = std::sin(lat) * sinDec; + float b = std::cos(lat); + for (int x = 0; x < mapWidth; x++) { + float sunHeight = a + b * columnCos[x]; + if (sunHeight < TWILIGHT_LIMIT) { + surface.darkenPixel(x, y, theme.nightDarken); + } else if (sunHeight < 0.0f) { + surface.darkenPixel(x, y, theme.twilightDarken); + } + } + } +} + +void MapRenderer::drawPolyline(Surface& surface, const std::vector& points, Rgb color, int thickness, int dashOn, int dashOff) const { + int dashPhase = 0; + for (size_t i = 1; i < points.size(); i++) { + float x0, y0, x1, y1; + project(points[i - 1], x0, y0); + project(points[i], x1, y1); + if (std::fabs(x1 - x0) > mapWidth / 2.0f) continue; + surface.line((int)x0, (int)y0, (int)x1, (int)y1, color, thickness, dashOn, dashOff, &dashPhase); + } +} + +void MapRenderer::drawFootprint(Surface& surface, GeoPoint center, float radiusDeg, Rgb color) const { + std::vector ring; + ring.reserve(73); + for (int bearing = 0; bearing <= 360; bearing += 5) { + double lat, lon; + orbit::destinationPoint(center.lat, center.lon, radiusDeg, bearing, lat, lon); + ring.push_back(GeoPoint { (float)lat, (float)lon }); + } + drawPolyline(surface, ring, color, 1, 3, 3); +} + +void MapRenderer::render(Surface& target, const Scene& scene) const { + const Theme& theme = *activeTheme; + Surface base(baseData, mapWidth, mapHeight, mapWidth * bytesPerPixel, pixelFormat); + target.copyFrom(base); + + if (scene.showNight) { + drawNight(target, scene); + } + + int trackThickness = mapWidth >= 600 ? 2 : 1; + for (const auto& satellite : scene.satellites) { + if (!satellite.visible || !satellite.drawTrack) continue; + Rgb color = theme.satellite[satellite.colorIndex % MARKER_SHAPE_COUNT]; + drawPolyline(target, satellite.pastTrack, color, trackThickness, 0, 0); + int dash = mapWidth >= 600 ? 10 : 5; + drawPolyline(target, satellite.futureTrack, color, trackThickness, dash, dash * 2 / 3 + 1); + if (satellite.footprintRadiusDeg > 0.0f) { + drawFootprint(target, satellite.position, satellite.footprintRadiusDeg, color); + } + } + + int radius = markerRadius(); + if (scene.hasObserver) { + float ox, oy; + project(scene.observer, ox, oy); + int arm = radius - 1; + target.line((int)ox - arm, (int)oy - arm, (int)ox + arm, (int)oy + arm, theme.observer, 2, 0, 0); + target.line((int)ox - arm, (int)oy + arm, (int)ox + arm, (int)oy - arm, theme.observer, 2, 0, 0); + } + + std::vector positions; + markerPositions(scene, positions); + for (size_t i = 0; i < scene.satellites.size(); i++) { + const auto& satellite = scene.satellites[i]; + if (!satellite.visible) continue; + int sx = (int)positions[i].x; + int sy = (int)positions[i].y; + Rgb color = theme.satellite[satellite.colorIndex % MARKER_SHAPE_COUNT]; + if (satellite.focused) { + target.circle(sx, sy, radius + 5, 2, theme.focus); + } + target.marker(sx, sy, radius, satellite.shape, satellite.hollow, color, theme.markerOutline); + } + + if (scene.frame) { + int right = mapWidth - 2, bottom = mapHeight - 2; + target.line(0, 0, right, 0, scene.frameColor, 2, 0, 0); + target.line(0, bottom, right, bottom, scene.frameColor, 2, 0, 0); + target.line(0, 0, 0, bottom, scene.frameColor, 2, 0, 0); + target.line(right, 0, right, bottom, scene.frameColor, 2, 0, 0); + } +} + +void MapRenderer::markerPositions(const Scene& scene, std::vector& out) const { + size_t count = scene.satellites.size(); + out.resize(count); + for (size_t i = 0; i < count; i++) { + float x, y; + project(scene.satellites[i].position, x, y); + out[i] = PixelPoint { x, y }; + } + + float minimumDistance = markerRadius() * 2.2f; + std::vector group(count); + for (size_t i = 0; i < count; i++) group[i] = (int)i; + bool merged = true; + while (merged) { + merged = false; + for (size_t i = 0; i < count; i++) { + if (!scene.satellites[i].visible) continue; + for (size_t j = i + 1; j < count; j++) { + if (group[i] == group[j] || !scene.satellites[j].visible) continue; + float dx = out[i].x - out[j].x; + float dy = out[i].y - out[j].y; + if (dx * dx + dy * dy < minimumDistance * minimumDistance) { + int from = group[j], to = group[i]; + for (auto& g : group) { + if (g == from) g = to; + } + merged = true; + } + } + } + } + + std::vector original = out; + for (size_t leader = 0; leader < count; leader++) { + int members = 0; + float cx = 0.0f, cy = 0.0f; + for (size_t i = 0; i < count; i++) { + if (group[i] != (int)leader) continue; + members++; + cx += original[i].x; + cy += original[i].y; + } + if (members < 2) continue; + cx /= members; + cy /= members; + float spread = minimumDistance / (2.0f * std::sin(PI_F / members)); + int n = 0; + for (size_t i = 0; i < count; i++) { + if (group[i] != (int)leader) continue; + float angle = 2.0f * PI_F * n / members - PI_F / 2.0f; + out[i] = PixelPoint { cx + spread * std::cos(angle), cy + spread * std::sin(angle) }; + n++; + } + } +} + +int MapRenderer::hitTest(const Scene& scene, int x, int y, int touchRadius) const { + int best = -1; + float bestDistance = (float)(touchRadius * touchRadius); + std::vector positions; + markerPositions(scene, positions); + for (size_t i = 0; i < scene.satellites.size(); i++) { + if (!scene.satellites[i].visible) continue; + float sx = positions[i].x; + float sy = positions[i].y; + float d = (sx - x) * (sx - x) + (sy - y) * (sy - y); + if (d <= bestDistance) { + bestDistance = d; + best = (int)i; + } + } + return best; +} + +} diff --git a/Apps/SatelliteTracker/main/Source/MapRenderer.h b/Apps/SatelliteTracker/main/Source/MapRenderer.h new file mode 100644 index 0000000..3f4c744 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/MapRenderer.h @@ -0,0 +1,161 @@ +#pragma once + +#include +#include + +namespace map { + +enum class PixelFormat { + Gray8, + Rgb565, +}; + +struct Rgb { + uint8_t r; + uint8_t g; + uint8_t b; +}; + +enum class MarkerShape { + Circle, + Square, + Triangle, + Diamond, + Plus, + TriangleDown, +}; + +constexpr int MARKER_SHAPE_COUNT = 6; + +struct Theme { + Rgb ocean; + Rgb land; + Rgb coast; + Rgb grid; + Rgb observer; + Rgb markerOutline; + Rgb focus; + Rgb satellite[MARKER_SHAPE_COUNT]; + uint8_t nightDarken; + uint8_t twilightDarken; +}; + +const Theme& colorTheme(); +const Theme& grayTheme(); + +struct GeoPoint { + float lat; + float lon; +}; + +struct PixelPoint { + float x; + float y; +}; + +struct SatelliteOverlay { + /** False when the position is unknown (no orbit data, or SGP4 failed). Such satellites get no marker and no track. */ + bool visible = true; + GeoPoint position; + MarkerShape shape; + /** Slots after the first six use the outlines of the same shapes. */ + bool hollow; + int colorIndex; + bool focused; + bool drawTrack; + /** Points before the current position, oldest first. */ + std::vector pastTrack; + /** Points after the current position, oldest first. */ + std::vector futureTrack; + /** 0 means no footprint. */ + float footprintRadiusDeg; +}; + +struct Scene { + bool showNight; + GeoPoint subsolar; + bool hasObserver; + GeoPoint observer; + std::vector satellites; + /** A frame along the map edges, used to show keyboard focus. */ + bool frame = false; + Rgb frameColor {}; +}; + +/** A pixel buffer that the renderer can draw into. Does not own the memory. */ +class Surface { + +public: + + Surface(void* data, int width, int height, int strideBytes, PixelFormat format); + + int width() const { return w; } + int height() const { return h; } + + void fill(Rgb color); + void setPixel(int x, int y, Rgb color); + void darkenPixel(int x, int y, uint8_t amount); + /** dashPhase (optional) carries the dash pattern position from one call to the next, so a polyline keeps its dashes. */ + void line(int x0, int y0, int x1, int y1, Rgb color, int thickness, int dashOn, int dashOff, int* dashPhase = nullptr); + void marker(int cx, int cy, int radius, MarkerShape shape, bool hollow, Rgb fillColor, Rgb outlineColor); + void circle(int cx, int cy, int radius, int thickness, Rgb color); + void copyFrom(const Surface& other); + +private: + + uint8_t* rowPointer(int y) const { return bytes + y * stride; } + + uint8_t* bytes; + int w; + int h; + int stride; + PixelFormat pixelFormat; +}; + +class MapRenderer { + +public: + + MapRenderer() = default; + ~MapRenderer(); + MapRenderer(const MapRenderer&) = delete; + MapRenderer& operator=(const MapRenderer&) = delete; + + /** Allocates the base map buffer and draws land, coasts and grid. Returns false when out of memory. */ + bool setup(int width, int height, PixelFormat format, const Theme& theme); + + int width() const { return mapWidth; } + int height() const { return mapHeight; } + int markerRadius() const; + + /** Draws the full map into target, which must have the size and format given to setup(). */ + void render(Surface& target, const Scene& scene) const; + + /** Pixel position of a geographic point. */ + void project(GeoPoint point, float& x, float& y) const; + + /** Index of the satellite whose marker is closest to (x, y) within the touch radius, or -1. */ + int hitTest(const Scene& scene, int x, int y, int touchRadius) const; + + /** + * Pixel positions of the markers. Markers that would cover each other (a module docked to a station) are spread around their common spot. + * Satellites that are not visible keep their projected position and are left out of the spreading. + */ + void markerPositions(const Scene& scene, std::vector& out) const; + +private: + + void drawBase(Surface& surface) const; + void drawNight(Surface& surface, const Scene& scene) const; + void drawPolyline(Surface& surface, const std::vector& points, Rgb color, int thickness, int dashOn, int dashOff) const; + void drawFootprint(Surface& surface, GeoPoint center, float radiusDeg, Rgb color) const; + + uint8_t* baseData = nullptr; + int mapWidth = 0; + int mapHeight = 0; + int bytesPerPixel = 1; + PixelFormat pixelFormat = PixelFormat::Gray8; + const Theme* activeTheme = nullptr; +}; + +} diff --git a/Apps/SatelliteTracker/main/Source/MapView.cpp b/Apps/SatelliteTracker/main/Source/MapView.cpp new file mode 100644 index 0000000..6241428 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/MapView.cpp @@ -0,0 +1,552 @@ +#include "SatelliteTracker.h" + +#include "LargeMemory.h" + +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr int GAP = 4; +constexpr int COMPACT_ROW_PAD = 3; +/** Lines of details and status below the legend in the column next to the map. */ +constexpr int SIDE_TEXT_LINES = 4; + +struct LegendClick { + Context* ctx; + int index; +}; + +LegendClick legendClicks[MAX_TRACKED]; + +void updateLegendFocus(Context* ctx) { + MapWidgets& w = ctx->mapWidgets; + for (int i = 0; i < MAX_TRACKED; i++) { + if (w.legendRows[i] != nullptr) lv_obj_invalidate(w.legendRows[i]); + } + int focus = effectiveFocus(ctx); + ctx->shownFocus = ctx->focusedIndex >= 0 ? focus : -2 - focus; +} + +/** Draws a rectangle frame just inside the object's area. */ +void drawFrame(lv_event_t* event, lv_obj_t* obj, lv_color_t color, int width, bool dashed) { + lv_area_t area; + lv_obj_get_coords(obj, &area); + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.color = color; + line.width = width; + if (dashed) { + line.dash_width = width * 4; + line.dash_gap = width * 3; + } + int inset = width / 2; + lv_value_precise_t left = area.x1 + inset, right = area.x2 - inset, top = area.y1 + inset, bottom = area.y2 - inset; + const lv_value_precise_t corners[5][2] = { { left, top }, { right, top }, { right, bottom }, { left, bottom }, { left, top } }; + lv_layer_t* layer = lv_event_get_layer(event); + for (int i = 0; i < 4; i++) { + line.p1.x = corners[i][0]; + line.p1.y = corners[i][1]; + line.p2.x = corners[i + 1][0]; + line.p2.y = corners[i + 1][1]; + lv_draw_line(layer, &line); + } +} + +lv_color_t accentColor(const Context* ctx, lv_obj_t* obj) { + return ctx->grayscale ? lv_color_black() : lv_theme_get_color_primary(obj); +} + +/** Frames the focused legend row: a solid line when selected, a dashed line when chosen automatically. */ +void onLegendDraw(lv_event_t* event) { + auto* click = static_cast(lv_event_get_user_data(event)); + Context* ctx = click->ctx; + lv_obj_t* row = ctx->mapWidgets.legendRows[click->index]; + if (row == nullptr || click->index != effectiveFocus(ctx)) return; + bool inverted = ctx->grayscale && lv_obj_has_state(row, LV_STATE_FOCUS_KEY); + lv_color_t color = inverted ? lv_color_white() : accentColor(ctx, row); + drawFrame(event, row, color, ctx->mapWidgets.detailed ? 2 : 1, click->index != ctx->focusedIndex); +} + +/** Keyboard focus on the map is drawn into the map image itself, the theme's outline would cover the legend below. */ +void onMapFocusChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + lv_obj_t* container = ctx->mapWidgets.mapContainer; + if (container == nullptr) return; + if (lv_obj_has_state(container, LV_STATE_FOCUS_KEY) == ctx->scene.frame) return; + mapViewUpdate(ctx, true); +} + +void setFocus(Context* ctx, int index) { + if (index < -1 || index >= ctx->model.count() || index == ctx->focusedIndex) return; + ctx->focusedIndex = index; + mapViewUpdate(ctx, true); +} + +/** Tapping the selected satellite again clears the selection. */ +void toggleFocus(Context* ctx, int index) { + setFocus(ctx, index == ctx->focusedIndex ? -1 : index); +} + +void onLegendClicked(lv_event_t* event) { + auto* click = static_cast(lv_event_get_user_data(event)); + toggleFocus(click->ctx, click->index); +} + +/** The tracked satellite whose pass is in progress or starts first. */ +int pickNextPass(Context* ctx) { + if (!ctx->model.hasObserver()) return -1; + int best = -1; + int64_t bestRise = 0; + for (int i = 0; i < ctx->model.count(); i++) { + const TrackedSatellite& entry = ctx->model.at(i); + if (entry.passResult != orbit::PassSearchResult::Found) continue; + if (best < 0 || entry.nextPass.riseTime < bestRise) { + best = i; + bestRise = entry.nextPass.riseTime; + } + } + return best; +} + +void toggleToolbar(Context* ctx, bool fromKeys) { + ctx->refocusMap = fromKeys; + ctx->settings.hideToolbar = !ctx->settings.hideToolbar; + settingsSave(ctx->settings); + showView(ctx, View::Map); +} + +/** A tap on a marker toggles its selection, a tap on the empty map (or the trackball button) hides or shows the toolbar. */ +void onMapClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + lv_indev_t* indev = lv_indev_active(); + if (indev == nullptr || ctx->mapWidgets.canvas == nullptr) return; + if (lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) { + toggleToolbar(ctx, true); + return; + } + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t area; + lv_obj_get_coords(ctx->mapWidgets.canvas, &area); + int touchRadius = ctx->renderer.markerRadius() * 3; + int hit = ctx->renderer.hitTest(ctx->scene, point.x - area.x1, point.y - area.y1, touchRadius); + if (hit >= 0) toggleFocus(ctx, hit); + else toggleToolbar(ctx, false); +} + +void onMapKey(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + uint32_t key = lv_event_get_key(event); + int count = ctx->model.count(); + if (count == 0) return; + if (key == LV_KEY_RIGHT) setFocus(ctx, ctx->focusedIndex + 1 >= count ? -1 : ctx->focusedIndex + 1); + else if (key == LV_KEY_LEFT) setFocus(ctx, ctx->focusedIndex - 1 < -1 ? count - 1 : ctx->focusedIndex - 1); +} + +lv_obj_t* createLabel(lv_obj_t* parent, const lv_font_t* font, const char* text) { + lv_obj_t* label = lv_label_create(parent); + lv_obj_set_style_text_font(label, font, 0); + lv_label_set_text(label, text); + return label; +} + +void describeLegendValue(Context* ctx, const TrackedSatellite& entry, char* out, size_t outSize) { + if (!entry.positionValid) { + snprintf(out, outSize, "--"); + } else if (!ctx->model.hasObserver()) { + snprintf(out, outSize, "%.0f km", entry.position.altKm); + } else if (entry.look.elevationDeg > 0.0) { + snprintf(out, outSize, "up %.0f\xC2\xB0", entry.look.elevationDeg); + } else if (entry.passResult == orbit::PassSearchResult::Found) { + formatClock(entry.nextPass.riseTime, out, outSize); + } else { + snprintf(out, outSize, "--"); + } +} + +void describePass(Context* ctx, const TrackedSatellite& entry, char* out, size_t outSize, bool compact) { + if (!ctx->model.hasObserver()) { + snprintf(out, outSize, compact ? "Set location in settings" : "Set your location in the settings to see passes."); + return; + } + char rise[8], set[8]; + bool passInProgress = entry.passResult == orbit::PassSearchResult::Found && entry.nextPass.riseTime <= currentTime(); + if (entry.look.elevationDeg > 0.0 && passInProgress) { + formatClock(entry.nextPass.setTime, set, sizeof(set)); + if (compact) { + snprintf(out, outSize, "Up %.0f° %s, sets %s", entry.look.elevationDeg, compassPoint(entry.look.azimuthDeg), set); + } else { + snprintf(out, outSize, "Above the horizon: %.0f°, azimuth %.0f° %s, sets %s", entry.look.elevationDeg, + entry.look.azimuthDeg, compassPoint(entry.look.azimuthDeg), set); + } + return; + } + switch (entry.passResult) { + case orbit::PassSearchResult::Found: { + formatClock(entry.nextPass.riseTime, rise, sizeof(rise)); + formatClock(entry.nextPass.setTime, set, sizeof(set)); + char day[16]; + formatDay(entry.nextPass.riseTime, day, sizeof(day)); + if (compact) { + snprintf(out, outSize, "Pass %s%s max %.0f\xC2\xB0 %s", day, rise, entry.nextPass.maxElevationDeg, compassPoint(entry.nextPass.riseAzimuthDeg)); + } else { + snprintf(out, outSize, "Next pass %s%s - %s, max %.0f\xC2\xB0, %s to %s", day, rise, set, entry.nextPass.maxElevationDeg, + compassPoint(entry.nextPass.riseAzimuthDeg), compassPoint(entry.nextPass.setAzimuthDeg)); + } + break; + } + case orbit::PassSearchResult::AlwaysUp: + snprintf(out, outSize, compact ? "Always up, %.0f\xC2\xB0" : "Always above your horizon, elevation %.0f\xC2\xB0", entry.look.elevationDeg); + break; + case orbit::PassSearchResult::None: + snprintf(out, outSize, compact ? "No pass in 36 h" : "No pass higher than 5° in the next 36 hours"); + break; + case orbit::PassSearchResult::Error: + snprintf(out, outSize, compact ? "Orbit data is too old" : "The orbit data is too old for a prediction"); + break; + } +} + +void describeUpdated(Context* ctx, char* out, size_t outSize) { + char clock[8]; + formatClock(currentTime(), clock, sizeof(clock)); + int64_t age = orbitDataAge(ctx); + if (age >= 0) { + snprintf(out, outSize, "Updated %s, orbit data %d h old", clock, (int)(age / 3600)); + } else { + snprintf(out, outSize, "Updated %s", clock); + } +} + +void describeFooter(Context* ctx, char* out, size_t outSize) { + if (ctx->statusText[0] != '\0') { + snprintf(out, outSize, "%s", ctx->statusText); + } else { + describeUpdated(ctx, out, outSize); + } +} + +int textWidth(const lv_font_t* font, const char* text) { + int width = 0; + for (const char* p = text; *p; p++) { + width += (int)lv_font_get_glyph_width(font, (uint32_t)(unsigned char)*p, (uint32_t)(unsigned char)p[1]); + } + return width; +} + +lv_obj_t* createLineLabel(lv_obj_t* parent, const lv_font_t* font, int x, int y, int width) { + lv_obj_t* label = createLabel(parent, font, ""); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_CLIP); + lv_obj_set_width(label, width); + lv_obj_set_pos(label, x, y); + return label; +} + +bool prepareRenderer(Context* ctx, int width, int height) { + map::PixelFormat format = ctx->grayscale ? map::PixelFormat::Gray8 : map::PixelFormat::Rgb565; + if (ctx->renderer.width() == width && ctx->renderer.height() == height) return true; + return ctx->renderer.setup(width, height, format, ctx->grayscale ? map::grayTheme() : map::colorTheme()); +} + +void createLegendRow(Context* ctx, lv_obj_t* parent, int index, int x, int y, int width, int height, const lv_font_t* font) { + MapWidgets& w = ctx->mapWidgets; + const TrackedSatellite& entry = ctx->model.at(index); + lv_obj_t* row = lvgl_focus_container_create(parent); + lv_obj_set_pos(row, x, y); + lv_obj_set_size(row, width, height); + lv_obj_set_clickable(row, true); + // Keyboard focus is shown as a tint, so the frame stays the only mark of the selected satellite. + // E-paper shows it inverted instead, a light tint turns fully black on black and white panels. + lv_obj_set_style_outline_width(row, 0, LV_STATE_FOCUS_KEY); + if (ctx->grayscale) { + lv_obj_set_style_bg_color(row, lv_color_black(), LV_STATE_FOCUS_KEY); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_FOCUS_KEY); + lv_obj_set_style_text_color(row, lv_color_white(), LV_STATE_FOCUS_KEY); + } else { + lv_obj_set_style_bg_color(row, lv_theme_get_color_primary(row), LV_STATE_FOCUS_KEY); + lv_obj_set_style_bg_opa(row, LV_OPA_40, LV_STATE_FOCUS_KEY); + } + lv_obj_set_style_radius(row, 4, 0); + lv_obj_set_style_pad_left(row, 3, 0); + lv_obj_set_style_pad_right(row, ctx->mapWidgets.detailed ? 8 : 3, 0); + lv_obj_set_style_pad_column(row, 4, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + legendClicks[index] = LegendClick { ctx, index }; + lv_obj_add_event_cb(row, onLegendClicked, LV_EVENT_CLICKED, &legendClicks[index]); + lv_obj_add_event_cb(row, onLegendDraw, LV_EVENT_DRAW_POST, &legendClicks[index]); + + createMarkerImage(ctx, row, entry.slot); + + lv_obj_t* name = createLabel(row, font, entry.shortName); + lv_label_set_long_mode(name, LV_LABEL_LONG_MODE_CLIP); + lv_obj_set_flex_grow(name, 1); + w.legendNames[index] = name; + + lv_obj_t* value = createLabel(row, font, ""); + w.legendValues[index] = value; + w.legendRows[index] = row; +} + +} + +void mapViewCreate(Context* ctx) { + lv_obj_t* content = ctx->content; + lv_obj_set_style_pad_all(content, 0, 0); + lv_obj_set_scrollable(content, false); + lv_obj_update_layout(content); + int width = lv_obj_get_content_width(content); + int height = lv_obj_get_content_height(content); + + MapWidgets& w = ctx->mapWidgets; + w.detailed = width >= 560; + const lv_font_t* smallFont = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* defaultFont = lvgl_get_text_font(FONT_SIZE_DEFAULT); + int smallHeight = (int)lv_font_get_line_height(smallFont); + int count = ctx->model.count(); + + // A legend entry needs room for the icon, a short name and a time like "ABCDEFG 88:88". Longer names are clipped. + int rowHeight = smallHeight + (w.detailed ? COMPACT_ROW_PAD * 2 : COMPACT_ROW_PAD); + int minimumEntryWidth = ctx->iconSize + textWidth(smallFont, "ABCDEFG 88:88") + (w.detailed ? 8 : 3) + 3 + 8; + int columns = (width + minimumEntryWidth / 2) / minimumEntryWidth; + if (columns < 1) columns = 1; + if (columns > MAX_TRACKED / 2) columns = MAX_TRACKED / 2; + int rows = count > columns ? (count + columns - 1) / columns : 1; + int bottomHeight = rows * rowHeight + smallHeight * 2 + 3; + int bottomMapWidth = width; + if (bottomMapWidth > (height - bottomHeight) * 2) bottomMapWidth = (height - bottomHeight) * 2; + + // Legend, details and status in a column next to the map, for short and wide screens. + int sideColumnWidth = minimumEntryWidth; + if (width - height * 2 - GAP > sideColumnWidth) sideColumnWidth = width - height * 2 - GAP; + int sideMapWidth = 0; + if (width > height) { + sideMapWidth = width - sideColumnWidth - GAP; + if (sideMapWidth > height * 2) sideMapWidth = height * 2; + } + + w.wide = sideMapWidth > bottomMapWidth; + int mapWidth = w.wide ? sideMapWidth : bottomMapWidth; + mapWidth &= ~1; + if (mapWidth < 40) mapWidth = 40; + int mapHeight = mapWidth / 2; + int mapX = w.wide ? 0 : (width - mapWidth) / 2; + if (w.wide) { + // From the full window height, so hiding or showing the toolbar does not change the limit. + int fullHeight = lv_obj_get_content_height(lv_obj_get_parent(content)); + ctx->model.setLimit((fullHeight - smallHeight * SIDE_TEXT_LINES - GAP) / (rowHeight + 2)); + } else { + ctx->model.setLimit(columns * 2); + } + + if (!prepareRenderer(ctx, mapWidth, mapHeight)) { + lv_obj_t* label = createLabel(content, defaultFont, "Not enough memory for the map."); + lv_obj_center(label); + return; + } + + int bytesPerPixel = ctx->grayscale ? 1 : 2; + w.canvasBuffer = static_cast(largeAlloc((size_t)mapWidth * mapHeight * bytesPerPixel)); + if (w.canvasBuffer == nullptr) { + lv_obj_t* label = createLabel(content, defaultFont, "Not enough memory for the map."); + lv_obj_center(label); + return; + } + w.mapContainer = lvgl_focus_container_create(content); + lv_obj_set_pos(w.mapContainer, mapX, 0); + lv_obj_set_size(w.mapContainer, mapWidth, mapHeight); + lv_obj_add_event_cb(w.mapContainer, onMapClicked, LV_EVENT_CLICKED, ctx); + lv_obj_add_event_cb(w.mapContainer, onMapKey, LV_EVENT_KEY, ctx); + lv_obj_add_event_cb(w.mapContainer, onMapFocusChanged, LV_EVENT_FOCUSED, ctx); + lv_obj_add_event_cb(w.mapContainer, onMapFocusChanged, LV_EVENT_DEFOCUSED, ctx); + lv_obj_set_style_outline_width(w.mapContainer, 0, LV_STATE_FOCUS_KEY); + w.canvas = lv_canvas_create(w.mapContainer); + lv_canvas_set_buffer(w.canvas, w.canvasBuffer, mapWidth, mapHeight, ctx->grayscale ? LV_COLOR_FORMAT_L8 : LV_COLOR_FORMAT_RGB565); + lv_obj_set_clickable(w.canvas, false); + w.mapX = mapX; + + if (ctx->focusedIndex >= count) ctx->focusedIndex = -1; + ctx->shownFocus = -100; + + if (w.wide) { + // The details and the status keep a fixed place at the bottom, the legend above scrolls when it does not fit. + int columnX = mapWidth + GAP; + int columnWidth = width - mapWidth - GAP * 2; + int textHeight = smallHeight * SIDE_TEXT_LINES; + lv_obj_t* legend = lv_obj_create(content); + lv_obj_remove_style_all(legend); + lv_obj_set_pos(legend, columnX, 0); + lv_obj_set_size(legend, width - columnX, height - textHeight - GAP); + lv_obj_set_flex_flow(legend, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(legend, 2, 0); + lv_obj_set_scroll_dir(legend, LV_DIR_VER); + lv_obj_set_scrollbar_mode(legend, LV_SCROLLBAR_MODE_AUTO); + for (int i = 0; i < count; i++) { + createLegendRow(ctx, legend, i, 0, 0, columnWidth, rowHeight, smallFont); + lv_obj_set_scroll_on_focus(w.legendRows[i], true); + } + w.info = createLabel(content, smallFont, count == 0 ? "No satellites yet. Use the list button to add some." : ""); + lv_label_set_long_mode(w.info, LV_LABEL_LONG_MODE_DOTS); + lv_obj_set_size(w.info, columnWidth, smallHeight * (SIDE_TEXT_LINES - 1)); + lv_obj_set_pos(w.info, columnX, height - textHeight); + w.status = createLabel(content, smallFont, ""); + lv_label_set_long_mode(w.status, LV_LABEL_LONG_MODE_DOTS); + lv_obj_set_size(w.status, columnWidth, smallHeight); + lv_obj_set_pos(w.status, columnX, height - smallHeight); + } else { + int columnWidth = (width - GAP) / columns; + for (int i = 0; i < count; i++) { + createLegendRow(ctx, content, i, (i % columns) * columnWidth, mapHeight + 1 + (i / columns) * rowHeight, columnWidth, rowHeight, smallFont); + } + int infoY = mapHeight + 2 + rows * rowHeight; + w.info = createLineLabel(content, smallFont, 2, infoY, width - 2); + if (count == 0) setLabelText(w.info, "No satellites yet. Use the list button to add some."); + if (w.detailed) { + int statusWidth = textWidth(smallFont, "Updated 88:88, orbit data 88 h old"); + w.passLabel = createLineLabel(content, smallFont, 2, infoY + smallHeight, width - statusWidth - GAP * 2); + w.status = createLineLabel(content, smallFont, width - statusWidth - 2, infoY + smallHeight, statusWidth); + lv_obj_set_style_text_align(w.status, LV_TEXT_ALIGN_RIGHT, 0); + } else { + w.status = createLineLabel(content, smallFont, 2, infoY + smallHeight, width - 2); + } + } + + if (ctx->refocusMap) { + ctx->refocusMap = false; + if (lv_obj_get_group(w.mapContainer) != nullptr) { + lv_obj_add_state(w.mapContainer, LV_STATE_FOCUS_KEY); + lv_group_focus_obj(w.mapContainer); + } + } + + mapViewUpdate(ctx, true); +} + +void mapViewDestroy(Context* ctx) { + MapWidgets& w = ctx->mapWidgets; + if (w.mapContainer != nullptr) { + // Cleared first: deleting the focused map moves the focus, and the focus handler must not redraw a map that is going away. + lv_obj_t* container = w.mapContainer; + w.mapContainer = nullptr; + w.canvas = nullptr; + lv_obj_delete(container); + } + largeFree(w.canvasBuffer); + w.canvasBuffer = nullptr; +} + +void mapViewUpdate(Context* ctx, bool force) { + MapWidgets& w = ctx->mapWidgets; + if (w.canvas == nullptr) return; + int64_t now = currentTime(); + if (!force && !ctx->forceRedraw && now - ctx->lastMapDraw < effectiveUpdateSeconds(ctx)) return; + + map::Surface surface(w.canvasBuffer, ctx->renderer.width(), ctx->renderer.height(), + ctx->renderer.width() * (ctx->grayscale ? 1 : 2), ctx->grayscale ? map::PixelFormat::Gray8 : map::PixelFormat::Rgb565); + + if (!timeIsValid()) { + ctx->scene = map::Scene {}; + ctx->renderer.render(surface, ctx->scene); + lv_obj_invalidate(w.canvas); + setLabelText(w.info, "The clock is not set yet."); + setLabelText(w.status, "Connect to Wi-Fi so the time can sync."); + ctx->lastMapDraw = now; + ctx->forceRedraw = false; + return; + } + + ctx->model.updatePositions((double)now); + ctx->model.updatePasses(now, PASS_SEARCH_SECONDS); + if (ctx->focusedIndex >= ctx->model.count()) ctx->focusedIndex = -1; + ctx->autoIndex = pickNextPass(ctx); + int focus = effectiveFocus(ctx); + int shownFocus = ctx->focusedIndex >= 0 ? focus : -2 - focus; + if (shownFocus != ctx->shownFocus) updateLegendFocus(ctx); + ctx->model.buildScene((double)now, focus, ctx->settings.tracksForAll, ctx->scene); + ctx->scene.frame = w.mapContainer != nullptr && lv_obj_has_state(w.mapContainer, LV_STATE_FOCUS_KEY); + if (ctx->scene.frame) { + lv_color_t color = accentColor(ctx, w.mapContainer); + ctx->scene.frameColor = map::Rgb { color.red, color.green, color.blue }; + } + ctx->renderer.render(surface, ctx->scene); + lv_obj_invalidate(w.canvas); + + char text[320]; + for (int i = 0; i < ctx->model.count(); i++) { + describeLegendValue(ctx, ctx->model.at(i), text, sizeof(text)); + setLabelText(w.legendValues[i], text); + } + + if (focus >= 0 && !ctx->model.at(focus).positionValid) { + const TrackedSatellite& focused = ctx->model.at(focus); + if (focused.hasOrbit) { + snprintf(text, sizeof(text), w.detailed ? "%s: the orbit data can no longer be used (re-entered or too old)." : "%s: orbit data unusable", + focused.shortName); + } else { + snprintf(text, sizeof(text), w.detailed ? "%s: waiting for orbit data, it is downloaded when Wi-Fi is available." : "%s: waiting for orbit data", + focused.shortName); + } + setLabelText(w.info, text); + setLabelText(w.passLabel, ""); + } else if (focus >= 0) { + const TrackedSatellite& focused = ctx->model.at(focus); + bool automatic = focus != ctx->focusedIndex; + char pass[128]; + describePass(ctx, focused, pass, sizeof(pass), !w.detailed); + const char* ns = focused.position.latDeg >= 0.0 ? "N" : "S"; + const char* ew = focused.position.lonDeg >= 0.0 ? "E" : "W"; + double lat = focused.position.latDeg < 0 ? -focused.position.latDeg : focused.position.latDeg; + double lon = focused.position.lonDeg < 0 ? -focused.position.lonDeg : focused.position.lonDeg; + unsigned catalog = (unsigned)focused.catalogNumber; + if (w.wide && w.detailed) { + snprintf(text, sizeof(text), "%s (%u)%s\n%.1f\xC2\xB0%s %.1f\xC2\xB0%s, %.0f km, %s\n%s", focused.tle.name, catalog, + automatic ? ", auto" : "", lat, ns, lon, ew, focused.position.altKm, focused.sunlit ? "sunlit" : "in shadow", pass); + } else if (w.wide) { + snprintf(text, sizeof(text), "%s%s\n%.0f km, %s\n%s", automatic ? "auto " : "", focused.shortName, focused.position.altKm, + focused.sunlit ? "sunlit" : "in shadow", pass); + } else if (w.detailed) { + snprintf(text, sizeof(text), "%s (%u)%s: %.1f\xC2\xB0%s %.1f\xC2\xB0%s, %.0f km, %.2f km/s, %s", focused.tle.name, catalog, + automatic ? ", selected automatically" : "", lat, ns, lon, ew, focused.position.altKm, focused.speedKmS, + focused.sunlit ? "sunlit" : "in shadow"); + if (w.passLabel != nullptr && ctx->statusText[0] == '\0') setLabelText(w.passLabel, pass); + } else { + snprintf(text, sizeof(text), "%s%s %.0f km %s | %s", automatic ? "auto " : "", focused.shortName, focused.position.altKm, + focused.sunlit ? "lit" : "dark", pass); + } + setLabelText(w.info, text); + } else if (ctx->model.count() > 0) { + const char* hint; + if (!ctx->settings.autoSelectPass) { + hint = w.detailed ? "No satellite selected. Tap a satellite to see its track and details." : "Tap a satellite to select it"; + } else if (!ctx->model.hasObserver()) { + hint = w.detailed ? "No satellite selected. Set your location in the settings to select the next pass automatically." + : "Set location for automatic selection"; + } else { + hint = w.detailed ? "No satellite selected and no pass higher than 5° in the next 36 hours." : "No pass in 36 h"; + } + setLabelText(w.info, hint); + setLabelText(w.passLabel, ""); + } + + if (w.passLabel != nullptr) { + // The status message replaces the pass text so it has the whole line. + if (ctx->statusText[0] != '\0') { + setLabelText(w.passLabel, ctx->statusText); + setLabelText(w.status, ""); + } else { + describeUpdated(ctx, text, sizeof(text)); + setLabelText(w.status, text); + } + } else { + describeFooter(ctx, text, sizeof(text)); + setLabelText(w.status, text); + } + ctx->lastMapDraw = now; + ctx->forceRedraw = false; +} diff --git a/Apps/SatelliteTracker/main/Source/Orbit.cpp b/Apps/SatelliteTracker/main/Source/Orbit.cpp new file mode 100644 index 0000000..afe3d5b --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Orbit.cpp @@ -0,0 +1,414 @@ +#include "Orbit.h" + +#include "Sgp4Bridge.h" + +#include +#include +#include + +namespace orbit { + +namespace { + +constexpr double PI = 3.14159265358979323846; +constexpr double DEG = PI / 180.0; +constexpr double WGS84_A = 6378.137; +constexpr double WGS84_F = 1.0 / 298.257223563; +constexpr double UNIX_EPOCH_JD = 2440587.5; +constexpr int64_t RISE_SET_STEP_SECONDS = 20; +constexpr int64_t MAX_PASS_SECONDS = 12 * 3600; + +double wrapDegrees180(double deg) { + deg = std::fmod(deg + 180.0, 360.0); + if (deg < 0.0) deg += 360.0; + return deg - 180.0; +} + +double wrapDegrees360(double deg) { + deg = std::fmod(deg, 360.0); + if (deg < 0.0) deg += 360.0; + return deg; +} + +bool parseField(const char* line, int start, int length, double& out) { + char buffer[24]; + if (length >= (int)sizeof(buffer)) return false; + memcpy(buffer, line + start, length); + buffer[length] = '\0'; + char* end = nullptr; + out = strtod(buffer, &end); + while (end && *end == ' ') end++; + return end != buffer && end && *end == '\0'; +} + +/** Parses the TLE notation " 12345-3" which means 0.12345e-3. */ +bool parseImpliedExponent(const char* line, int start, double& out) { + char mantissa[8] = "0."; + char sign = line[start]; + for (int i = 0; i < 5; i++) { + char c = line[start + 1 + i]; + mantissa[2 + i] = (c == ' ') ? '0' : c; + } + mantissa[7] = '\0'; + double value = strtod(mantissa, nullptr); + char expSign = line[start + 6]; + char expDigit = line[start + 7]; + if (expDigit < '0' || expDigit > '9') return false; + int exponent = expDigit - '0'; + if (expSign == '-') exponent = -exponent; + value *= std::pow(10.0, exponent); + out = (sign == '-') ? -value : value; + return true; +} + +double julianDayOfJanuaryFirst(int year) { + return 367.0 * year - std::floor(7.0 * year * 0.25) + 30.0 + 1.0 + 1721013.5; +} + +double lengthOf(const Vec3& v) { + return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); +} + +Vec3 eciToEcef(const Vec3& eci, double gmst) { + double c = std::cos(gmst); + double s = std::sin(gmst); + return Vec3 { c * eci.x + s * eci.y, -s * eci.x + c * eci.y, eci.z }; +} + +Geodetic ecefToGeodetic(const Vec3& ecef) { + const double e2 = WGS84_F * (2.0 - WGS84_F); + double p = std::sqrt(ecef.x * ecef.x + ecef.y * ecef.y); + double lon = std::atan2(ecef.y, ecef.x); + double lat = std::atan2(ecef.z, p * (1.0 - e2)); + double height = 0.0; + for (int i = 0; i < 5; i++) { + double sinLat = std::sin(lat); + double n = WGS84_A / std::sqrt(1.0 - e2 * sinLat * sinLat); + height = p / std::cos(lat) - n; + lat = std::atan2(ecef.z, p * (1.0 - e2 * n / (n + height))); + } + return Geodetic { lat / DEG, wrapDegrees180(lon / DEG), height }; +} + +void sunEclipticLongitude(double unixTime, double& outLambda, double& outObliquity) { + double n = julianDateFromUnix(unixTime) - 2451545.0; + double meanLongitude = wrapDegrees360(280.460 + 0.9856474 * n); + double meanAnomaly = wrapDegrees360(357.528 + 0.9856003 * n) * DEG; + outLambda = (meanLongitude + 1.915 * std::sin(meanAnomaly) + 0.020 * std::sin(2.0 * meanAnomaly)) * DEG; + outObliquity = (23.439 - 0.0000004 * n) * DEG; +} + +} + +bool tleLineValid(const char* line, char expectedLineNumber) { + if (line == nullptr || strlen(line) < 69 || line[0] != expectedLineNumber || line[1] != ' ') return false; + int sum = 0; + for (int i = 0; i < 68; i++) { + char c = line[i]; + if (c >= '0' && c <= '9') sum += c - '0'; + else if (c == '-') sum += 1; + } + char check = line[68]; + return check >= '0' && check <= '9' && (sum % 10) == (check - '0'); +} + +bool parseTle(const char* name, const char* line1, const char* line2, Tle& out) { + if (!tleLineValid(line1, '1') || !tleLineValid(line2, '2')) return false; + if (strncmp(line1 + 2, line2 + 2, 5) != 0) return false; + + memset(&out, 0, sizeof(out)); + if (name != nullptr) { + while (*name == ' ') name++; + if (name[0] == '0' && name[1] == ' ') name += 2; + strncpy(out.name, name, sizeof(out.name) - 1); + size_t len = strlen(out.name); + while (len > 0 && (out.name[len - 1] == ' ' || out.name[len - 1] == '\r' || out.name[len - 1] == '\n')) { + out.name[--len] = '\0'; + } + } + memcpy(out.line1, line1, 69); + memcpy(out.line2, line2, 69); + if (out.name[0] == '\0') { + memcpy(out.name, line1 + 2, 5); + } + return true; +} + +uint32_t tleCatalogNumber(const Tle& tle) { + const char* field = tle.line1 + 2; + uint32_t value = 0; + int first = field[0]; + if (first >= 'A' && first <= 'Z' && first != 'I' && first != 'O') { + int letterValue = first - 'A' + 10; + if (first > 'I') letterValue--; + if (first > 'O') letterValue--; + value = (uint32_t)letterValue; + } else if (first >= '0' && first <= '9') { + value = (uint32_t)(first - '0'); + } else if (first != ' ') { + return 0; + } + for (int i = 1; i < 5; i++) { + char c = field[i]; + if (c == ' ') c = '0'; + if (c < '0' || c > '9') return 0; + value = value * 10 + (uint32_t)(c - '0'); + } + return value; +} + +double julianDateFromUnix(double unixTime) { + return unixTime / 86400.0 + UNIX_EPOCH_JD; +} + +double gmstFromUnix(double unixTime) { + return sgp4_bridge_gmst(julianDateFromUnix(unixTime)); +} + +Geodetic eciToGeodetic(const Vec3& eciKm, double unixTime) { + return ecefToGeodetic(eciToEcef(eciKm, gmstFromUnix(unixTime))); +} + +Vec3 geodeticToEcef(const Geodetic& geodetic) { + const double e2 = WGS84_F * (2.0 - WGS84_F); + double lat = geodetic.latDeg * DEG; + double lon = geodetic.lonDeg * DEG; + double sinLat = std::sin(lat); + double n = WGS84_A / std::sqrt(1.0 - e2 * sinLat * sinLat); + double r = (n + geodetic.altKm) * std::cos(lat); + return Vec3 { r * std::cos(lon), r * std::sin(lon), (n * (1.0 - e2) + geodetic.altKm) * sinLat }; +} + +Geodetic subsolarPoint(double unixTime) { + double lambda, obliquity; + sunEclipticLongitude(unixTime, lambda, obliquity); + double rightAscension = std::atan2(std::cos(obliquity) * std::sin(lambda), std::cos(lambda)); + double declination = std::asin(std::sin(obliquity) * std::sin(lambda)); + double lon = (rightAscension - gmstFromUnix(unixTime)) / DEG; + return Geodetic { declination / DEG, wrapDegrees180(lon), 0.0 }; +} + +Vec3 sunDirectionEci(double unixTime) { + double lambda, obliquity; + sunEclipticLongitude(unixTime, lambda, obliquity); + return Vec3 { + std::cos(lambda), + std::cos(obliquity) * std::sin(lambda), + std::sin(obliquity) * std::sin(lambda) + }; +} + +bool isSunlit(const Vec3& eciKm, double unixTime) { + Vec3 sun = sunDirectionEci(unixTime); + double along = eciKm.x * sun.x + eciKm.y * sun.y + eciKm.z * sun.z; + if (along >= 0.0) return true; + Vec3 perpendicular { eciKm.x - along * sun.x, eciKm.y - along * sun.y, eciKm.z - along * sun.z }; + return lengthOf(perpendicular) > EARTH_RADIUS_KM; +} + +double footprintRadiusDeg(double altitudeKm) { + if (altitudeKm <= 0.0) return 0.0; + return std::acos(EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm)) / DEG; +} + +void destinationPoint(double latDeg, double lonDeg, double distanceDeg, double bearingDeg, double& outLatDeg, double& outLonDeg) { + double lat1 = latDeg * DEG; + double lon1 = lonDeg * DEG; + double d = distanceDeg * DEG; + double b = bearingDeg * DEG; + double lat2 = std::asin(std::sin(lat1) * std::cos(d) + std::cos(lat1) * std::sin(d) * std::cos(b)); + double lon2 = lon1 + std::atan2(std::sin(b) * std::sin(d) * std::cos(lat1), std::cos(d) - std::sin(lat1) * std::sin(lat2)); + outLatDeg = lat2 / DEG; + outLonDeg = wrapDegrees180(lon2 / DEG); +} + +Satellite::Satellite() = default; + +Satellite::~Satellite() { + if (record != nullptr) { + sgp4_bridge_destroy(record); + } +} + +bool Satellite::init(const Tle& tle) { + valid = false; + if (record == nullptr) { + record = sgp4_bridge_create(); + if (record == nullptr) return false; + } + + const char* l1 = tle.line1; + const char* l2 = tle.line2; + double epochYear, epochDay; + Sgp4Elements elements {}; + if (!parseField(l1, 18, 2, epochYear) || !parseField(l1, 20, 12, epochDay)) return false; + if (!parseField(l1, 33, 10, elements.meanMotionDot)) return false; + if (!parseImpliedExponent(l1, 44, elements.meanMotionDotDot)) return false; + if (!parseImpliedExponent(l1, 53, elements.bstar)) return false; + if (!parseField(l2, 8, 8, elements.inclinationDeg)) return false; + if (!parseField(l2, 17, 8, elements.raanDeg)) return false; + double eccentricityDigits; + if (!parseField(l2, 26, 7, eccentricityDigits)) return false; + elements.eccentricity = eccentricityDigits * 1.0e-7; + if (!parseField(l2, 34, 8, elements.argumentOfPerigeeDeg)) return false; + if (!parseField(l2, 43, 8, elements.meanAnomalyDeg)) return false; + if (!parseField(l2, 52, 11, elements.meanMotionRevPerDay)) return false; + if (elements.meanMotionRevPerDay <= 0.0) return false; + + int year = (int)epochYear; + year += (year < 57) ? 2000 : 1900; + double wholeDay = std::floor(epochDay); + elements.epochJulianDay = julianDayOfJanuaryFirst(year) + wholeDay - 1.0; + elements.epochJulianDayFraction = epochDay - wholeDay; + + if (!sgp4_bridge_init(record, &elements)) return false; + + epoch = ((elements.epochJulianDay - UNIX_EPOCH_JD) + elements.epochJulianDayFraction) * 86400.0; + meanMotion = elements.meanMotionRevPerDay; + valid = true; + return true; +} + +double Satellite::periodMinutes() const { + return meanMotion > 0.0 ? 1440.0 / meanMotion : 0.0; +} + +bool Satellite::positionEci(double unixTime, Vec3& outPosition, Vec3& outVelocity) { + if (!valid) return false; + double r[3], v[3]; + if (!sgp4_bridge_propagate(record, (unixTime - epoch) / 60.0, r, v)) return false; + outPosition = Vec3 { r[0], r[1], r[2] }; + outVelocity = Vec3 { v[0], v[1], v[2] }; + return true; +} + +bool Satellite::geodetic(double unixTime, Geodetic& out) { + Vec3 r, v; + if (!positionEci(unixTime, r, v)) return false; + out = eciToGeodetic(r, unixTime); + return true; +} + +bool Satellite::lookAngles(const Geodetic& observer, double unixTime, LookAngles& out) { + Vec3 r, v; + if (!positionEci(unixTime, r, v)) return false; + Vec3 satellite = eciToEcef(r, gmstFromUnix(unixTime)); + Vec3 station = geodeticToEcef(observer); + Vec3 range { satellite.x - station.x, satellite.y - station.y, satellite.z - station.z }; + + double lat = observer.latDeg * DEG; + double lon = observer.lonDeg * DEG; + double sinLat = std::sin(lat), cosLat = std::cos(lat); + double sinLon = std::sin(lon), cosLon = std::cos(lon); + double south = sinLat * cosLon * range.x + sinLat * sinLon * range.y - cosLat * range.z; + double east = -sinLon * range.x + cosLon * range.y; + double up = cosLat * cosLon * range.x + cosLat * sinLon * range.y + sinLat * range.z; + + double distance = lengthOf(range); + out.rangeKm = distance; + out.elevationDeg = std::asin(up / distance) / DEG; + out.azimuthDeg = wrapDegrees360(std::atan2(east, -south) / DEG); + return true; +} + +bool Satellite::elevationAt(const Geodetic& observer, double unixTime, double& outElevation) { + LookAngles angles; + if (!lookAngles(observer, unixTime, angles)) return false; + outElevation = angles.elevationDeg; + return true; +} + +int64_t Satellite::refineCrossing(const Geodetic& observer, int64_t below, int64_t above) { + while (std::llabs(above - below) > 1) { + int64_t middle = below + (above - below) / 2; + double elevation; + if (!elevationAt(observer, (double)middle, elevation)) break; + if (elevation > 0.0) above = middle; + else below = middle; + } + return above; +} + +PassSearchResult Satellite::findNextPass(const Geodetic& observer, int64_t startTime, int64_t searchSeconds, double minElevationDeg, Pass& out) { + if (!valid) return PassSearchResult::Error; + + int64_t endTime = startTime + searchSeconds; + int64_t t = startTime; + double elevation; + if (!elevationAt(observer, (double)t, elevation)) return PassSearchResult::Error; + + if (elevation > 0.0) { + int64_t back = t; + double backElevation = elevation; + while (backElevation > 0.0) { + if (startTime - back > MAX_PASS_SECONDS) return PassSearchResult::AlwaysUp; + back -= RISE_SET_STEP_SECONDS; + if (!elevationAt(observer, (double)back, backElevation)) return PassSearchResult::Error; + } + t = back; + elevation = backElevation; + } + + while (true) { + while (elevation <= 0.0) { + int64_t step = RISE_SET_STEP_SECONDS; + if (elevation < -2.5) { + step = (int64_t)(-elevation * 8.0); + } + t += step; + if (t > endTime) return PassSearchResult::None; + if (!elevationAt(observer, (double)t, elevation)) return PassSearchResult::Error; + if (elevation > 0.0) { + t = refineCrossing(observer, t - step, t); + } + } + + Pass pass {}; + pass.riseTime = t; + pass.maxTime = t; + pass.maxElevationDeg = 0.0; + + int64_t s = t; + double setElevation = elevation; + while (setElevation > 0.0) { + if (s - t > MAX_PASS_SECONDS) return PassSearchResult::AlwaysUp; + if (setElevation > pass.maxElevationDeg) { + pass.maxElevationDeg = setElevation; + pass.maxTime = s; + } + s += RISE_SET_STEP_SECONDS; + if (!elevationAt(observer, (double)s, setElevation)) return PassSearchResult::Error; + } + pass.setTime = refineCrossing(observer, s, s - RISE_SET_STEP_SECONDS); + + int64_t low = pass.maxTime - RISE_SET_STEP_SECONDS; + int64_t high = pass.maxTime + RISE_SET_STEP_SECONDS; + if (low < pass.riseTime) low = pass.riseTime; + if (high > pass.setTime) high = pass.setTime; + while (high - low > 2) { + int64_t m1 = low + (high - low) / 3; + int64_t m2 = high - (high - low) / 3; + double e1, e2; + if (!elevationAt(observer, (double)m1, e1) || !elevationAt(observer, (double)m2, e2)) return PassSearchResult::Error; + if (e1 < e2) low = m1; + else high = m2; + } + pass.maxTime = (low + high) / 2; + elevationAt(observer, (double)pass.maxTime, pass.maxElevationDeg); + + LookAngles angles; + if (lookAngles(observer, (double)pass.riseTime, angles)) pass.riseAzimuthDeg = angles.azimuthDeg; + if (lookAngles(observer, (double)pass.setTime, angles)) pass.setAzimuthDeg = angles.azimuthDeg; + + if (pass.maxElevationDeg >= minElevationDeg) { + out = pass; + return PassSearchResult::Found; + } + + t = pass.setTime + 1; + if (t > endTime) return PassSearchResult::None; + if (!elevationAt(observer, (double)t, elevation)) return PassSearchResult::Error; + } +} + +} diff --git a/Apps/SatelliteTracker/main/Source/Orbit.h b/Apps/SatelliteTracker/main/Source/Orbit.h new file mode 100644 index 0000000..f637454 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Orbit.h @@ -0,0 +1,119 @@ +#pragma once + +#include + +struct ElsetRec; + +namespace orbit { + +constexpr double EARTH_RADIUS_KM = 6378.137; + +struct Vec3 { + double x; + double y; + double z; +}; + +struct Tle { + char name[25]; + char line1[70]; + char line2[70]; +}; + +struct Geodetic { + double latDeg; + double lonDeg; + double altKm; +}; + +struct LookAngles { + double azimuthDeg; + double elevationDeg; + double rangeKm; +}; + +struct Pass { + int64_t riseTime; + int64_t maxTime; + int64_t setTime; + double maxElevationDeg; + double riseAzimuthDeg; + double setAzimuthDeg; +}; + +enum class PassSearchResult { + Found, + None, + AlwaysUp, + Error, +}; + +bool tleLineValid(const char* line, char expectedLineNumber); + +/** Copies and validates a TLE. The name is trimmed. Returns false on a bad line length or checksum. */ +bool parseTle(const char* name, const char* line1, const char* line2, Tle& out); + +/** NORAD catalog number, including the Alpha-5 form (A0001 = 100001). Returns 0 when invalid. */ +uint32_t tleCatalogNumber(const Tle& tle); + +double julianDateFromUnix(double unixTime); + +/** Greenwich mean sidereal time in radians. */ +double gmstFromUnix(double unixTime); + +Geodetic eciToGeodetic(const Vec3& eciKm, double unixTime); + +Vec3 geodeticToEcef(const Geodetic& geodetic); + +/** Point on Earth where the sun is in the zenith (low precision, about 0.01 degree). */ +Geodetic subsolarPoint(double unixTime); + +/** Unit vector to the sun in the TEME/ECI frame. */ +Vec3 sunDirectionEci(double unixTime); + +bool isSunlit(const Vec3& eciKm, double unixTime); + +/** Angular radius (degrees of arc on the ground) of the area that sees the satellite above the horizon. */ +double footprintRadiusDeg(double altitudeKm); + +/** Point at the given angular distance and bearing from a start point on the sphere. */ +void destinationPoint(double latDeg, double lonDeg, double distanceDeg, double bearingDeg, double& outLatDeg, double& outLonDeg); + +class Satellite { + +public: + + Satellite(); + ~Satellite(); + Satellite(const Satellite&) = delete; + Satellite& operator=(const Satellite&) = delete; + + bool init(const Tle& tle); + bool isValid() const { return valid; } + + /** TEME position and velocity in km and km/s. Returns false when SGP4 reports an error (for example a decayed orbit). */ + bool positionEci(double unixTime, Vec3& outPosition, Vec3& outVelocity); + bool geodetic(double unixTime, Geodetic& out); + bool lookAngles(const Geodetic& observer, double unixTime, LookAngles& out); + + double epochUnix() const { return epoch; } + double periodMinutes() const; + + /** + * Searches the next pass that is in progress at startTime or begins within searchSeconds. + * Passes lower than minElevationDeg at their highest point are skipped. + */ + PassSearchResult findNextPass(const Geodetic& observer, int64_t startTime, int64_t searchSeconds, double minElevationDeg, Pass& out); + +private: + + bool elevationAt(const Geodetic& observer, double unixTime, double& outElevation); + int64_t refineCrossing(const Geodetic& observer, int64_t below, int64_t above); + + ElsetRec* record = nullptr; + double epoch = 0.0; + double meanMotion = 0.0; + bool valid = false; +}; + +} diff --git a/Apps/SatelliteTracker/main/Source/PassesView.cpp b/Apps/SatelliteTracker/main/Source/PassesView.cpp new file mode 100644 index 0000000..983c885 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/PassesView.cpp @@ -0,0 +1,158 @@ +#include "SatelliteTracker.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr int64_t PASS_LIST_SECONDS = 24 * 3600; +constexpr size_t MAX_ROWS = 40; +constexpr int MAX_PASSES_PER_SATELLITE = 10; + +struct PassRow { + int index; + orbit::Pass pass; + bool visible; +}; + +/** The satellite is in sunlight while the observer has a dark sky (sun at least 6 degrees below the horizon). */ +bool isVisiblePass(TrackedSatellite& entry, const orbit::Geodetic& observer, const orbit::Pass& pass) { + double t = (double)pass.maxTime; + orbit::Vec3 r, v; + if (!entry.propagator.positionEci(t, r, v) || !orbit::isSunlit(r, t)) return false; + orbit::Geodetic sun = orbit::subsolarPoint(t); + const double deg = 3.14159265358979323846 / 180.0; + double sunHeight = std::sin(observer.latDeg * deg) * std::sin(sun.latDeg * deg) + + std::cos(observer.latDeg * deg) * std::cos(sun.latDeg * deg) * std::cos((observer.lonDeg - sun.lonDeg) * deg); + return sunHeight < std::sin(-6.0 * deg); +} + +void addMessage(Context* ctx, const char* text) { + lv_obj_t* label = lv_label_create(ctx->content); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_text(label, text); +} + +} + +void passesViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 2, 0); + + if (!timeIsValid()) { + addMessage(ctx, "The clock is not set yet. Connect to Wi-Fi so the time can sync."); + return; + } + if (!ctx->model.hasObserver()) { + addMessage(ctx, "Set your location in the settings to see passes."); + return; + } + if (ctx->model.count() == 0) { + addMessage(ctx, "No satellites are tracked yet."); + return; + } + + int64_t now = currentTime(); + const orbit::Geodetic& observer = ctx->model.observer(); + std::vector rows; + std::vector alwaysUp; + int withoutOrbit = 0; + for (int i = 0; i < ctx->model.count(); i++) { + TrackedSatellite& entry = ctx->model.at(i); + if (!entry.hasOrbit) { + withoutOrbit++; + continue; + } + int64_t t = now; + for (int n = 0; n < MAX_PASSES_PER_SATELLITE && t < now + PASS_LIST_SECONDS; n++) { + orbit::Pass pass; + auto result = entry.propagator.findNextPass(observer, t, now + PASS_LIST_SECONDS - t, MIN_PASS_ELEVATION_DEG, pass); + if (result == orbit::PassSearchResult::AlwaysUp) { + alwaysUp.push_back(i); + break; + } + if (result != orbit::PassSearchResult::Found) break; + rows.push_back(PassRow { i, pass, isVisiblePass(entry, observer, pass) }); + t = pass.setTime + 60; + } + } + std::sort(rows.begin(), rows.end(), [](const PassRow& a, const PassRow& b) { return a.pass.riseTime < b.pass.riseTime; }); + if (rows.size() > MAX_ROWS) rows.resize(MAX_ROWS); + + bool compact = lv_obj_get_content_width(ctx->content) < 400; + const lv_font_t* font = lvgl_get_text_font(compact ? FONT_SIZE_SMALL : FONT_SIZE_DEFAULT); + time_t nowValue = (time_t)now; + struct tm nowTm {}; + localtime_r(&nowValue, &nowTm); + bool anyVisible = false; + + for (int index : alwaysUp) { + const TrackedSatellite& entry = ctx->model.at(index); + lv_obj_t* row = lv_obj_create(ctx->content); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 6, 0); + createMarkerImage(ctx, row, entry.slot); + lv_obj_t* label = lv_label_create(row); + lv_obj_set_style_text_font(label, font, 0); + char text[64]; + snprintf(text, sizeof(text), "%s always above the horizon", entry.shortName); + lv_label_set_text(label, text); + } + + for (const auto& item : rows) { + const TrackedSatellite& entry = ctx->model.at(item.index); + char rise[8], set[8], day[8] = ""; + formatClock(item.pass.riseTime, rise, sizeof(rise)); + formatClock(item.pass.setTime, set, sizeof(set)); + time_t riseValue = (time_t)item.pass.riseTime; + struct tm riseTm {}; + localtime_r(&riseValue, &riseTm); + if (riseTm.tm_yday != nowTm.tm_yday) { + strftime(day, sizeof(day), "%a ", &riseTm); + } + anyVisible = anyVisible || item.visible; + + lv_obj_t* row = lv_obj_create(ctx->content); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 6, 0); + createMarkerImage(ctx, row, entry.slot); + lv_obj_t* label = lv_label_create(row); + lv_obj_set_style_text_font(label, font, 0); + char text[128]; + if (compact) { + snprintf(text, sizeof(text), "%s%s-%s %2.0f\xC2\xB0 %s%s", day, rise, set, item.pass.maxElevationDeg, entry.shortName, item.visible ? " *" : ""); + } else { + snprintf(text, sizeof(text), "%s%s - %s max %2.0f\xC2\xB0 %s to %s %s%s", day, rise, set, item.pass.maxElevationDeg, + compassPoint(item.pass.riseAzimuthDeg), compassPoint(item.pass.setAzimuthDeg), entry.shortName, + item.visible ? " * visible" : ""); + } + lv_label_set_text(label, text); + } + + if (rows.empty() && alwaysUp.empty()) { + addMessage(ctx, "No passes higher than 5° in the next 24 hours."); + } else if (anyVisible) { + lv_obj_t* note = lv_label_create(ctx->content); + lv_obj_set_style_text_font(note, lvgl_get_text_font(FONT_SIZE_SMALL), 0); + lv_label_set_long_mode(note, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(note, LV_PCT(100)); + lv_label_set_text(note, "* sunlit satellite in a dark sky, visible to the eye when bright enough"); + } + if (withoutOrbit > 0) { + char text[96]; + snprintf(text, sizeof(text), withoutOrbit == 1 ? "%d satellite has no orbit data yet." : "%d satellites have no orbit data yet.", withoutOrbit); + addMessage(ctx, text); + } +} diff --git a/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp b/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp new file mode 100644 index 0000000..8f40101 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp @@ -0,0 +1,569 @@ +#include "SatelliteTracker.h" + +#include "LargeMemory.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +struct DefaultSatellite { + uint32_t catalogNumber; + const char* shortName; +}; + +/** Tracked on the first start: the ISS, the Chinese space station, the Hubble Space Telescope and XMM-Newton. */ +constexpr DefaultSatellite DEFAULT_SATELLITES[] = { + { 25544, "ISS" }, + { 48274, "CSS" }, + { 20580, "HST" }, + { 25989, "XMM-NEWTON" }, +}; + +/** Missing orbit data is tried this many times at a short interval, then at the normal retry interval. */ +constexpr int MISSING_ORBIT_QUICK_ATTEMPTS = 3; +constexpr int64_t MISSING_ORBIT_RETRY_SECONDS = 30; +constexpr int64_t REFRESH_RETRY_SECONDS = 15 * 60; + +struct DisplayInfo { + bool found; + bool grayscale; + bool slow; +}; + +bool onDisplayFound(Device* device, void* context) { + auto* info = static_cast(context); + auto format = display_get_color_format(device); + info->found = true; + info->grayscale = format == DISPLAY_COLOR_FORMAT_GRAYSCALE8 || format == DISPLAY_COLOR_FORMAT_MONOCHROME; + info->slow = display_has_capability(device, DISPLAY_CAPABILITY_SLOW_REFRESH); + return false; +} + +void setStatus(Context* ctx, const char* text) { + snprintf(ctx->statusText, sizeof(ctx->statusText), "%s", text); + ctx->forceRedraw = true; +} + +bool queueTracked(Context* ctx, uint32_t catalogNumber) { + DownloadJob job; + if (!Downloader::buildCatalogJob(catalogNumber, job) || ctx->downloader.hasQueued(DownloadKind::Tracked, job.tag)) return false; + return ctx->downloader.enqueue(job); +} + +/** Queues the tracked satellites that have orbit data, those without it, or both. */ +bool queueRefresh(Context* ctx, bool withOrbit, bool withoutOrbit) { + bool queued = false; + for (int i = 0; i < ctx->model.count(); i++) { + const TrackedSatellite& entry = ctx->model.at(i); + if ((entry.hasOrbit ? withOrbit : withoutOrbit) && queueTracked(ctx, entry.catalogNumber)) queued = true; + } + if (queued) setStatus(ctx, "Updating satellite data..."); + return queued; +} + +/** Satellites without orbit data (the first start, or a list restored without the cache) are downloaded as soon as possible. */ +void requestMissingOrbits(Context* ctx, int64_t now) { + bool queued = false; + for (int i = 0; i < ctx->model.count(); i++) { + TrackedSatellite& entry = ctx->model.at(i); + if (entry.hasOrbit || now < entry.retryAt) continue; + if (queueTracked(ctx, entry.catalogNumber)) queued = true; + // Until the result is handled. The download leaves the queue before its result reaches the LVGL task. + entry.retryAt = now + REFRESH_RETRY_SECONDS; + } + if (queued) setStatus(ctx, "Getting satellite data..."); +} + +void scheduleDownloads(Context* ctx) { + if (!timeIsValid()) return; + int64_t now = currentTime(); + requestMissingOrbits(ctx, now); + int64_t age = orbitDataAge(ctx); + int64_t needed = ctx->startupRefreshDone ? TRACKED_REFRESH_SECONDS : TRACKED_MIN_REFRESH_SECONDS; + if (age >= 0 && age < needed) { + ctx->startupRefreshDone = true; + return; + } + if (now - ctx->lastRefreshAttempt < REFRESH_RETRY_SECONDS || ctx->downloader.isBusy()) return; + ctx->lastRefreshAttempt = now; + ctx->startupRefreshDone = true; + // Satellites without orbit data have their own schedule in requestMissingOrbits(). + queueRefresh(ctx, true, false); +} + +/** Orbit data and the download time are written once per batch of downloads, flash writes are slow on some devices. */ +void saveDownloads(Context* ctx) { + if (!ctx->downloadsUnsaved) return; + ctx->downloadsUnsaved = false; + orbitCacheSave(ctx->model); + settingsSave(ctx->settings); +} + +void freeMarkerIcons(Context* ctx) { + for (auto& buffer : ctx->iconBuffers) { + largeFree(buffer); + buffer = nullptr; + } +} + +void onCloseClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void onBackClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + switch (ctx->view) { + case View::Groups: + case View::Search: + case View::Rename: + showView(ctx, View::Satellites); + break; + case View::GroupList: + showView(ctx, View::Groups); + break; + default: + showView(ctx, View::Map); + break; + } +} + +void onPassesClicked(lv_event_t* event) { + showView(static_cast(lv_event_get_user_data(event)), View::Passes); +} + +void onSatellitesClicked(lv_event_t* event) { + showView(static_cast(lv_event_get_user_data(event)), View::Satellites); +} + +void onSettingsClicked(lv_event_t* event) { + showView(static_cast(lv_event_get_user_data(event)), View::Settings); +} + +const char* viewTitle(const Context* ctx) { + switch (ctx->view) { + case View::Map: return "Satellite Tracker"; + case View::Passes: return "Next passes"; + case View::Satellites: return "Satellites"; + case View::Groups: return "Add from group"; + case View::GroupList: return ctx->selectedGroupName; + case View::Search: return "Search"; + case View::Rename: return "Rename"; + case View::Settings: return "Settings"; + } + return ""; +} + +void onUpdateTimer(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; + scheduleDownloads(ctx); + if (ctx->view == View::Map) { + mapViewUpdate(ctx, false); + } else if (ctx->view == View::Settings) { + settingsViewUpdate(ctx); + } +} + +void onTextAreaFocused(lv_event_t* event) { + static_cast(lv_event_get_user_data(event))->keyboardBound = true; +} + +/** + * Tactility hides the on-screen keyboard when its text area is deleted, but the keyboard keeps a pointer to it. + * Focusing the next text area then touches the deleted one, so the keyboard is detached before our widgets go. + */ +void releaseKeyboard(Context* ctx) { + if (!ctx->keyboardBound) return; + ctx->keyboardBound = false; + LvglSoftwareKeyboard* keyboard = lvgl_software_keyboard_get_last(); + if (keyboard != nullptr && keyboard->object != nullptr) { + lv_keyboard_set_textarea(keyboard->object, nullptr); + lvgl_software_keyboard_hide(keyboard); + } +} + +void buildView(Context* ctx) { + if (ctx->root == nullptr) return; + mapViewDestroy(ctx); + releaseKeyboard(ctx); + lv_obj_clean(ctx->root); + ctx->mapWidgets = MapWidgets {}; + ctx->listWidget = nullptr; + ctx->messageLabel = nullptr; + ctx->textArea = nullptr; + + ctx->toolbar = nullptr; + if (ctx->view != View::Map) { + ctx->toolbar = lvgl_toolbar_create(ctx->root, viewTitle(ctx)); + lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_LEFT, onBackClicked, ctx); + } else if (!ctx->settings.hideToolbar) { + ctx->toolbar = lvgl_toolbar_create(ctx->root, viewTitle(ctx)); + lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onCloseClicked, ctx); + lv_obj_t* passesButton = lvgl_toolbar_add_text_button_action(ctx->toolbar, LVGL_ICON_SHARED_CALENDAR_MONTH, onPassesClicked, ctx); + lv_obj_set_style_text_font(lv_obj_get_child(passesButton, 0), lvgl_get_shared_icon_default_font(), 0); + lvgl_toolbar_add_text_button_action(ctx->toolbar, LV_SYMBOL_LIST, onSatellitesClicked, ctx); + lvgl_toolbar_add_text_button_action(ctx->toolbar, LV_SYMBOL_SETTINGS, onSettingsClicked, ctx); + } + + ctx->content = lv_obj_create(ctx->root); + lv_obj_set_width(ctx->content, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->content, 1); + lv_obj_set_style_border_width(ctx->content, 0, 0); + lv_obj_set_style_radius(ctx->content, 0, 0); + lv_obj_update_layout(ctx->root); + + switch (ctx->view) { + case View::Map: mapViewCreate(ctx); break; + case View::Passes: passesViewCreate(ctx); break; + case View::Satellites: satellitesViewCreate(ctx); break; + case View::Groups: groupsViewCreate(ctx); break; + case View::GroupList: groupListViewCreate(ctx); break; + case View::Search: searchViewCreate(ctx); break; + case View::Rename: renameViewCreate(ctx); break; + case View::Settings: settingsViewCreate(ctx); break; + } +} + +void onViewTimer(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + ctx->viewTimer = nullptr; + ctx->view = ctx->pendingView; + if (ctx->view == View::Map) ctx->forceRedraw = true; + buildView(ctx); +} + +void handleTrackedDownload(Context* ctx, const DownloadResult& result) { + uint32_t catalog = (uint32_t)strtoul(result.job.tag, nullptr, 10); + int index = ctx->model.indexOfCatalog(catalog); + int64_t now = currentTime(); + if (!result.success) { + // Satellites that have orbit data keep it and are retried with the next refresh. + if (index >= 0 && !ctx->model.at(index).hasOrbit) { + TrackedSatellite& entry = ctx->model.at(index); + entry.downloadFailures++; + entry.retryAt = now + (entry.downloadFailures < MISSING_ORBIT_QUICK_ATTEMPTS ? MISSING_ORBIT_RETRY_SECONDS : REFRESH_RETRY_SECONDS); + } + return; + } + + TleList tles; + tleFileRead(result.job.target, tles, 4); + remove(result.job.target); + // The satellite may have been removed while its download was queued. + if (index < 0) return; + + TrackedSatellite& entry = ctx->model.at(index); + const orbit::Tle* received = nullptr; + for (const auto& tle : tles) { + if (orbit::tleCatalogNumber(tle) == catalog) received = &tle; + } + if (received != nullptr) { + ctx->settings.lastTrackedRefresh = now; + ctx->downloadsUnsaved = true; + if (ctx->model.setTle(index, *received)) ctx->forceRedraw = true; + } + if (!entry.hasOrbit) { + // CelesTrak has no usable data for this number (re-entered, or never existed). Asking again soon would not help. + entry.retryAt = now + TRACKED_REFRESH_SECONDS; + char text[64]; + snprintf(text, sizeof(text), "No orbit data for %s (%u)", entry.shortName, (unsigned)catalog); + setStatus(ctx, text); + } else if (!ctx->downloader.isBusy()) { + setStatus(ctx, ""); + } +} + +/** Fills the catalog from a group or search download, when it still belongs to the screen that asked for it. */ +void handleCatalogDownload(Context* ctx, const DownloadResult& result) { + bool current; + size_t maxEntries; + if (result.job.kind == DownloadKind::Group) { + current = ctx->view == View::GroupList && strcmp(ctx->selectedGroup, result.job.tag) == 0; + maxEntries = ctx->groupLimit; + } else { + DownloadJob expected; + current = ctx->view == View::Search && Downloader::buildSearchJob(ctx->searchText, expected) && strcmp(expected.tag, result.job.tag) == 0; + maxEntries = ctx->searchLimit; + } + if (!current) return; + ctx->catalogLoading = false; + // After a failed download a group falls back to its saved copy, which a failed download leaves untouched. + bool fallback = !result.success && result.job.kind == DownloadKind::Group; + if (result.success || fallback) { + tleFileRead(result.job.target, ctx->catalog, maxEntries); + sortByName(ctx->catalog); + } + catalogViewRefresh(ctx); + if (fallback && !ctx->catalog.empty()) { + setLabelText(ctx->messageLabel, "Download failed, this is the saved copy of the group."); + } +} + +} + +void applyObserver(Context* ctx) { + if (ctx->settings.useDeviceLocation && ctx->deviceFixValid) { + ctx->model.setObserver(true, ctx->deviceFix); + return; + } + orbit::Geodetic location { ctx->settings.latitudeDeg, ctx->settings.longitudeDeg, ctx->settings.altitudeM / 1000.0 }; + ctx->model.setObserver(ctx->settings.observerSet, location); +} + +void satelliteTrackerSetDeviceLocation(Context* ctx, const Location* fix) { + if (fix == nullptr) { + if (!ctx->deviceFixValid) return; + ctx->deviceFixValid = false; + } else { + ctx->deviceAccuracyM = fix->horizontal_accuracy; + snprintf(ctx->deviceProvider, sizeof(ctx->deviceProvider), "%s", fix->provider); + // Movements below about 1 km are ignored, so GPS noise does not recalculate the passes every second. + bool moved = !ctx->deviceFixValid || std::fabs(fix->latitude - ctx->deviceFix.latDeg) > 0.01 || + std::fabs(fix->longitude - ctx->deviceFix.lonDeg) > 0.01; + if (!moved) return; + double altitudeM = std::isnan(fix->altitude) ? 0.0 : fix->altitude; + ctx->deviceFix = orbit::Geodetic { fix->latitude, fix->longitude, altitudeM / 1000.0 }; + ctx->deviceFixValid = true; + } + applyObserver(ctx); + ctx->forceRedraw = true; +} + +int effectiveFocus(const Context* ctx) { + int count = ctx->model.count(); + if (ctx->focusedIndex >= 0 && ctx->focusedIndex < count) return ctx->focusedIndex; + if (ctx->settings.autoSelectPass && ctx->autoIndex >= 0 && ctx->autoIndex < count) return ctx->autoIndex; + return -1; +} + +int addTracked(Context* ctx, const orbit::Tle& tle) { + int index = ctx->model.add(tle, nullptr); + if (index < 0) return -1; + trackedListSave(ctx->model); + // The orbit data comes from a group or search download and goes into the cache with the next save. + ctx->downloadsUnsaved = true; + ctx->autoIndex = -1; + ctx->forceRedraw = true; + return index; +} + +void removeTracked(Context* ctx, int index) { + ctx->model.remove(index); + trackedListSave(ctx->model); + if (ctx->focusedIndex == index) ctx->focusedIndex = -1; + else if (ctx->focusedIndex > index) ctx->focusedIndex--; + ctx->autoIndex = -1; + ctx->forceRedraw = true; +} + +int64_t orbitDataAge(const Context* ctx) { + int64_t last = ctx->settings.lastTrackedRefresh; + if (last <= 0 || !timeIsValid()) return -1; + int64_t age = currentTime() - last; + return age < 0 ? -1 : age; +} + +int64_t currentTime() { + return (int64_t)time(nullptr); +} + +bool timeIsValid() { + return currentTime() >= MIN_VALID_TIME; +} + +int effectiveUpdateSeconds(const Context* ctx) { + if (ctx->settings.updateSeconds > 0) return ctx->settings.updateSeconds; + return ctx->slowDisplay ? 60 : 5; +} + +void setLabelText(lv_obj_t* label, const char* text) { + if (label == nullptr) return; + const char* current = lv_label_get_text(label); + if (current != nullptr && strcmp(current, text) == 0) return; + lv_label_set_text(label, text); +} + +lv_obj_t* createTextArea(Context* ctx, lv_obj_t* parent) { + lv_obj_t* textArea = lv_textarea_create(parent); + lv_obj_add_event_cb(textArea, onTextAreaFocused, LV_EVENT_FOCUSED, ctx); + return textArea; +} + +void formatClock(int64_t unixTime, char* out, size_t outSize) { + time_t value = (time_t)unixTime; + struct tm local {}; + localtime_r(&value, &local); + snprintf(out, outSize, "%02d:%02d", local.tm_hour, local.tm_min); +} + +void formatDay(int64_t unixTime, char* out, size_t outSize) { + time_t now = (time_t)currentTime(); + time_t value = (time_t)unixTime; + struct tm nowTm {}, valueTm {}, tomorrowTm {}; + localtime_r(&now, &nowTm); + localtime_r(&value, &valueTm); + // Noon today plus one day is tomorrow even on days with a daylight saving change. + time_t tomorrow = now + (12 - nowTm.tm_hour) * 3600 - nowTm.tm_min * 60 - nowTm.tm_sec + 24 * 3600; + localtime_r(&tomorrow, &tomorrowTm); + bool sameDay = valueTm.tm_yday == nowTm.tm_yday && valueTm.tm_year == nowTm.tm_year; + bool nextDay = valueTm.tm_yday == tomorrowTm.tm_yday && valueTm.tm_year == tomorrowTm.tm_year; + if (sameDay) { + if (outSize > 0) out[0] = '\0'; + } else if (nextDay) { + snprintf(out, outSize, "tomorrow "); + } else { + strftime(out, outSize, "%a ", &valueTm); + } +} + +const char* compassPoint(double azimuthDeg) { + static const char* const POINTS[] = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; + int index = (int)((azimuthDeg + 22.5) / 45.0) & 7; + return POINTS[index]; +} + +const uint8_t* markerIcon(Context* ctx, int slot) { + slot %= MAX_TRACKED; + if (ctx->iconBuffers[slot] != nullptr) return ctx->iconBuffers[slot]; + int bytesPerPixel = ctx->grayscale ? 1 : 2; + int size = ctx->iconSize; + auto* buffer = static_cast(largeAlloc((size_t)size * size * bytesPerPixel)); + if (buffer == nullptr) return nullptr; + map::PixelFormat format = ctx->grayscale ? map::PixelFormat::Gray8 : map::PixelFormat::Rgb565; + const map::Theme& theme = ctx->grayscale ? map::grayTheme() : map::colorTheme(); + map::Surface surface(buffer, size, size, size * bytesPerPixel, format); + surface.fill(ctx->grayscale ? theme.ocean : map::Rgb { 0x20, 0x20, 0x20 }); + surface.marker(size / 2, size / 2, (size - 5) / 2, static_cast(slot % map::MARKER_SHAPE_COUNT), + slot >= map::MARKER_SHAPE_COUNT, theme.satellite[slot % map::MARKER_SHAPE_COUNT], theme.markerOutline); + ctx->iconBuffers[slot] = buffer; + return buffer; +} + +lv_obj_t* createMarkerImage(Context* ctx, lv_obj_t* parent, int slot) { + const uint8_t* buffer = markerIcon(ctx, slot); + lv_obj_t* canvas = lv_canvas_create(parent); + if (buffer != nullptr) { + lv_canvas_set_buffer(canvas, const_cast(buffer), ctx->iconSize, ctx->iconSize, + ctx->grayscale ? LV_COLOR_FORMAT_L8 : LV_COLOR_FORMAT_RGB565); + } + lv_obj_set_size(canvas, ctx->iconSize, ctx->iconSize); + lv_obj_set_clickable(canvas, false); + return canvas; +} + +void satelliteTrackerInit(Context* ctx) { + DisplayInfo display { false, false, false }; + device_for_each_of_type(&DISPLAY_TYPE, &display, onDisplayFound); + ctx->grayscale = display.found && display.grayscale; + ctx->slowDisplay = display.found && display.slow; + // A list entry takes about 170 bytes. Without external RAM big groups would use too much of the internal heap. + if (memory_external_total() == 0) { + ctx->groupLimit = 150; + ctx->searchLimit = 60; + } + + settingsLoad(ctx->settings); + if (!trackedLoad(ctx->model)) { + // First start: the list is saved right away, the orbit data follows when the network is available. + for (const auto& satellite : DEFAULT_SATELLITES) { + ctx->model.add(satellite.catalogNumber, satellite.shortName, -1, true); + } + trackedListSave(ctx->model); + } + applyObserver(ctx); + ctx->focusedIndex = (ctx->settings.autoSelectPass || ctx->model.count() == 0) ? -1 : 0; +} + +void satelliteTrackerTeardown(Context* ctx) { + saveDownloads(ctx); + freeMarkerIcons(ctx); +} + +void satelliteTrackerCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->root = parent; + lv_obj_set_scrollable(parent, false); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(parent, 0, 0); + lv_obj_set_style_pad_row(parent, 0, 0); + + int iconSize = (int)lvgl_get_text_font_height(FONT_SIZE_SMALL) + 2; + if (iconSize < 11) iconSize = 11; + if (iconSize != ctx->iconSize) { + freeMarkerIcons(ctx); + ctx->iconSize = iconSize; + } + + ctx->forceRedraw = true; + buildView(ctx); + ctx->updateTimer = lv_timer_create(onUpdateTimer, 1000, ctx); +} + +void satelliteTrackerDestroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + releaseKeyboard(ctx); + if (ctx->updateTimer != nullptr) { + lv_timer_delete(ctx->updateTimer); + ctx->updateTimer = nullptr; + } + if (ctx->viewTimer != nullptr) { + lv_timer_delete(ctx->viewTimer); + ctx->viewTimer = nullptr; + ctx->view = ctx->pendingView; + } + mapViewDestroy(ctx); + ctx->mapWidgets = MapWidgets {}; + ctx->toolbar = nullptr; + ctx->content = nullptr; + ctx->listWidget = nullptr; + ctx->messageLabel = nullptr; + ctx->textArea = nullptr; + ctx->root = nullptr; +} + +void showView(Context* ctx, View view) { + ctx->pendingView = view; + if (ctx->viewTimer == nullptr) { + ctx->viewTimer = lv_timer_create(onViewTimer, 0, ctx); + lv_timer_set_repeat_count(ctx->viewTimer, 1); + } +} + +bool requestTrackedRefresh(Context* ctx, int64_t minAgeSeconds) { + if (!timeIsValid()) return false; + int64_t age = orbitDataAge(ctx); + bool recent = age >= 0 && age < minAgeSeconds; + return queueRefresh(ctx, !recent, true); +} + +void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result) { + if (!result.success) { + char text[96]; + if (result.statusCode != 0) { + snprintf(text, sizeof(text), "Download failed (HTTP %d)", (int)result.statusCode); + } else { + snprintf(text, sizeof(text), "Download failed: %s. Check Wi-Fi.", result.message); + } + setStatus(ctx, text); + } + if (result.job.kind == DownloadKind::Tracked) { + handleTrackedDownload(ctx, result); + } else { + handleCatalogDownload(ctx, result); + } + if (!ctx->downloader.isBusy()) saveDownloads(ctx); +} diff --git a/Apps/SatelliteTracker/main/Source/SatelliteTracker.h b/Apps/SatelliteTracker/main/Source/SatelliteTracker.h new file mode 100644 index 0000000..9cdf115 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/SatelliteTracker.h @@ -0,0 +1,179 @@ +#pragma once + +#include "Downloads.h" +#include "LocationApi.h" +#include "MapRenderer.h" +#include "Storage.h" +#include "TrackerModel.h" + +#include +#include +#include + +#include +#include + +enum class View { + Map, + Passes, + Satellites, + Groups, + GroupList, + Search, + Rename, + Settings, +}; + +struct Context; + +struct RowAction { + Context* ctx; + int index; + lv_obj_t* label; +}; + +struct MapWidgets { + /** Focus container around the canvas, so keypads and encoders can reach the map. */ + lv_obj_t* mapContainer = nullptr; + lv_obj_t* canvas = nullptr; + uint8_t* canvasBuffer = nullptr; + int mapX = 0; + int mapY = 0; + lv_obj_t* legendRows[MAX_TRACKED] {}; + lv_obj_t* legendNames[MAX_TRACKED] {}; + lv_obj_t* legendValues[MAX_TRACKED] {}; + lv_obj_t* info = nullptr; + /** Pass text on the last line next to the status, only in the wide layout with the legend below the map. */ + lv_obj_t* passLabel = nullptr; + lv_obj_t* status = nullptr; + /** Legend in a column to the right of the map, else in a grid below it. */ + bool wide = false; + /** Enough width for the long texts. */ + bool detailed = false; +}; + +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; + Downloader downloader; + + TrackerModel model; + Settings settings; + /** New orbit data or a new download time that is saved once the queued downloads are done. */ + bool downloadsUnsaved = false; + int64_t lastRefreshAttempt = 0; + bool startupRefreshDone = false; + /** Satellite selected by the user, -1 when none. */ + int focusedIndex = -1; + /** Satellite with the next pass, used when nothing is selected and the setting is on. */ + int autoIndex = -1; + int shownFocus = -2; + View view = View::Map; + bool grayscale = false; + bool slowDisplay = false; + + char statusText[96] {}; + int64_t lastMapDraw = 0; + bool forceRedraw = true; + + char selectedGroup[24] {}; + char selectedGroupName[40] {}; + /** As long as a download tag, which identifies the search when its result arrives. */ + char searchText[sizeof(DownloadJob::tag)] {}; + TleList catalog; + /** Entries read from a group or search result, lower on devices without external RAM. */ + size_t groupLimit = 400; + size_t searchLimit = 100; + std::vector rowActions; + bool catalogLoading = false; + int renameIndex = -1; + + map::MapRenderer renderer; + map::Scene scene; + uint8_t* iconBuffers[MAX_TRACKED] {}; + int iconSize = 0; + + /** The app's window, null while the window has no widgets. */ + lv_obj_t* root = nullptr; + lv_obj_t* toolbar = nullptr; + lv_obj_t* content = nullptr; + lv_timer_t* updateTimer = nullptr; + lv_timer_t* viewTimer = nullptr; + View pendingView = View::Map; + /** Give the map keyboard focus again after the toolbar was hidden or shown with a key. */ + bool refocusMap = false; + MapWidgets mapWidgets; + lv_obj_t* listWidget = nullptr; + lv_obj_t* messageLabel = nullptr; + lv_obj_t* textArea = nullptr; + bool keyboardBound = false; + + /** Latest fix of Tactility's location service, set on the main task with the LVGL lock held. */ + bool deviceFixValid = false; + orbit::Geodetic deviceFix {}; + float deviceAccuracyM = 0.0f; + char deviceProvider[LOCATION_PROVIDER_ID_MAX_LENGTH + 1] {}; +}; + +constexpr int64_t MIN_VALID_TIME = 1735689600; +constexpr int64_t TRACKED_REFRESH_SECONDS = 24 * 3600; +constexpr int64_t TRACKED_MIN_REFRESH_SECONDS = 2 * 3600; +constexpr int64_t GROUP_CACHE_SECONDS = 24 * 3600; +constexpr int64_t PASS_SEARCH_SECONDS = 36 * 3600; + +void satelliteTrackerInit(Context* ctx); +void satelliteTrackerCreateWidgets(lv_obj_t* parent, void* userData); +void satelliteTrackerDestroyWidgets(void* userData); +/** Saves what is still unsaved and frees the marker icons. */ +void satelliteTrackerTeardown(Context* ctx); + +/** Called on the main task with the LVGL lock held. */ +void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result); + +/** + * Queues downloads of the orbit data of all tracked satellites when it is older than minAgeSeconds, else only of those + * that have none yet. Returns true when anything was queued. + */ +bool requestTrackedRefresh(Context* ctx, int64_t minAgeSeconds); +/** Adds a satellite from a group or search result, with its orbit data. Returns the index or -1. */ +int addTracked(Context* ctx, const orbit::Tle& tle); + +/** Switches the screen after the current event has finished. */ +void showView(Context* ctx, View view); +/** Uses the device location when enabled and available, else the location from the settings. */ +void applyObserver(Context* ctx); +/** Called on the main task with the LVGL lock held. nullptr means the device location is no longer available. */ +void satelliteTrackerSetDeviceLocation(Context* ctx, const Location* fix); +/** The satellite shown with track and details: the selected one, else the automatic choice, else -1. */ +int effectiveFocus(const Context* ctx); +void removeTracked(Context* ctx, int index); +int64_t currentTime(); +/** Seconds since the tracked TLEs were downloaded, or -1 when unknown (never, or the clock went back since). */ +int64_t orbitDataAge(const Context* ctx); +bool timeIsValid(); +int effectiveUpdateSeconds(const Context* ctx); +const uint8_t* markerIcon(Context* ctx, int slot); +lv_obj_t* createMarkerImage(Context* ctx, lv_obj_t* parent, int slot); +void setLabelText(lv_obj_t* label, const char* text); +/** Creates a text area that detaches the on-screen keyboard before its screen is deleted. */ +lv_obj_t* createTextArea(Context* ctx, lv_obj_t* parent); +void formatClock(int64_t unixTime, char* out, size_t outSize); +/** Day of a time in the next days: "" for today, "tomorrow " for the next day, else the weekday ("Fri "). */ +void formatDay(int64_t unixTime, char* out, size_t outSize); +const char* compassPoint(double azimuthDeg); + +void mapViewCreate(Context* ctx); +void mapViewDestroy(Context* ctx); +void mapViewUpdate(Context* ctx, bool force); + +void passesViewCreate(Context* ctx); +void satellitesViewCreate(Context* ctx); +void groupsViewCreate(Context* ctx); +void groupListViewCreate(Context* ctx); +void searchViewCreate(Context* ctx); +void renameViewCreate(Context* ctx); +void settingsViewCreate(Context* ctx); +void settingsViewUpdate(Context* ctx); + +/** Rebuilds the visible catalog list after a group or search download. */ +void catalogViewRefresh(Context* ctx); diff --git a/Apps/SatelliteTracker/main/Source/SatellitesView.cpp b/Apps/SatelliteTracker/main/Source/SatellitesView.cpp new file mode 100644 index 0000000..59ae744 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/SatellitesView.cpp @@ -0,0 +1,395 @@ +#include "SatelliteTracker.h" + +#include + +#include +#include +#include + +namespace { + +constexpr size_t MAX_CATALOG_ROWS = 250; + +struct Group { + const char* id; + const char* name; +}; + +constexpr Group GROUPS[] = { + { "stations", "Space stations" }, + { "visual", "100 brightest" }, + { "last-30-days", "Launched in the last 30 days" }, + { "weather", "Weather" }, + { "noaa", "NOAA" }, + { "goes", "GOES" }, + { "resource", "Earth resources" }, + { "sarsat", "Search and rescue" }, + { "amateur", "Amateur radio" }, + { "gps-ops", "GPS" }, + { "galileo", "Galileo" }, + { "glo-ops", "GLONASS" }, + { "beidou", "BeiDou" }, + { "science", "Space and earth science" }, + { "geodetic", "Geodetic" }, + { "engineering", "Engineering" }, + { "education", "Education" }, + { "cubesat", "CubeSats" }, +}; + +/** "Tracking 3 of 6", or an explanation when a list saved on a bigger screen holds more than this screen fits. */ +void describeTracking(const Context* ctx, char* out, size_t outSize) { + int count = ctx->model.count(); + int limit = ctx->model.limit(); + if (count > limit) { + snprintf(out, outSize, "Tracking %d satellites, this screen fits %d. Remove some to add others.", count, limit); + } else { + snprintf(out, outSize, "Tracking %d of %d", count, limit); + } +} + +void addMessage(Context* ctx, lv_obj_t* parent, const char* text) { + lv_obj_t* label = lv_label_create(parent); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_text(label, text); + ctx->messageLabel = label; +} + +lv_obj_t* addButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* userData) { + lv_obj_t* button = lv_button_create(parent); + lv_obj_t* label = lv_label_create(button); + lv_label_set_text(label, text); + lv_obj_center(label); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, userData); + return button; +} + +void onRenameClicked(lv_event_t* event) { + auto* action = static_cast(lv_event_get_user_data(event)); + action->ctx->renameIndex = action->index; + showView(action->ctx, View::Rename); +} + +void onRemoveClicked(lv_event_t* event) { + auto* action = static_cast(lv_event_get_user_data(event)); + Context* ctx = action->ctx; + removeTracked(ctx, action->index); + showView(ctx, View::Satellites); +} + +void onAddFromGroupClicked(lv_event_t* event) { + showView(static_cast(lv_event_get_user_data(event)), View::Groups); +} + +void onSearchClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->catalog.clear(); + ctx->catalogLoading = false; + showView(ctx, View::Search); +} + +void onGroupClicked(lv_event_t* event) { + auto* action = static_cast(lv_event_get_user_data(event)); + Context* ctx = action->ctx; + const Group& group = GROUPS[action->index]; + snprintf(ctx->selectedGroup, sizeof(ctx->selectedGroup), "%s", group.id); + snprintf(ctx->selectedGroupName, sizeof(ctx->selectedGroupName), "%s", group.name); + ctx->catalog.clear(); + showView(ctx, View::GroupList); +} + +void catalogRowText(Context* ctx, const orbit::Tle& tle, char* out, size_t outSize) { + bool tracked = ctx->model.indexOfCatalog(orbit::tleCatalogNumber(tle)) >= 0; + snprintf(out, outSize, "%s %s", tracked ? LV_SYMBOL_OK : LV_SYMBOL_PLUS, tle.name); +} + +void onCatalogRowClicked(lv_event_t* event) { + auto* action = static_cast(lv_event_get_user_data(event)); + Context* ctx = action->ctx; + if (action->index < 0 || action->index >= (int)ctx->catalog.size()) return; + const orbit::Tle& tle = ctx->catalog[action->index]; + int tracked = ctx->model.indexOfCatalog(orbit::tleCatalogNumber(tle)); + if (tracked >= 0) { + removeTracked(ctx, tracked); + } else if (ctx->model.count() >= ctx->model.limit()) { + char full[64]; + snprintf(full, sizeof(full), "You can track up to %d satellites. Remove one first.", ctx->model.limit()); + setLabelText(ctx->messageLabel, full); + return; + } else if (addTracked(ctx, tle) < 0) { + setLabelText(ctx->messageLabel, "This satellite has no usable orbit data."); + return; + } + char text[64]; + catalogRowText(ctx, tle, text, sizeof(text)); + lv_label_set_text(action->label, text); + char message[96]; + describeTracking(ctx, message, sizeof(message)); + setLabelText(ctx->messageLabel, message); +} + +void startSearch(Context* ctx) { + if (ctx->textArea == nullptr) return; + const char* text = lv_textarea_get_text(ctx->textArea); + snprintf(ctx->searchText, sizeof(ctx->searchText), "%s", text); + DownloadJob job; + if (!Downloader::buildSearchJob(ctx->searchText, job)) { + setLabelText(ctx->messageLabel, "Enter a name or a NORAD catalog number."); + return; + } + if (!timeIsValid()) { + setLabelText(ctx->messageLabel, "The clock is not set yet. Connect to Wi-Fi so the time can sync."); + return; + } + if (!ctx->downloader.enqueue(job)) { + setLabelText(ctx->messageLabel, "Downloads are not available. Restart the app."); + return; + } + ctx->catalog.clear(); + ctx->catalogLoading = true; + catalogViewRefresh(ctx); +} + +void onSearchStart(lv_event_t* event) { + startSearch(static_cast(lv_event_get_user_data(event))); +} + +void onRenameSave(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->renameIndex >= 0 && ctx->renameIndex < ctx->model.count() && ctx->textArea != nullptr) { + TrackedSatellite& entry = ctx->model.at(ctx->renameIndex); + const char* text = lv_textarea_get_text(ctx->textArea); + while (*text == ' ') text++; + if (*text != '\0') { + snprintf(entry.shortName, sizeof(entry.shortName), "%s", text); + } else if (entry.hasOrbit) { + TrackerModel::makeShortName(entry.tle.name, entry.shortName, sizeof(entry.shortName)); + } + trackedListSave(ctx->model); + ctx->forceRedraw = true; + } + showView(ctx, View::Satellites); +} + +/** The automatic name comes from the orbit data, so the button is only offered once that was downloaded. */ +void onRenameAuto(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->renameIndex >= 0 && ctx->renameIndex < ctx->model.count() && ctx->textArea != nullptr) { + const TrackedSatellite& entry = ctx->model.at(ctx->renameIndex); + char name[SHORT_NAME_LENGTH]; + TrackerModel::makeShortName(entry.tle.name, name, sizeof(name)); + lv_textarea_set_text(ctx->textArea, name); + } +} + +lv_obj_t* createColumn(lv_obj_t* parent) { + lv_obj_t* column = lv_obj_create(parent); + lv_obj_remove_style_all(column); + lv_obj_set_size(column, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(column, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(column, 4, 0); + return column; +} + +} + +void satellitesViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 6, 0); + ctx->rowActions.clear(); + ctx->rowActions.reserve(MAX_TRACKED); + + lv_obj_t* header = lv_label_create(ctx->content); + char tracking[96]; + describeTracking(ctx, tracking, sizeof(tracking)); + lv_label_set_long_mode(header, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(header, LV_PCT(100)); + lv_label_set_text(header, tracking); + + for (int i = 0; i < ctx->model.count(); i++) { + const TrackedSatellite& entry = ctx->model.at(i); + lv_obj_t* row = lv_obj_create(ctx->content); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 6, 0); + + createMarkerImage(ctx, row, entry.slot); + lv_obj_t* label = lv_label_create(row); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_DOTS); + lv_obj_set_flex_grow(label, 1); + char text[96]; + if (entry.hasOrbit) { + snprintf(text, sizeof(text), "%s (%s)", entry.shortName, entry.tle.name); + } else { + snprintf(text, sizeof(text), "%s (%u, waiting for orbit data)", entry.shortName, (unsigned)entry.catalogNumber); + } + lv_label_set_text(label, text); + + ctx->rowActions.push_back(RowAction { ctx, i, label }); + addButton(row, LV_SYMBOL_EDIT, onRenameClicked, &ctx->rowActions.back()); + addButton(row, LV_SYMBOL_TRASH, onRemoveClicked, &ctx->rowActions.back()); + } + + lv_obj_t* buttons = lv_obj_create(ctx->content); + lv_obj_remove_style_all(buttons); + lv_obj_set_size(buttons, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(buttons, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(buttons, 8, 0); + lv_obj_set_style_pad_row(buttons, 4, 0); + addButton(buttons, LV_SYMBOL_LIST " Add from group", onAddFromGroupClicked, ctx); + addButton(buttons, LV_SYMBOL_DIRECTORY " Search", onSearchClicked, ctx); + + if (ctx->statusText[0] != '\0') { + addMessage(ctx, ctx->content, ctx->statusText); + } +} + +void groupsViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 4, 0); + ctx->rowActions.clear(); + ctx->rowActions.reserve(sizeof(GROUPS) / sizeof(GROUPS[0])); + for (size_t i = 0; i < sizeof(GROUPS) / sizeof(GROUPS[0]); i++) { + lv_obj_t* button = lv_button_create(ctx->content); + lv_obj_set_width(button, LV_PCT(100)); + lv_obj_t* label = lv_label_create(button); + lv_label_set_text_fmt(label, LV_SYMBOL_DIRECTORY " %s", GROUPS[i].name); + ctx->rowActions.push_back(RowAction { ctx, (int)i, label }); + lv_obj_add_event_cb(button, onGroupClicked, LV_EVENT_SHORT_CLICKED, &ctx->rowActions.back()); + } +} + +void groupListViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 4, 0); + addMessage(ctx, ctx->content, ""); + ctx->listWidget = createColumn(ctx->content); + + char name[48], path[256]; + snprintf(name, sizeof(name), "group-%s.tle", ctx->selectedGroup); + int64_t age = storageUserPath(name, path, sizeof(path)) ? fileAgeSeconds(path, currentTime()) : -1; + bool fresh = age >= 0 && (age < GROUP_CACHE_SECONDS || !timeIsValid()); + ctx->catalogLoading = false; + if (fresh) { + tleFileRead(path, ctx->catalog, ctx->groupLimit); + sortByName(ctx->catalog); + } + if (!fresh || ctx->catalog.empty()) { + DownloadJob job; + bool queued = timeIsValid() && Downloader::buildGroupJob(ctx->selectedGroup, job) && + (ctx->downloader.hasQueued(DownloadKind::Group, job.tag) || ctx->downloader.enqueue(job)); + if (queued) { + ctx->catalogLoading = true; + } else if (age >= 0) { + tleFileRead(path, ctx->catalog, ctx->groupLimit); + sortByName(ctx->catalog); + } + } + catalogViewRefresh(ctx); +} + +void searchViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 4, 0); + + lv_obj_t* row = lv_obj_create(ctx->content); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 6, 0); + + ctx->textArea = createTextArea(ctx, row); + lv_textarea_set_one_line(ctx->textArea, true); + lv_textarea_set_max_length(ctx->textArea, sizeof(ctx->searchText) - 1); + lv_textarea_set_placeholder_text(ctx->textArea, "Name or NORAD number"); + lv_textarea_set_text(ctx->textArea, ctx->searchText); + lv_obj_set_flex_grow(ctx->textArea, 1); + lv_obj_add_event_cb(ctx->textArea, onSearchStart, LV_EVENT_READY, ctx); + addButton(row, LV_SYMBOL_DIRECTORY, onSearchStart, ctx); + + addMessage(ctx, ctx->content, ""); + ctx->listWidget = createColumn(ctx->content); + catalogViewRefresh(ctx); +} + +void catalogViewRefresh(Context* ctx) { + if ((ctx->view != View::GroupList && ctx->view != View::Search) || ctx->listWidget == nullptr) return; + lv_obj_clean(ctx->listWidget); + ctx->rowActions.clear(); + ctx->rowActions.reserve(ctx->catalog.size()); + + if (ctx->catalogLoading) { + setLabelText(ctx->messageLabel, "Downloading from CelesTrak..."); + return; + } + if (ctx->catalog.empty()) { + if (ctx->view == View::Search && ctx->searchText[0] == '\0') { + setLabelText(ctx->messageLabel, "Type a satellite name (for example ISS or NOAA 19) or a NORAD catalog number."); + } else { + setLabelText(ctx->messageLabel, ctx->statusText[0] != '\0' ? ctx->statusText : "Nothing found."); + } + return; + } + + char tracking[96], message[160]; + describeTracking(ctx, tracking, sizeof(tracking)); + size_t shown = ctx->catalog.size() > MAX_CATALOG_ROWS ? MAX_CATALOG_ROWS : ctx->catalog.size(); + if (shown < ctx->catalog.size()) { + snprintf(message, sizeof(message), "%s. Showing the first %d, search to find others.", tracking, (int)shown); + } else if (ctx->model.count() > ctx->model.limit()) { + snprintf(message, sizeof(message), "%s", tracking); + } else { + snprintf(message, sizeof(message), "%s. Tap to add or remove.", tracking); + } + setLabelText(ctx->messageLabel, message); + for (size_t i = 0; i < shown; i++) { + lv_obj_t* button = lv_button_create(ctx->listWidget); + lv_obj_set_width(button, LV_PCT(100)); + lv_obj_t* label = lv_label_create(button); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_DOTS); + lv_obj_set_width(label, LV_PCT(100)); + char text[64]; + catalogRowText(ctx, ctx->catalog[i], text, sizeof(text)); + lv_label_set_text(label, text); + ctx->rowActions.push_back(RowAction { ctx, (int)i, label }); + lv_obj_add_event_cb(button, onCatalogRowClicked, LV_EVENT_SHORT_CLICKED, &ctx->rowActions.back()); + } +} + +void renameViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 6, 0); + if (ctx->renameIndex < 0 || ctx->renameIndex >= ctx->model.count()) { + addMessage(ctx, ctx->content, "This satellite is no longer tracked."); + return; + } + const TrackedSatellite& entry = ctx->model.at(ctx->renameIndex); + lv_obj_t* label = lv_label_create(ctx->content); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(label, LV_PCT(100)); + char text[96]; + if (entry.hasOrbit) { + snprintf(text, sizeof(text), "Short name for %s, shown in the legend:", entry.tle.name); + } else { + snprintf(text, sizeof(text), "Short name for satellite %u, shown in the legend:", (unsigned)entry.catalogNumber); + } + lv_label_set_text(label, text); + + ctx->textArea = createTextArea(ctx, ctx->content); + lv_textarea_set_one_line(ctx->textArea, true); + lv_textarea_set_max_length(ctx->textArea, SHORT_NAME_LENGTH - 1); + lv_textarea_set_text(ctx->textArea, entry.shortName); + lv_obj_set_width(ctx->textArea, LV_PCT(100)); + lv_obj_add_event_cb(ctx->textArea, onRenameSave, LV_EVENT_READY, ctx); + + lv_obj_t* buttons = lv_obj_create(ctx->content); + lv_obj_remove_style_all(buttons); + lv_obj_set_size(buttons, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(buttons, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(buttons, 8, 0); + addButton(buttons, LV_SYMBOL_OK " Save", onRenameSave, ctx); + if (entry.hasOrbit) addButton(buttons, LV_SYMBOL_REFRESH " Automatic", onRenameAuto, ctx); +} diff --git a/Apps/SatelliteTracker/main/Source/SettingsView.cpp b/Apps/SatelliteTracker/main/Source/SettingsView.cpp new file mode 100644 index 0000000..82b0315 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/SettingsView.cpp @@ -0,0 +1,250 @@ +#include "SatelliteTracker.h" + +#include "LocationApi.h" + +#include + +#include +#include +#include + +namespace { + +struct SettingsWidgets { + lv_obj_t* deviceStatus; + lv_obj_t* latitude; + lv_obj_t* longitude; + lv_obj_t* altitude; + lv_obj_t* interval; + lv_obj_t* tracksSwitch; + lv_obj_t* autoSwitch; + lv_obj_t* orbitLabel; +}; + +SettingsWidgets widgets {}; + +lv_obj_t* addLabel(lv_obj_t* parent, const char* text) { + lv_obj_t* label = lv_label_create(parent); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_text(label, text); + return label; +} + +lv_obj_t* addField(Context* ctx, lv_obj_t* parent, const char* title, const char* acceptedChars, const char* value, const char* placeholder) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 8, 0); + lv_obj_t* label = lv_label_create(row); + lv_label_set_text(label, title); + lv_obj_set_width(label, LV_PCT(45)); + lv_obj_t* textArea = createTextArea(ctx, row); + lv_textarea_set_one_line(textArea, true); + lv_textarea_set_accepted_chars(textArea, acceptedChars); + lv_textarea_set_max_length(textArea, 12); + lv_textarea_set_text(textArea, value); + lv_textarea_set_placeholder_text(textArea, placeholder); + lv_obj_set_flex_grow(textArea, 1); + return textArea; +} + +bool parseNumber(lv_obj_t* textArea, double minimum, double maximum, double& out) { + const char* text = lv_textarea_get_text(textArea); + if (text == nullptr || text[0] == '\0') return false; + char buffer[16]; + snprintf(buffer, sizeof(buffer), "%s", text); + for (char* p = buffer; *p; p++) { + if (*p == ',') *p = '.'; + } + char* end = nullptr; + double value = strtod(buffer, &end); + if (end == buffer || *end != '\0' || value < minimum || value > maximum) return false; + out = value; + return true; +} + +void describeOrbitData(Context* ctx, char* out, size_t outSize) { + int64_t age = orbitDataAge(ctx); + if (age < 0) { + snprintf(out, outSize, ctx->settings.lastTrackedRefresh > 0 ? "The age of the orbit data is unknown." : "Orbit data has not been downloaded yet."); + return; + } + int64_t minutes = age / 60; + if (minutes < 120) { + snprintf(out, outSize, "Orbit data is %d minutes old.", (int)minutes); + } else { + snprintf(out, outSize, "Orbit data is %d hours old.", (int)(minutes / 60)); + } +} + +void onSaveClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + Settings& settings = ctx->settings; + double latitude, longitude, altitude = 0.0, interval; + bool hasLatitude = parseNumber(widgets.latitude, -90.0, 90.0, latitude); + bool hasLongitude = parseNumber(widgets.longitude, -180.0, 180.0, longitude); + const char* latitudeText = lv_textarea_get_text(widgets.latitude); + const char* longitudeText = lv_textarea_get_text(widgets.longitude); + bool bothEmpty = latitudeText[0] == '\0' && longitudeText[0] == '\0'; + + if (!bothEmpty && (!hasLatitude || !hasLongitude)) { + setLabelText(ctx->messageLabel, "Latitude must be -90 to 90 and longitude -180 to 180, in decimal degrees (for example 40.7128 and -74.0060). Use a minus sign for south and west."); + return; + } + if (lv_textarea_get_text(widgets.altitude)[0] != '\0' && !parseNumber(widgets.altitude, -500.0, 9000.0, altitude)) { + setLabelText(ctx->messageLabel, "Altitude must be -500 to 9000 m."); + return; + } + if (lv_textarea_get_text(widgets.interval)[0] == '\0') { + settings.updateSeconds = 0; + } else if (parseNumber(widgets.interval, 1.0, 3600.0, interval)) { + settings.updateSeconds = (int32_t)interval; + } else { + setLabelText(ctx->messageLabel, "The update interval must be 1 to 3600 seconds."); + return; + } + + settings.observerSet = !bothEmpty; + if (settings.observerSet) { + settings.latitudeDeg = latitude; + settings.longitudeDeg = longitude; + settings.altitudeM = altitude; + } + settingsSave(settings); + applyObserver(ctx); + ctx->forceRedraw = true; + setLabelText(ctx->messageLabel, "Saved."); +} + +void onTracksChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settings.tracksForAll = lv_obj_has_state(widgets.tracksSwitch, LV_STATE_CHECKED); + settingsSave(ctx->settings); + ctx->forceRedraw = true; +} + +void describeDeviceLocation(Context* ctx, char* out, size_t outSize) { + if (!ctx->settings.useDeviceLocation) { + snprintf(out, outSize, "The location below is used."); + } else if (!location_is_enabled()) { + snprintf(out, outSize, "Location services are off in Tactility's settings, the location below is used."); + } else if (ctx->deviceFixValid) { + const orbit::Geodetic& fix = ctx->deviceFix; + snprintf(out, outSize, "Using the device location: %.3f\xC2\xB0%s %.3f\xC2\xB0%s (\xC2\xB1%.0f m, %s).", fix.latDeg < 0 ? -fix.latDeg : fix.latDeg, + fix.latDeg >= 0 ? "N" : "S", fix.lonDeg < 0 ? -fix.lonDeg : fix.lonDeg, fix.lonDeg >= 0 ? "E" : "W", ctx->deviceAccuracyM, ctx->deviceProvider); + } else { + snprintf(out, outSize, "Waiting for the device location, the location below is used until then."); + } +} + +void onDeviceLocationChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settings.useDeviceLocation = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); + settingsSave(ctx->settings); + applyObserver(ctx); + ctx->forceRedraw = true; + settingsViewUpdate(ctx); +} + +void onAutoSelectChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settings.autoSelectPass = lv_obj_has_state(widgets.autoSwitch, LV_STATE_CHECKED); + settingsSave(ctx->settings); + ctx->forceRedraw = true; +} + +lv_obj_t* addSwitch(lv_obj_t* parent, const char* title, bool checked, lv_event_cb_t callback, void* userData) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 8, 0); + lv_obj_t* label = lv_label_create(row); + lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); + lv_label_set_text(label, title); + lv_obj_set_flex_grow(label, 1); + lv_obj_t* toggle = lv_switch_create(row); + if (checked) lv_obj_add_state(toggle, LV_STATE_CHECKED); + lv_obj_add_event_cb(toggle, callback, LV_EVENT_VALUE_CHANGED, userData); + return toggle; +} + +void onRefreshClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (!timeIsValid()) { + setLabelText(ctx->messageLabel, "The clock is not set yet. Connect to Wi-Fi so the time can sync."); + } else if (requestTrackedRefresh(ctx, TRACKED_MIN_REFRESH_SECONDS)) { + setLabelText(ctx->messageLabel, "Updating orbit data..."); + } else if (ctx->downloader.isBusy()) { + setLabelText(ctx->messageLabel, "A download is already running."); + } else { + setLabelText(ctx->messageLabel, "Orbit data is less than 2 hours old. CelesTrak asks not to download it more often."); + } +} + +} + +void settingsViewCreate(Context* ctx) { + lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->content, 6, 0); + widgets = SettingsWidgets {}; + const Settings& settings = ctx->settings; + + addSwitch(ctx->content, "Use the device location (GPS or network) when available", settings.useDeviceLocation, onDeviceLocationChanged, ctx); + widgets.deviceStatus = addLabel(ctx->content, ""); + settingsViewUpdate(ctx); + addLabel(ctx->content, "Location used when the device location is off or not available. Leave empty for none."); + lv_obj_t* help = addLabel(ctx->content, + "Use decimal degrees. North and east are positive, south and west are negative. " + "Example for New York City: latitude 40.7128, longitude -74.0060, altitude 10. " + "To convert degrees and minutes, add minutes / 60: 40\xC2\xB0 43' is 40.72."); + lv_obj_set_style_text_font(help, lvgl_get_text_font(FONT_SIZE_SMALL), 0); + char latitude[16] = "", longitude[16] = "", altitude[16] = "", interval[16] = ""; + if (settings.observerSet) { + snprintf(latitude, sizeof(latitude), "%.4f", settings.latitudeDeg); + snprintf(longitude, sizeof(longitude), "%.4f", settings.longitudeDeg); + snprintf(altitude, sizeof(altitude), "%.0f", settings.altitudeM); + } + if (settings.updateSeconds > 0) { + snprintf(interval, sizeof(interval), "%d", (int)settings.updateSeconds); + } + widgets.latitude = addField(ctx, ctx->content, "Latitude", "-0123456789.,", latitude, "40.7128"); + widgets.longitude = addField(ctx, ctx->content, "Longitude", "-0123456789.,", longitude, "-74.0060"); + widgets.altitude = addField(ctx, ctx->content, "Altitude (m)", "-0123456789", altitude, "10"); + + char intervalTitle[48]; + snprintf(intervalTitle, sizeof(intervalTitle), "Map update (s), default %d", ctx->slowDisplay ? 60 : 5); + widgets.interval = addField(ctx, ctx->content, intervalTitle, "0123456789", interval, ctx->slowDisplay ? "60" : "5"); + + widgets.tracksSwitch = addSwitch(ctx->content, "Tracks for all satellites", settings.tracksForAll, onTracksChanged, ctx); + widgets.autoSwitch = addSwitch(ctx->content, "Select the next pass automatically when no satellite is selected", settings.autoSelectPass, + onAutoSelectChanged, ctx); + + lv_obj_t* saveButton = lv_button_create(ctx->content); + lv_obj_t* saveLabel = lv_label_create(saveButton); + lv_label_set_text(saveLabel, LV_SYMBOL_SAVE " Save"); + lv_obj_add_event_cb(saveButton, onSaveClicked, LV_EVENT_SHORT_CLICKED, ctx); + + char orbit[64]; + describeOrbitData(ctx, orbit, sizeof(orbit)); + widgets.orbitLabel = addLabel(ctx->content, orbit); + lv_obj_t* refreshButton = lv_button_create(ctx->content); + lv_obj_t* refreshLabel = lv_label_create(refreshButton); + lv_label_set_text(refreshLabel, LV_SYMBOL_REFRESH " Update orbit data"); + lv_obj_add_event_cb(refreshButton, onRefreshClicked, LV_EVENT_SHORT_CLICKED, ctx); + + ctx->messageLabel = addLabel(ctx->content, ""); + lv_obj_t* credit = addLabel(ctx->content, "Orbit data: CelesTrak. Map: Natural Earth."); + lv_obj_set_style_text_font(credit, lvgl_get_text_font(FONT_SIZE_SMALL), 0); +} + +void settingsViewUpdate(Context* ctx) { + if (widgets.deviceStatus == nullptr) return; + char text[160]; + describeDeviceLocation(ctx, text, sizeof(text)); + setLabelText(widgets.deviceStatus, text); +} diff --git a/Apps/SatelliteTracker/main/Source/Sgp4Bridge.c b/Apps/SatelliteTracker/main/Source/Sgp4Bridge.c new file mode 100644 index 0000000..f9d8982 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Sgp4Bridge.c @@ -0,0 +1,45 @@ +#include "Sgp4Bridge.h" + +#include "sgp4/SGP4.h" + +#include +#include + +struct ElsetRec* sgp4_bridge_create(void) { + return (struct ElsetRec*)calloc(1, sizeof(ElsetRec)); +} + +void sgp4_bridge_destroy(struct ElsetRec* record) { + free(record); +} + +bool sgp4_bridge_init(struct ElsetRec* record, const struct Sgp4Elements* elements) { + const double xpdotp = 1440.0 / (2.0 * pi); + + memset(record, 0, sizeof(ElsetRec)); + record->whichconst = wgs72; + record->jdsatepoch = elements->epochJulianDay; + record->jdsatepochF = elements->epochJulianDayFraction; + record->bstar = elements->bstar; + record->inclo = elements->inclinationDeg * deg2rad; + record->nodeo = elements->raanDeg * deg2rad; + record->argpo = elements->argumentOfPerigeeDeg * deg2rad; + record->mo = elements->meanAnomalyDeg * deg2rad; + record->ecco = elements->eccentricity; + record->no_kozai = elements->meanMotionRevPerDay / xpdotp; + record->ndot = elements->meanMotionDot / (xpdotp * 1440.0); + record->nddot = elements->meanMotionDotDot / (xpdotp * 1440.0 * 1440.0); + + sgp4init('a', record); + return record->error == 0; +} + +bool sgp4_bridge_propagate(struct ElsetRec* record, double minutesSinceEpoch, double position[3], double velocity[3]) { + record->error = 0; + sgp4(record, minutesSinceEpoch, position, velocity); + return record->error == 0; +} + +double sgp4_bridge_gmst(double julianDayUt1) { + return gstime(julianDayUt1); +} diff --git a/Apps/SatelliteTracker/main/Source/Sgp4Bridge.h b/Apps/SatelliteTracker/main/Source/Sgp4Bridge.h new file mode 100644 index 0000000..a5951fb --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Sgp4Bridge.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct ElsetRec; + +struct Sgp4Elements { + double epochJulianDay; + double epochJulianDayFraction; + double bstar; + double meanMotionDot; + double meanMotionDotDot; + double inclinationDeg; + double raanDeg; + double eccentricity; + double argumentOfPerigeeDeg; + double meanAnomalyDeg; + double meanMotionRevPerDay; +}; + +struct ElsetRec* sgp4_bridge_create(void); +void sgp4_bridge_destroy(struct ElsetRec* record); +bool sgp4_bridge_init(struct ElsetRec* record, const struct Sgp4Elements* elements); +bool sgp4_bridge_propagate(struct ElsetRec* record, double minutesSinceEpoch, double position[3], double velocity[3]); +double sgp4_bridge_gmst(double julianDayUt1); + +#ifdef __cplusplus +} +#endif diff --git a/Apps/SatelliteTracker/main/Source/Storage.cpp b/Apps/SatelliteTracker/main/Source/Storage.cpp new file mode 100644 index 0000000..09e8279 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Storage.cpp @@ -0,0 +1,189 @@ +#include "Storage.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* SETTINGS_FILE = "settings.properties"; +/** The tracked satellites, written only when the user changes the list. */ +constexpr const char* LIST_FILE = "satellites.txt"; +/** The latest orbit data of the tracked satellites, written after downloads. */ +constexpr const char* ORBIT_FILE = "orbits.tle"; + +void trimLineEnd(char* line) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; + } +} + +FILE* openUserFile(const char* name) { + char path[256]; + if (!storageEnsureDirectory() || !storageUserPath(name, path, sizeof(path))) return nullptr; + return fopen(path, "w"); +} + +} + +bool storageEnsureDirectory() { + char dir[256]; + if (app_paths_get_user_data_directory(APP_ID, dir, sizeof(dir)) != ERROR_NONE) return false; + for (char* p = dir + 1; *p; ++p) { + if (*p == '/') { + *p = '\0'; + mkdir(dir, 0755); + *p = '/'; + } + } + mkdir(dir, 0755); + return true; +} + +bool storageUserPath(const char* fileName, char* out, size_t outSize) { + return app_paths_get_user_data_path(APP_ID, fileName, out, outSize) == ERROR_NONE; +} + +bool storageAssetPath(const char* fileName, char* out, size_t outSize) { + return app_paths_get_assets_path(APP_ID, fileName, out, outSize) == ERROR_NONE; +} + +void settingsLoad(Settings& settings) { + char path[256]; + if (!storageUserPath(SETTINGS_FILE, path, sizeof(path))) return; + Preferences* preferences = preferences_open(path); + if (preferences == nullptr) return; + int32_t latMicro = 0, lonMicro = 0, altM = 0; + preferences_opt_bool(preferences, "observerSet", &settings.observerSet); + if (preferences_opt_int32(preferences, "latMicro", &latMicro)) settings.latitudeDeg = latMicro / 1.0e6; + if (preferences_opt_int32(preferences, "lonMicro", &lonMicro)) settings.longitudeDeg = lonMicro / 1.0e6; + if (preferences_opt_int32(preferences, "altM", &altM)) settings.altitudeM = altM; + preferences_opt_int32(preferences, "updateSeconds", &settings.updateSeconds); + preferences_opt_bool(preferences, "tracksForAll", &settings.tracksForAll); + preferences_opt_bool(preferences, "autoSelect", &settings.autoSelectPass); + preferences_opt_bool(preferences, "hideToolbar", &settings.hideToolbar); + preferences_opt_bool(preferences, "useDeviceLoc", &settings.useDeviceLocation); + preferences_opt_int64(preferences, "lastRefresh", &settings.lastTrackedRefresh); + preferences_close(preferences); +} + +void settingsSave(const Settings& settings) { + if (!storageEnsureDirectory()) return; + char path[256]; + if (!storageUserPath(SETTINGS_FILE, path, sizeof(path))) return; + Preferences* preferences = preferences_open(path); + if (preferences == nullptr) return; + preferences_put_bool(preferences, "observerSet", settings.observerSet); + preferences_put_int32(preferences, "latMicro", (int32_t)(settings.latitudeDeg * 1.0e6)); + preferences_put_int32(preferences, "lonMicro", (int32_t)(settings.longitudeDeg * 1.0e6)); + preferences_put_int32(preferences, "altM", (int32_t)settings.altitudeM); + preferences_put_int32(preferences, "updateSeconds", settings.updateSeconds); + preferences_put_bool(preferences, "tracksForAll", settings.tracksForAll); + preferences_put_bool(preferences, "autoSelect", settings.autoSelectPass); + preferences_put_bool(preferences, "hideToolbar", settings.hideToolbar); + preferences_put_bool(preferences, "useDeviceLoc", settings.useDeviceLocation); + preferences_put_int64(preferences, "lastRefresh", settings.lastTrackedRefresh); + preferences_close(preferences); +} + +namespace { + +/** Reads the list format "catalog|slot|short name". The short name is the rest of the line and may contain '|'. */ +bool listLoad(const char* path, TrackerModel& model) { + FILE* file = fopen(path, "r"); + if (file == nullptr) return false; + char line[96]; + while (fgets(line, sizeof(line), file)) { + trimLineEnd(line); + char* end = nullptr; + unsigned long catalog = strtoul(line, &end, 10); + if (end == line || *end != '|') continue; + char* slotText = end + 1; + long slot = strtol(slotText, &end, 10); + if (end == slotText || *end != '|') continue; + model.add((uint32_t)catalog, end + 1, (int)slot, true); + } + fclose(file); + return true; +} + +} + +bool trackedLoad(TrackerModel& model) { + char path[256]; + if (!storageUserPath(LIST_FILE, path, sizeof(path)) || !listLoad(path, model)) return false; + if (storageUserPath(ORBIT_FILE, path, sizeof(path))) { + TleList tles; + tleFileRead(path, tles, MAX_TRACKED * 2); + for (const auto& tle : tles) { + model.setTle(model.indexOfCatalog(orbit::tleCatalogNumber(tle)), tle); + } + } + return true; +} + +void trackedListSave(const TrackerModel& model) { + FILE* file = openUserFile(LIST_FILE); + if (file == nullptr) return; + for (int i = 0; i < model.count(); i++) { + const TrackedSatellite& entry = model.at(i); + fprintf(file, "%u|%d|%s\n", (unsigned)entry.catalogNumber, entry.slot, entry.shortName); + } + fclose(file); +} + +void orbitCacheSave(const TrackerModel& model) { + FILE* file = openUserFile(ORBIT_FILE); + if (file == nullptr) return; + for (int i = 0; i < model.count(); i++) { + const TrackedSatellite& entry = model.at(i); + if (!entry.hasOrbit) continue; + fprintf(file, "%s\n%.69s\n%.69s\n", entry.tle.name, entry.tle.line1, entry.tle.line2); + } + fclose(file); +} + +bool tleFileRead(const char* path, TleList& out, size_t maxEntries) { + out.clear(); + FILE* file = fopen(path, "r"); + if (file == nullptr) return false; + // One allocation of the final size, growing step by step would need the old and the new block at the same time. + out.reserve(maxEntries); + char lines[3][96]; + int filled = 0; + while (out.size() < maxEntries && fgets(lines[filled], sizeof(lines[filled]), file)) { + trimLineEnd(lines[filled]); + if (lines[filled][0] == '\0') continue; + filled++; + if (filled < 3) continue; + orbit::Tle tle; + if (orbit::parseTle(lines[0], lines[1], lines[2], tle)) { + out.push_back(tle); + filled = 0; + } else { + memmove(lines[0], lines[1], sizeof(lines[0])); + memmove(lines[1], lines[2], sizeof(lines[1])); + filled = 2; + } + } + fclose(file); + return true; +} + +void sortByName(TleList& list) { + std::sort(list.begin(), list.end(), [](const orbit::Tle& a, const orbit::Tle& b) { return strcasecmp(a.name, b.name) < 0; }); +} + +int64_t fileAgeSeconds(const char* path, int64_t now) { + struct stat info; + if (stat(path, &info) != 0) return -1; + int64_t age = now - (int64_t)info.st_mtime; + return age < 0 ? 0 : age; +} diff --git a/Apps/SatelliteTracker/main/Source/Storage.h b/Apps/SatelliteTracker/main/Source/Storage.h new file mode 100644 index 0000000..9879cae --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/Storage.h @@ -0,0 +1,54 @@ +#pragma once + +#include "LargeMemory.h" +#include "Orbit.h" +#include "TrackerModel.h" + +#include +#include + +constexpr const char* APP_ID = "tactility.satellitetracker"; + +using TleList = std::vector>; + +/** Until the user sets a location, New York City is used, so passes and the automatic selection work from the start. */ +struct Settings { + bool observerSet = true; + double latitudeDeg = 40.7128; + double longitudeDeg = -74.0060; + double altitudeM = 10.0; + /** 0 means the default for the display type. */ + int32_t updateSeconds = 0; + bool tracksForAll = false; + bool autoSelectPass = true; + bool hideToolbar = false; + /** Follow Tactility's location service (GPS or network) when it has a fix. */ + bool useDeviceLocation = true; + /** When orbit data of a tracked satellite was last downloaded. */ + int64_t lastTrackedRefresh = 0; +}; + +bool storageEnsureDirectory(); +bool storageUserPath(const char* fileName, char* out, size_t outSize); +bool storageAssetPath(const char* fileName, char* out, size_t outSize); + +void settingsLoad(Settings& settings); +void settingsSave(const Settings& settings); + +/** + * Restores the list of tracked satellites and their orbit data from the cache. Satellites without cached orbit data are + * restored without it. Returns false when no list was saved yet, which means the app starts for the first time. + */ +bool trackedLoad(TrackerModel& model); +/** Writes the list of tracked satellites: catalog number, slot and short name. */ +void trackedListSave(const TrackerModel& model); +/** Writes the orbit data of the tracked satellites that have it. */ +void orbitCacheSave(const TrackerModel& model); + +/** Reads a CelesTrak TLE file (name line plus two element lines). Stops after maxEntries, which is also reserved up front. */ +bool tleFileRead(const char* path, TleList& out, size_t maxEntries); + +void sortByName(TleList& list); + +/** Seconds since the file was written, or -1 when it does not exist. */ +int64_t fileAgeSeconds(const char* path, int64_t now); diff --git a/Apps/SatelliteTracker/main/Source/TrackerModel.cpp b/Apps/SatelliteTracker/main/Source/TrackerModel.cpp new file mode 100644 index 0000000..b46ea57 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/TrackerModel.cpp @@ -0,0 +1,186 @@ +#include "TrackerModel.h" + +#include +#include +#include + +namespace { + +constexpr int TRACK_POINTS = 90; + +void appendTrack(orbit::Satellite& satellite, double from, double to, std::vector& out) { + out.clear(); + out.reserve(TRACK_POINTS + 1); + double step = (to - from) / TRACK_POINTS; + for (int i = 0; i <= TRACK_POINTS; i++) { + orbit::Geodetic g; + if (satellite.geodetic(from + step * i, g)) { + out.push_back(map::GeoPoint { (float)g.latDeg, (float)g.lonDeg }); + } + } +} + +} + +bool TrackerModel::slotUsed(int slot) const { + for (int i = 0; i < satelliteCount; i++) { + if (satellites[i]->slot == slot) return true; + } + return false; +} + +int TrackerModel::freeSlot() const { + for (int slot = 0; slot < MAX_TRACKED; slot++) { + if (!slotUsed(slot)) return slot; + } + return -1; +} + +int TrackerModel::add(uint32_t catalogNumber, const char* shortName, int slot, bool restoring) { + if (catalogNumber == 0 || satelliteCount >= MAX_TRACKED || (!restoring && satelliteCount >= trackedLimit)) return -1; + if (indexOfCatalog(catalogNumber) >= 0) return -1; + auto entry = std::make_unique(); + entry->catalogNumber = catalogNumber; + if (shortName != nullptr && shortName[0] != '\0') { + snprintf(entry->shortName, sizeof(entry->shortName), "%s", shortName); + } else { + snprintf(entry->shortName, sizeof(entry->shortName), "%u", (unsigned)catalogNumber); + } + entry->slot = (slot >= 0 && slot < MAX_TRACKED && !slotUsed(slot)) ? slot : freeSlot(); + satellites[satelliteCount] = std::move(entry); + return satelliteCount++; +} + +int TrackerModel::add(const orbit::Tle& tle, const char* shortName) { + orbit::Satellite check; + if (!check.init(tle)) return -1; + char automatic[SHORT_NAME_LENGTH]; + if (shortName == nullptr || shortName[0] == '\0') { + makeShortName(tle.name, automatic, sizeof(automatic)); + shortName = automatic; + } + int index = add(orbit::tleCatalogNumber(tle), shortName); + if (index >= 0) setTle(index, tle); + return index; +} + +void TrackerModel::remove(int index) { + if (index < 0 || index >= satelliteCount) return; + for (int i = index; i < satelliteCount - 1; i++) { + satellites[i] = std::move(satellites[i + 1]); + } + satellites[--satelliteCount].reset(); +} + +int TrackerModel::indexOfCatalog(uint32_t catalogNumber) const { + for (int i = 0; i < satelliteCount; i++) { + if (satellites[i]->catalogNumber == catalogNumber) return i; + } + return -1; +} + +bool TrackerModel::setTle(int index, const orbit::Tle& tle) { + if (index < 0 || index >= satelliteCount) return false; + TrackedSatellite& entry = *satellites[index]; + if (orbit::tleCatalogNumber(tle) != entry.catalogNumber) return false; + if (entry.hasOrbit && memcmp(entry.tle.line1, tle.line1, sizeof(tle.line1)) == 0 && memcmp(entry.tle.line2, tle.line2, sizeof(tle.line2)) == 0) { + return false; + } + if (!entry.propagator.init(tle)) { + // The previous orbit data stays in use. + if (entry.hasOrbit) entry.propagator.init(entry.tle); + return false; + } + entry.tle = tle; + entry.hasOrbit = true; + entry.passResult = orbit::PassSearchResult::None; + entry.passSearchedAt = 0; + return true; +} + +void TrackerModel::setObserver(bool available, const orbit::Geodetic& location) { + bool changed = available != observerAvailable || location.latDeg != observerLocation.latDeg || location.lonDeg != observerLocation.lonDeg; + observerAvailable = available; + observerLocation = location; + if (changed) invalidatePasses(); +} + +void TrackerModel::updatePositions(double unixTime) { + for (int i = 0; i < satelliteCount; i++) { + TrackedSatellite& entry = *satellites[i]; + orbit::Vec3 r, v; + entry.positionValid = entry.hasOrbit && entry.propagator.positionEci(unixTime, r, v); + if (!entry.positionValid) continue; + entry.position = orbit::eciToGeodetic(r, unixTime); + entry.sunlit = orbit::isSunlit(r, unixTime); + entry.speedKmS = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (observerAvailable) { + entry.propagator.lookAngles(observerLocation, unixTime, entry.look); + } + } +} + +bool TrackerModel::updatePasses(int64_t unixTime, int64_t searchSeconds) { + if (!observerAvailable) return false; + bool changed = false; + for (int i = 0; i < satelliteCount; i++) { + TrackedSatellite& entry = *satellites[i]; + if (!entry.hasOrbit) continue; + bool stale = entry.passSearchedAt == 0; + if (entry.passResult == orbit::PassSearchResult::Found && entry.nextPass.setTime < unixTime) stale = true; + if (entry.passResult != orbit::PassSearchResult::Found && unixTime - entry.passSearchedAt > 3600) stale = true; + if (!stale) continue; + entry.passResult = entry.propagator.findNextPass(observerLocation, unixTime, searchSeconds, MIN_PASS_ELEVATION_DEG, entry.nextPass); + entry.passSearchedAt = unixTime; + changed = true; + } + return changed; +} + +void TrackerModel::invalidatePasses() { + for (int i = 0; i < satelliteCount; i++) { + satellites[i]->passSearchedAt = 0; + } +} + +void TrackerModel::buildScene(double unixTime, int focusedIndex, bool tracksForAll, map::Scene& out) { + orbit::Geodetic sun = orbit::subsolarPoint(unixTime); + out.showNight = true; + out.subsolar = map::GeoPoint { (float)sun.latDeg, (float)sun.lonDeg }; + out.hasObserver = observerAvailable; + out.observer = map::GeoPoint { (float)observerLocation.latDeg, (float)observerLocation.lonDeg }; + out.satellites.resize(satelliteCount); + for (int i = 0; i < satelliteCount; i++) { + TrackedSatellite& entry = *satellites[i]; + map::SatelliteOverlay& overlay = out.satellites[i]; + overlay.visible = entry.positionValid; + overlay.position = map::GeoPoint { (float)entry.position.latDeg, (float)entry.position.lonDeg }; + overlay.shape = static_cast(entry.slot % map::MARKER_SHAPE_COUNT); + overlay.hollow = entry.slot >= map::MARKER_SHAPE_COUNT; + overlay.colorIndex = entry.slot; + overlay.focused = i == focusedIndex; + overlay.drawTrack = entry.positionValid && (overlay.focused || tracksForAll); + overlay.footprintRadiusDeg = overlay.drawTrack ? (float)orbit::footprintRadiusDeg(entry.position.altKm) : 0.0f; + if (overlay.drawTrack) { + double period = entry.propagator.periodMinutes() * 60.0; + if (period > 6.0 * 3600.0) period = 6.0 * 3600.0; + appendTrack(entry.propagator, unixTime - period * 0.5, unixTime, overlay.pastTrack); + appendTrack(entry.propagator, unixTime, unixTime + period, overlay.futureTrack); + } else { + overlay.pastTrack.clear(); + overlay.futureTrack.clear(); + } + } +} + +void TrackerModel::makeShortName(const char* fullName, char* out, size_t outSize) { + if (outSize == 0) return; + size_t length = 0; + const char* bracket = strchr(fullName, '('); + size_t end = bracket != nullptr && bracket != fullName ? (size_t)(bracket - fullName) : strlen(fullName); + while (end > 0 && fullName[end - 1] == ' ') end--; + for (size_t i = 0; i < end && length + 1 < outSize; i++) { + out[length++] = fullName[i]; + } + out[length] = '\0'; +} diff --git a/Apps/SatelliteTracker/main/Source/TrackerModel.h b/Apps/SatelliteTracker/main/Source/TrackerModel.h new file mode 100644 index 0000000..5a49165 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/TrackerModel.h @@ -0,0 +1,90 @@ +#pragma once + +#include "MapRenderer.h" +#include "Orbit.h" + +#include +#include +#include + +/** Upper bound for the arrays. The usable limit depends on the screen, see TrackerModel::setLimit(). */ +constexpr int MAX_TRACKED = 12; +constexpr int DEFAULT_TRACKED_LIMIT = 6; +constexpr int SHORT_NAME_LENGTH = 16; +/** Passes that stay lower than this are skipped, they are hardly usable. */ +constexpr double MIN_PASS_ELEVATION_DEG = 5.0; + +struct TrackedSatellite { + uint32_t catalogNumber = 0; + char shortName[SHORT_NAME_LENGTH] {}; + int slot = 0; + + /** False until orbit data was downloaded or restored. tle and propagator are only usable when true. */ + bool hasOrbit = false; + orbit::Tle tle {}; + orbit::Satellite propagator; + + bool positionValid = false; + orbit::Geodetic position {}; + orbit::LookAngles look {}; + bool sunlit = false; + double speedKmS = 0.0; + + orbit::PassSearchResult passResult = orbit::PassSearchResult::None; + orbit::Pass nextPass {}; + int64_t passSearchedAt = 0; + + /** Download state, not saved: failed attempts and the earliest time of the next attempt for missing orbit data. */ + int downloadFailures = 0; + int64_t retryAt = 0; +}; + +class TrackerModel { + +public: + + int count() const { return satelliteCount; } + int limit() const { return trackedLimit; } + void setLimit(int value) { trackedLimit = value < 1 ? 1 : (value > MAX_TRACKED ? MAX_TRACKED : value); } + TrackedSatellite& at(int index) { return *satellites[index]; } + const TrackedSatellite& at(int index) const { return *satellites[index]; } + + /** + * Adds a satellite without orbit data. Returns the index or -1 when full or already tracked. A slot of -1 picks a free one. + * restoring skips the limit so a list saved on a bigger screen is kept. + */ + int add(uint32_t catalogNumber, const char* shortName, int slot = -1, bool restoring = false); + /** Adds a satellite with its orbit data. A null or empty shortName is made from the TLE name. Returns -1 when the TLE is not usable. */ + int add(const orbit::Tle& tle, const char* shortName); + void remove(int index); + int indexOfCatalog(uint32_t catalogNumber) const; + + /** Sets new orbit data. Returns false when the TLE belongs to another satellite, is not usable or did not change. */ + bool setTle(int index, const orbit::Tle& tle); + + void setObserver(bool available, const orbit::Geodetic& location); + bool hasObserver() const { return observerAvailable; } + const orbit::Geodetic& observer() const { return observerLocation; } + + void updatePositions(double unixTime); + + /** Searches passes that ended or are missing. Returns true when anything changed. */ + bool updatePasses(int64_t unixTime, int64_t searchSeconds); + void invalidatePasses(); + + void buildScene(double unixTime, int focusedIndex, bool tracksForAll, map::Scene& out); + + /** The name from the orbit data without the part in brackets ("ISS" for "ISS (ZARYA)"). */ + static void makeShortName(const char* fullName, char* out, size_t outSize); + +private: + + int freeSlot() const; + bool slotUsed(int slot) const; + + std::array, MAX_TRACKED> satellites; + int satelliteCount = 0; + int trackedLimit = DEFAULT_TRACKED_LIMIT; + bool observerAvailable = false; + orbit::Geodetic observerLocation {}; +}; diff --git a/Apps/SatelliteTracker/main/Source/WorldMap.c b/Apps/SatelliteTracker/main/Source/WorldMap.c new file mode 100644 index 0000000..3ae301c --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/WorldMap.c @@ -0,0 +1,644 @@ +// Generated by tools/gen_world_map.py from Natural Earth ne_110m_land (public domain). Do not edit. +#include "WorldMap.h" + +const uint16_t WORLD_MAP_RING_COUNT = 128; + +const uint16_t WORLD_MAP_RING_LENGTHS[] = { + 12, 10, 20, 8, 6, 12, 24, 558, 22, 9, 8, 16, 29, 35, 12, 8, + 7, 4, 4, 6, 48, 223, 5, 6, 7, 6, 14, 9, 8, 8, 6, 22, + 6, 10, 21, 5, 8, 13, 66, 44, 12, 34, 45, 26, 9, 7, 11, 8, + 8, 13, 5, 32, 8, 10, 35, 9, 16, 8, 4, 8, 6, 41, 7, 8, + 5, 6, 11, 11, 10, 10, 9, 36, 6, 15, 8, 7, 15, 32, 10, 21, + 11, 5, 8, 43, 6, 7, 6, 11, 19, 19, 8, 23, 10, 4, 7, 929, + 44, 5, 10, 72, 14, 7, 10, 16, 6, 7, 8, 12, 21, 25, 27, 10, + 1297, 51, 6, 6, 7, 6, 8, 10, 6, 17, 11, 14, 10, 20, 64, 131, +}; + +const int16_t WORLD_MAP_POINTS[10002] = { + -5957, -8004, -5987, -8055, -6016, -8100, -6226, -8086, -6449, -8092, -6574, -8059, -6574, -8055, -6629, -8026, + -6404, -8029, -6188, -8039, -6114, -7998, -6061, -7963, -15921, -7950, -16113, -7963, -16244, -7928, -16303, -7893, + -16307, -7887, -16371, -7860, -16311, -7822, -16125, -7838, -16025, -7869, -15948, -7905, -4515, -7805, -4392, -7848, + -4349, -7909, -4337, -7952, -4333, -8003, -4488, -8034, -4651, -8059, -4839, -8083, -5048, -8103, -5285, -8097, + -5416, -8063, -5399, -8022, -5185, -7995, -5099, -7961, -5036, -7918, -4991, -7881, -4931, -7846, -4866, -7805, + -4815, -7805, -4666, -7783, -12121, -7350, -11992, -7366, -11872, -7348, -11929, -7383, -12023, -7409, -12162, -7401, + -12262, -7366, -12241, -7332, -12556, -7348, -12403, -7387, -12462, -7383, -12591, -7374, -12728, -7346, -12656, -7325, + -9898, -7193, -9788, -7207, -9679, -7195, -9620, -7252, -9698, -7244, -9820, -7248, -9943, -7244, -10078, -7250, + -10180, -7231, -10233, -7189, -10170, -7172, -10043, -7185, -6845, -7096, -6833, -7141, -6851, -7180, -6878, -7217, + -6996, -7231, -7108, -7250, -7239, -7248, -7190, -7209, -7307, -7223, -7419, -7237, -7495, -7207, -7501, -7166, + -7392, -7127, -7323, -7115, -7207, -7119, -7178, -7068, -7172, -7031, -7174, -6951, -7117, -6904, -7025, -6888, + -6972, -6925, -6949, -6962, -6906, -7007, -6873, -7051, -5861, -6415, -5905, -6437, -5979, -6421, -6061, -6431, + -6130, -6454, -6202, -6480, -6251, -6509, -6265, -6548, -6259, -6586, -6212, -6619, -6281, -6643, -6375, -6650, + -6429, -6684, -6488, -6715, -6551, -6758, -6567, -6795, -6531, -6837, -6478, -6868, -6396, -6891, -6320, -6923, + -6279, -6962, -6257, -6999, -6228, -7038, -6181, -7072, -6151, -7109, -6138, -7201, -6108, -7238, -6100, -7277, + -6069, -7317, -6083, -7370, -6138, -7411, -6196, -7444, -6330, -7458, -6375, -7493, -6435, -7526, -6586, -7564, + -6719, -7579, -6845, -7601, -6980, -7622, -7060, -7663, -7221, -7667, -7397, -7663, -7556, -7671, -7724, -7671, + -7693, -7710, -7540, -7728, -7428, -7756, -7366, -7791, -7477, -7822, -7650, -7812, -7793, -7838, -7798, -7879, + -7802, -7918, -7685, -7951, -7663, -7989, -7536, -8026, -7324, -8042, -7144, -8069, -7001, -8100, -6819, -8132, + -6570, -8147, -6326, -8175, -6155, -8204, -5969, -8238, -5871, -8285, -5822, -8322, -5701, -8287, -5536, -8257, + -5362, -8226, -5154, -8200, -4976, -8173, -4727, -8171, -4483, -8185, -4281, -8208, -4216, -8165, -4077, -8136, + -3824, -8134, -3627, -8112, -3439, -8091, -3231, -8077, -3010, -8059, -2855, -8034, -2925, -7999, -2969, -7963, + -2969, -7926, -3162, -7930, -3368, -7946, -3564, -7946, -3591, -7908, -3578, -7834, -3533, -7812, -3390, -7789, + -3221, -7765, -3100, -7736, -2978, -7707, -2888, -7667, -2751, -7650, -2616, -7636, -2547, -7628, -2393, -7624, + -2246, -7611, -2122, -7591, -2001, -7567, -1891, -7544, -1752, -7513, -1664, -7479, -1570, -7450, -1541, -7411, + -1647, -7387, -1611, -7346, -1545, -7315, -1441, -7295, -1331, -7272, -1229, -7240, -1151, -7201, -1102, -7154, + -1030, -7127, -910, -7132, -861, -7166, -742, -7170, -738, -7132, -687, -7093, -579, -7103, -554, -7140, + -434, -7146, -305, -7129, -180, -7117, -66, -7123, -23, -7164, 87, -7130, 189, -7113, 302, -7099, + 414, -7085, 516, -7062, 627, -7046, 714, -7025, 774, -6989, 849, -7015, 953, -7001, 1025, -7048, + 1082, -7083, 1195, -7064, 1240, -7025, 1342, -6997, 1473, -7003, 1513, -7040, 1595, -7003, 1703, -6991, + 1820, -6987, 1926, -6989, 2038, -7001, 2145, -7007, 2192, -7040, 2257, -7070, 2367, -7052, 2484, -7048, + 2598, -7048, 2709, -7046, 2809, -7032, 2915, -7021, 3003, -6993, 3097, -6976, 3199, -6966, 3275, -6938, + 3330, -6884, 3387, -6850, 3491, -6866, 3530, -6901, 3616, -6925, 3720, -6917, 3791, -6952, 3865, -6978, + 3967, -6954, 4002, -6911, 4092, -6893, 4196, -6860, 4294, -6846, 4411, -6827, 4490, -6805, 4572, -6782, + 4650, -6760, 4744, -6772, 4834, -6737, 4899, -6709, 4993, -6711, 5075, -6688, 5095, -6652, 5179, -6625, + 5261, -6605, 5361, -6590, 5453, -6582, 5541, -6588, 5636, -6597, 5716, -6625, 5726, -6668, 5814, -6701, + 5874, -6729, 5994, -6741, 6061, -6768, 6143, -6795, 6239, -6801, 6319, -6782, 6405, -6741, 6499, -6762, + 6597, -6774, 6691, -6786, 6789, -6793, 6889, -6793, 6971, -6897, 6967, -6923, 6956, -6968, 6860, -6993, + 6781, -7031, 6795, -7070, 6907, -7068, 6893, -7107, 6842, -7144, 6795, -7185, 6871, -7217, 6987, -7226, + 7102, -7209, 7157, -7170, 7191, -7132, 7245, -7101, 7308, -7072, 7334, -7036, 7386, -6987, 7449, -6978, + 7563, -6974, 7663, -6962, 7764, -6946, 7813, -6907, 7843, -6870, 7911, -6833, 8009, -6807, 8094, -6788, + 8148, -6754, 8205, -6737, 8278, -6721, 8378, -6731, 8468, -6721, 8566, -6709, 8675, -6715, 8748, -6688, + 8799, -6621, 8836, -6648, 8883, -6695, 8967, -6715, 9063, -6723, 9159, -6711, 9261, -6719, 9355, -6721, + 9418, -6711, 9502, -6717, 9578, -6739, 9668, -6725, 9776, -6725, 9868, -6711, 9972, -6725, 10038, -6692, + 10089, -6658, 10158, -6631, 10283, -6556, 10348, -6570, 10424, -6597, 10491, -6633, 10618, -6693, 10716, -6695, + 10808, -6695, 10916, -6684, 11024, -6670, 11106, -6643, 11174, -6613, 11286, -6609, 11360, -6588, 11439, -6607, + 11490, -6639, 11560, -6670, 11670, -6666, 11738, -6692, 11858, -6717, 11983, -6727, 12087, -6719, 12165, -6688, + 12232, -6656, 12322, -6648, 12412, -6662, 12516, -6672, 12610, -6656, 12700, -6656, 12788, -6666, 12880, -6676, + 12970, -6658, 13078, -6643, 13180, -6639, 13294, -6639, 13386, -6629, 13476, -6621, 13503, -6572, 13507, -6531, + 13570, -6558, 13587, -6603, 13621, -6645, 13662, -6678, 13746, -6695, 13860, -6690, 13991, -6688, 14081, -6682, + 14212, -6682, 14306, -6680, 14437, -6684, 14549, -6692, 14620, -6723, 14600, -6760, 14665, -6790, 14772, -6813, + 14884, -6839, 15013, -6856, 15148, -6872, 15250, -6887, 15364, -6889, 15428, -6856, 15517, -6884, 15593, -6915, + 15681, -6938, 15803, -6948, 15918, -6960, 15967, -6999, 16081, -7023, 16157, -7058, 16269, -7074, 16384, -7072, + 16492, -7078, 16611, -7076, 16731, -7083, 16843, -7097, 16946, -7121, 17050, -7140, 17121, -7170, 17109, -7209, + 17056, -7244, 17011, -7289, 16976, -7324, 16929, -7366, 16798, -7381, 16739, -7417, 16609, -7438, 16564, -7477, + 16496, -7515, 16423, -7546, 16382, -7587, 16357, -7624, 16347, -7669, 16349, -7707, 16406, -7746, 16427, -7783, + 16474, -7818, 16660, -7832, 16700, -7875, 16519, -7891, 16367, -7912, 16177, -7916, 16092, -7973, 16075, -8020, + 16032, -8057, 15979, -8095, 16112, -8128, 16163, -8169, 16249, -8206, 16371, -8240, 16510, -8271, 16660, -8302, + 16890, -8334, 16940, -8383, 17228, -8404, 17248, -8412, 17322, -8441, 17599, -8416, 17828, -8447, 18000, -8471, + 18000, -9000, -18000, -9000, -18000, -8471, -17994, -8472, -17906, -8414, -17726, -8445, -17714, -8442, -17686, -8433, + -17652, -8423, -17623, -8414, -17608, -8410, -17593, -8410, -17583, -8412, -17438, -8453, -17312, -8412, -17289, -8406, + -16995, -8388, -16900, -8412, -16853, -8424, -16702, -8457, -16418, -8483, -16193, -8514, -15807, -8537, -15519, -8510, + -15094, -8530, -14853, -8561, -14589, -8532, -14311, -8504, -14289, -8457, -14683, -8453, -15006, -8430, -15090, -8390, + -15359, -8369, -15341, -8324, -15304, -8283, -15267, -8245, -15286, -8204, -15453, -8177, -15529, -8142, -15684, -8110, + -15441, -8116, -15210, -8100, -15065, -8134, -14887, -8104, -14722, -8067, -14642, -8034, -14677, -7993, -14806, -7965, + -14953, -7936, -15159, -7930, -15339, -7916, -15533, -7906, -15598, -7869, -15727, -7838, -15805, -7803, -15837, -7689, + -15788, -7699, -15697, -7730, -15533, -7720, -15374, -7707, -15292, -7750, -15133, -7740, -15000, -7718, -14875, -7691, + -14761, -7658, -14610, -7648, -14614, -7611, -14650, -7573, -14620, -7538, -14491, -7520, -14432, -7554, -14279, -7534, + -14164, -7509, -14021, -7507, -13886, -7497, -13751, -7473, -13643, -7452, -13521, -7430, -13443, -7436, -13375, -7444, + -13226, -7430, -13093, -7448, -12955, -7446, -12824, -7432, -12689, -7442, -12540, -7452, -12401, -7448, -12256, -7450, + -12107, -7452, -11970, -7448, -11868, -7419, -11747, -7403, -11622, -7424, -11502, -7407, -11394, -7371, -11330, -7403, + -11295, -7438, -11230, -7471, -11126, -7442, -11007, -7479, -10871, -7491, -10756, -7518, -10615, -7513, -10488, -7495, + -10337, -7499, -10202, -7513, -10065, -7530, -10012, -7487, -10076, -7454, -10125, -7419, -10255, -7411, -10311, -7373, + -10333, -7336, -10368, -7262, -10292, -7275, -10161, -7281, -10031, -7275, -9914, -7291, -9812, -7321, -9769, -7356, + -9634, -7362, -9504, -7348, -9367, -7328, -9244, -7317, -9142, -7340, -9009, -7332, -8923, -7256, -8842, -7301, + -8727, -7319, -8601, -7309, -8519, -7348, -8388, -7352, -8267, -7364, -8147, -7385, -8069, -7348, -8030, -7313, + -7930, -7352, -7793, -7342, -7691, -7364, -7622, -7397, -7489, -7387, -7385, -7366, -7283, -7340, -7162, -7326, + -7021, -7315, -6894, -7301, -6796, -7279, -6737, -7248, -6713, -7205, -6725, -7164, -6756, -7125, -6792, -7085, + -6823, -7046, -6849, -7011, -6854, -6972, -6845, -6933, -6798, -6895, -6758, -6854, -6743, -6815, -6762, -6772, + -6774, -6733, -6725, -6688, -6670, -6658, -6606, -6621, -6537, -6590, -6457, -6560, -6418, -6517, -6363, -6490, + -6300, -6464, -6204, -6458, -6141, -6427, -6071, -6407, -5989, -6396, -5916, -6370, -5859, -6339, -5781, -6327, + -5722, -6353, -5760, -6386, -6775, -5385, -6645, -5445, -6505, -5470, -6550, -5520, -6645, -5525, -6696, -5490, + -6729, -5530, -6815, -5561, -6923, -5550, -6996, -5520, -7101, -5505, -7226, -5450, -7329, -5396, -7466, -5284, + -7384, -5305, -7243, -5372, -7111, -5407, -7059, -5362, -7027, -5293, -6935, -5252, -6863, -5264, -6825, -5310, + -5855, -5110, -5775, -5155, -5805, -5190, -5940, -5220, -5985, -5185, -6070, -5230, -6120, -5185, -6000, -5125, + -5915, -5150, 7028, -4971, 6874, -4978, 6872, -4924, 6887, -4883, 6894, -4862, 6958, -4894, 7053, -4906, + 7056, -4926, 14540, -4079, 14636, -4114, 14691, -4100, 14769, -4081, 14829, -4088, 14836, -4206, 14802, -4241, + 14791, -4321, 14756, -4294, 14687, -4363, 14666, -4358, 14605, -4355, 14543, -4269, 14530, -4203, 14472, -4116, + 14474, -4070, 17302, -4092, 17325, -4133, 17396, -4093, 17425, -4135, 17425, -4177, 17388, -4223, 17322, -4297, + 17271, -4337, 17308, -4385, 17231, -4387, 17145, -4424, 17119, -4490, 17062, -4591, 16983, -4636, 16933, -4664, + 16841, -4662, 16776, -4629, 16668, -4622, 16651, -4585, 16705, -4511, 16830, -4412, 16895, -4394, 16967, -4356, + 17052, -4303, 17113, -4251, 17157, -4177, 17195, -4151, 17210, -4096, 17280, -4049, 17461, -3616, 17534, -3721, + 17536, -3653, 17581, -3680, 17596, -3756, 17676, -3788, 17744, -3796, 17801, -3758, 17852, -3770, 17827, -3858, + 17797, -3917, 17721, -3915, 17694, -3945, 17703, -3988, 17689, -4007, 17651, -4060, 17601, -4129, 17524, -4169, + 17507, -4143, 17465, -4128, 17523, -4046, 17490, -3991, 17382, -3951, 17385, -3915, 17457, -3880, 17474, -3803, + 17470, -3738, 17429, -3671, 17432, -3653, 17384, -3612, 17305, -3524, 17264, -3453, 17301, -3445, 17355, -3501, + 17433, -3527, 16712, -2216, 16674, -2240, 16619, -2213, 16547, -2168, 16483, -2115, 16417, -2044, 16403, -2011, + 16446, -2012, 16502, -2046, 16546, -2080, 16578, -2108, 16660, -2170, 17837, -1734, 17872, -1763, 17855, -1815, + 17793, -1829, 17738, -1816, 17729, -1772, 17767, -1738, 17813, -1750, 17936, -1680, 17873, -1701, 17860, -1664, + 17910, -1643, 17941, -1638, 18000, -1607, 18000, -1656, -17992, -1650, -18000, -1656, -18000, -1607, -17979, -1602, + 16784, -1647, 16752, -1660, 16718, -1616, 16722, -1589, 16711, -1493, 16727, -1574, 16700, -1561, 16679, -1567, + 16665, -1539, 16663, -1463, 5006, -1356, 5022, -1476, 5048, -1523, 5038, -1571, 5020, -1600, 4986, -1541, + 4967, -1571, 4986, -1645, 4977, -1688, 4950, -1711, 4944, -1795, 4904, -1912, 4855, -2050, 4793, -2239, + 4755, -2378, 4710, -2494, 4628, -2518, 4541, -2560, 4483, -2535, 4404, -2499, 4376, -2446, 4370, -2357, + 4335, -2278, 4325, -2206, 4343, -2134, 4389, -2116, 4390, -2083, 4437, -2007, 4446, -1944, 4423, -1896, + 4404, -1833, 4396, -1741, 4431, -1685, 4445, -1622, 4494, -1618, 4550, -1597, 4587, -1579, 4631, -1578, + 4688, -1521, 4771, -1459, 4801, -1409, 4787, -1366, 4829, -1378, 4885, -1309, 4886, -1249, 4919, -1204, + 4954, -1247, 4981, -1290, 14356, -1376, 14392, -1455, 14456, -1417, 14489, -1459, 14537, -1498, 14527, -1543, + 14549, -1629, 14564, -1678, 14589, -1691, 14616, -1776, 14606, -1828, 14639, -1896, 14747, -1948, 14818, -1996, + 14885, -2039, 14872, -2063, 14929, -2126, 14968, -2234, 15008, -2212, 15048, -2256, 15073, -2240, 15090, -2346, + 15161, -2408, 15207, -2446, 15286, -2527, 15314, -2607, 15316, -2664, 15309, -2726, 15357, -2811, 15351, -2900, + 15334, -2946, 15307, -3035, 15309, -3092, 15289, -3164, 15245, -3255, 15171, -3304, 15134, -3382, 15101, -3431, + 15071, -3517, 15033, -3567, 15008, -3642, 14995, -3711, 15000, -3743, 14942, -3777, 14830, -3781, 14738, -3822, + 14692, -3861, 14632, -3904, 14549, -3859, 14488, -3842, 14503, -3790, 14449, -3809, 14361, -3881, 14275, -3854, + 14218, -3838, 14161, -3831, 14064, -3802, 13999, -3740, 13981, -3664, 13957, -3614, 13908, -3573, 13812, -3561, + 13845, -3513, 13821, -3438, 13772, -3508, 13683, -3526, 13735, -3471, 13750, -3413, 13789, -3364, 13781, -3290, + 13700, -3375, 13637, -3409, 13599, -3489, 13521, -3448, 13524, -3395, 13461, -3322, 13409, -3285, 13427, -3262, + 13299, -3201, 13229, -3198, 13133, -3150, 12954, -3159, 12824, -3195, 12710, -3228, 12615, -3222, 12509, -3273, + 12422, -3296, 12403, -3348, 12366, -3389, 12281, -3391, 12218, -3400, 12130, -3382, 12058, -3393, 11989, -3398, + 11930, -3451, 11901, -3446, 11851, -3475, 11802, -3506, 11730, -3503, 11663, -3503, 11556, -3439, 11503, -3420, + 11505, -3362, 11555, -3349, 11571, -3326, 11568, -3290, 11580, -3221, 11569, -3161, 11516, -3060, 11500, -3003, + 11504, -2946, 11464, -2881, 11462, -2852, 11417, -2812, 11405, -2733, 11348, -2654, 11334, -2612, 11378, -2655, + 11344, -2562, 11394, -2591, 11423, -2630, 11422, -2579, 11372, -2500, 11363, -2468, 11339, -2438, 11350, -2381, + 11371, -2356, 11384, -2306, 11374, -2248, 11415, -2176, 11423, -2252, 11465, -2183, 11546, -2150, 11595, -2107, + 11671, -2070, 11717, -2062, 11744, -2075, 11823, -2037, 11884, -2026, 11899, -2004, 11925, -1995, 11981, -1998, + 12086, -1968, 12140, -1924, 12166, -1871, 12224, -1820, 12229, -1780, 12231, -1725, 12301, -1641, 12343, -1727, + 12386, -1707, 12350, -1660, 12382, -1611, 12426, -1633, 12438, -1557, 12493, -1508, 12517, -1468, 12567, -1451, + 12569, -1423, 12613, -1435, 12614, -1410, 12658, -1395, 12707, -1382, 12780, -1428, 12836, -1487, 12899, -1488, + 12962, -1497, 12941, -1442, 12989, -1362, 13034, -1336, 13018, -1311, 13062, -1254, 13122, -1218, 13174, -1230, + 13258, -1211, 13256, -1160, 13182, -1127, 13236, -1113, 13302, -1138, 13355, -1179, 13439, -1204, 13468, -1194, + 13530, -1225, 13588, -1196, 13626, -1205, 13649, -1186, 13695, -1235, 13669, -1289, 13631, -1329, 13596, -1332, + 13608, -1372, 13578, -1422, 13543, -1472, 13550, -1500, 13630, -1555, 13707, -1587, 13758, -1622, 13830, -1681, + 13859, -1681, 13911, -1706, 13926, -1737, 14022, -1771, 14088, -1737, 14107, -1683, 14127, -1639, 14140, -1584, + 14170, -1504, 14156, -1456, 14164, -1427, 14152, -1370, 14165, -1294, 14184, -1274, 14169, -1241, 14193, -1188, + 14212, -1133, 14214, -1104, 14252, -1067, 14280, -1116, 14287, -1178, 14312, -1191, 14316, -1233, 14352, -1283, + 14360, -1340, 16212, -1048, 16240, -1083, 16170, -1082, 16132, -1020, 16192, -1045, 12072, -1024, 12030, -1026, + 11897, -956, 11990, -936, 12043, -967, 12078, -997, 16085, -987, 16046, -990, 15985, -979, 15964, -964, + 15970, -924, 16036, -940, 16069, -961, 16168, -960, 16153, -978, 16079, -892, 16058, -832, 16092, -832, + 16128, -912, 12444, -1014, 12358, -1036, 12346, -1024, 12355, -990, 12398, -929, 12497, -889, 12509, -866, + 12595, -843, 12664, -840, 12696, -827, 12734, -840, 12697, -867, 12593, -911, 12509, -939, 11790, -810, + 11826, -836, 11888, -828, 11913, -871, 11797, -891, 11728, -904, 11674, -903, 11708, -846, 11763, -845, + 12290, -809, 12276, -865, 12125, -893, 11992, -881, 11992, -844, 12072, -824, 12134, -854, 12201, -846, + 15988, -834, 15992, -854, 15913, -811, 15859, -775, 15821, -742, 15836, -732, 15882, -756, 15964, -802, + 15754, -735, 15734, -740, 15690, -718, 15649, -677, 15654, -660, 15714, -702, 10862, -678, 11054, -688, + 11076, -647, 11261, -695, 11298, -759, 11448, -778, 11571, -837, 11456, -875, 11346, -835, 11256, -838, + 11152, -830, 11059, -812, 10943, -774, 10869, -764, 10828, -777, 10645, -735, 10628, -692, 10537, -685, + 10605, -590, 10727, -595, 10807, -635, 10849, -642, 13472, -621, 13421, -690, 13411, -614, 13429, -578, + 13450, -545, 13473, -574, 15588, -682, 15560, -692, 15517, -654, 15473, -590, 15451, -514, 15465, -504, + 15476, -534, 15506, -557, 15555, -620, 15602, -654, 15198, -548, 15146, -556, 15130, -584, 15075, -608, + 15024, -632, 14971, -632, 14889, -603, 14832, -575, 14840, -544, 14930, -558, 14985, -551, 15000, -503, + 15014, -500, 15024, -553, 15081, -546, 15109, -511, 15165, -476, 15154, -417, 15214, -415, 15234, -431, + 15232, -487, 12725, -346, 12687, -379, 12618, -361, 12599, -318, 12700, -313, 13047, -309, 13083, -386, + 12999, -345, 12916, -336, 12859, -343, 12790, -339, 12814, -284, 12937, -280, 15314, -450, 15283, -477, + 15264, -418, 15241, -379, 15195, -346, 15138, -304, 15066, -274, 15094, -250, 15148, -278, 15182, -300, + 15224, -324, 15264, -366, 15302, -398, 13414, -115, 13442, -277, 13546, -337, 13629, -231, 13744, -170, + 13833, -170, 13918, -205, 13993, -241, 14100, -260, 14274, -329, 14458, -386, 14527, -437, 14583, -488, + 14598, -547, 14765, -608, 14789, -661, 14697, -672, 14719, -739, 14808, -804, 14873, -910, 14931, -907, + 14927, -951, 15004, -968, 14974, -987, 15080, -1029, 15069, -1058, 15003, -1065, 14978, -1039, 14892, -1028, + 14791, -1013, 14714, -949, 14657, -894, 14605, -807, 14474, -763, 14390, -792, 14329, -825, 14341, -898, + 14263, -933, 14207, -916, 14103, -912, 14014, -830, 13913, -810, 13888, -838, 13761, -841, 13804, -760, + 13867, -732, 13841, -623, 13793, -539, 13599, -455, 13516, -446, 13366, -354, 13337, -402, 13298, -411, + 13276, -375, 13275, -331, 13199, -282, 13307, -246, 13378, -248, 13370, -221, 13223, -221, 13184, -162, + 13094, -143, 13052, -94, 13187, -70, 13238, -37, 13399, -78, 12524, 142, 12444, 43, 12369, 24, + 12272, 43, 12106, 38, 12018, 24, 12004, -52, 12094, -141, 12148, -96, 12334, -62, 12326, -108, + 12282, -93, 12239, -152, 12151, -190, 12245, -319, 12227, -353, 12317, -468, 12316, -534, 12263, -563, + 12224, -528, 12272, -446, 12174, -485, 12149, -457, 12162, -419, 12090, -360, 12097, -263, 12031, -293, + 12039, -410, 12043, -553, 11980, -567, 11937, -538, 11965, -446, 11950, -349, 11908, -349, 11877, -280, + 11918, -215, 11932, -135, 11983, 15, 12004, 57, 12089, 131, 12167, 101, 12293, 88, 12408, 92, + 12507, 164, 12869, 113, 12864, 26, 12812, 36, 12797, -25, 12838, -78, 12810, -90, 12770, -27, + 12740, 101, 12760, 181, 12793, 217, 12800, 163, 12859, 154, 10582, -585, 10471, -587, 10387, -504, + 10258, -422, 10216, -361, 10140, -280, 10090, -205, 10014, -65, 9926, 18, 9897, 104, 9860, 182, + 9770, 245, 9718, 331, 9642, 387, 9538, 497, 9529, 548, 9594, 544, 9748, 525, 9837, 427, + 9914, 359, 9969, 317, 10064, 210, 10166, 208, 10250, 140, 10308, 56, 10384, 10, 10344, -71, + 10401, -106, 10437, -108, 10454, -178, 10489, -234, 10562, -243, 10611, -306, 10586, -431, 11788, 183, + 11900, 90, 11781, 78, 11748, 10, 11752, -80, 11656, -149, 11653, -248, 11615, -401, 11600, -366, + 11486, -411, 11447, -350, 11376, -344, 11326, -312, 11207, -348, 11170, -299, 11105, -305, 11022, -293, + 11007, -159, 10957, -131, 10909, -46, 10895, 42, 10907, 134, 10966, 201, 11040, 166, 11117, 185, + 11137, 270, 11180, 289, 11300, 310, 11371, 389, 11420, 453, 11460, 490, 11545, 545, 11622, 614, + 11673, 692, 11713, 693, 11764, 642, 11769, 599, 11835, 571, 11918, 541, 11911, 502, 11844, 497, + 11862, 448, 11788, 414, 11731, 323, 11805, 229, 12638, 841, 12648, 775, 12654, 719, 12620, 627, + 12583, 729, 12536, 679, 12568, 605, 12540, 558, 12422, 616, 12394, 689, 12424, 736, 12361, 783, + 12330, 742, 12283, 746, 12209, 690, 12192, 719, 12231, 803, 12294, 832, 12349, 869, 12384, 824, + 12460, 851, 12476, 896, 12547, 899, 12541, 976, 12622, 929, 12631, 878, 8122, 620, 8035, 597, + 7987, 676, 7970, 820, 8015, 982, 8084, 927, 8130, 856, 8179, 752, 8164, 648, -6094, 1011, + -6177, 1000, -6195, 1009, -6166, 1036, -6168, 1076, -6110, 1089, -6090, 1086, 12398, 1028, 12362, 995, + 12331, 932, 12300, 902, 12238, 971, 12259, 998, 12284, 1026, 12295, 1088, 12350, 1094, 12334, 1027, + 12408, 1123, 11850, 932, 11717, 837, 11766, 907, 11839, 968, 11899, 1038, 11951, 1137, 11969, 1055, + 11903, 1000, 12188, 1189, 12248, 1158, 12312, 1158, 12310, 1117, 12264, 1074, 12200, 1044, 12197, 1091, + 12204, 1142, 12550, 1216, 12578, 1105, 12501, 1131, 12503, 1098, 12528, 1036, 12480, 1013, 12476, 1084, + 12446, 1089, 12430, 1150, 12489, 1142, 12488, 1179, 12427, 1256, 12523, 1254, 12153, 1307, 12126, 1221, + 12083, 1270, 12032, 1347, 12118, 1343, 12132, 1850, 12194, 1822, 12225, 1848, 12234, 1822, 12217, 1781, + 12252, 1709, 12225, 1626, 12166, 1593, 12151, 1512, 12173, 1433, 12226, 1422, 12270, 1434, 12395, 1378, + 12386, 1324, 12418, 1300, 12408, 1254, 12330, 1303, 12293, 1355, 12267, 1319, 12203, 1378, 12113, 1364, + 12063, 1386, 12068, 1427, 12099, 1453, 12069, 1476, 12056, 1440, 12007, 1497, 11992, 1541, 11988, 1636, + 12029, 1603, 12039, 1760, 12072, 1851, -6559, 1823, -6585, 1798, -6660, 1798, -6718, 1795, -6724, 1837, + -6710, 1852, -6628, 1851, -6577, 1843, -7690, 1787, -7721, 1770, -7777, 1786, -7834, 1823, -7822, 1845, + -7780, 1852, -7757, 1849, -7690, 1840, -7637, 1816, -7620, 1789, -7258, 1987, -7171, 1971, -7159, 1988, + -7081, 1988, -7021, 1962, -6995, 1965, -6977, 1929, -6922, 1931, -6925, 1902, -6881, 1898, -6832, 1861, + -6869, 1821, -6916, 1842, -6962, 1838, -6995, 1843, -7013, 1825, -7052, 1818, -7067, 1843, -7100, 1828, + -7140, 1760, -7166, 1776, -7171, 1804, -7237, 1821, -7284, 1815, -7345, 1822, -7392, 1803, -7446, 1834, + -7437, 1866, -7345, 1853, -7269, 1845, -7233, 1867, -7279, 1910, -7278, 1948, -7342, 1964, -7319, 1992, + 11034, 1868, 10948, 1820, 10866, 1851, 10863, 1937, 10912, 1982, 11021, 2010, 11079, 2008, 11101, 1970, + 11057, 1926, -15554, 1908, -15569, 1892, -15594, 1906, -15591, 1934, -15607, 1970, -15602, 1981, -15585, 1998, + -15592, 2017, -15586, 2027, -15579, 2025, -15540, 2008, -15522, 1999, -15506, 1986, -15481, 1951, -15483, 1945, + -15522, 1924, -15608, 2064, -15641, 2057, -15659, 2078, -15670, 2086, -15671, 2093, -15661, 2101, -15626, 2092, + -15600, 2076, -15676, 2118, -15679, 2107, -15733, 2110, -15725, 2122, -15765, 2132, -15771, 2126, -15778, 2128, + -15813, 2131, -15825, 2154, -15829, 2158, -15803, 2172, -15794, 2165, -15935, 2198, -15946, 2188, -15980, 2207, + -15975, 2214, -15960, 2224, -15937, 2221, -7968, 2277, -7928, 2240, -7835, 2251, -7799, 2228, -7715, 2166, + -7652, 2121, -7619, 2122, -7560, 2102, -7567, 2074, -7493, 2069, -7418, 2028, -7430, 2005, -7496, 1992, + -7563, 1987, -7632, 1995, -7776, 1986, -7709, 2041, -7749, 2067, -7814, 2074, -7848, 2103, -7872, 2160, + -7928, 2156, -8022, 2183, -8052, 2204, -8182, 2219, -8217, 2239, -8180, 2264, -8278, 2269, -8349, 2217, + -8391, 2215, -8405, 2191, -8455, 2180, -8497, 2190, -8445, 2220, -8423, 2257, -8378, 2279, -8327, 2298, + -8251, 2308, -8227, 2319, -8140, 2312, -8062, 2311, -7753, 2376, -7778, 2371, -7803, 2429, -7841, 2458, + -7819, 2521, -7789, 2517, -7754, 2434, 12118, 2279, 12075, 2197, 12022, 2281, 12011, 2356, 12069, 2454, + 12150, 2530, 12195, 2500, 12178, 2439, -7782, 2658, -7891, 2642, -7898, 2679, -7851, 2687, -7785, 2684, + -7700, 2659, -7717, 2588, -7736, 2601, -7734, 2653, -7779, 2693, -7779, 2704, 13464, 3415, 13477, 3381, + 13420, 3320, 13379, 3352, 13328, 3329, 13301, 3270, 13236, 3299, 13237, 3346, 13292, 3406, 13349, 3394, + 13390, 3436, 3458, 3567, 3390, 3525, 3397, 3506, 3400, 3498, 3298, 3457, 3249, 3470, 3226, 3510, + 3273, 3514, 3280, 3515, 3295, 3539, 3367, 3537, 2370, 3571, 2425, 3537, 2503, 3542, 2577, 3535, + 2575, 3518, 2629, 3530, 2616, 3500, 2472, 3492, 2474, 3508, 2351, 3528, 1552, 3823, 1516, 3744, + 1531, 3713, 1510, 3662, 1434, 3700, 1383, 3710, 1243, 3761, 1257, 3813, 1374, 3803, 1476, 3814, + 921, 4121, 981, 4050, 967, 3918, 921, 3924, 881, 3891, 843, 3917, 839, 4038, 816, 4095, + 871, 4090, 14098, 3714, 14060, 3634, 14077, 3584, 14025, 3514, 13898, 3467, 13722, 3461, 13579, 3346, + 13512, 3385, 13508, 3460, 13334, 3438, 13216, 3390, 13099, 3389, 13200, 3315, 13133, 3145, 13069, 3103, + 13020, 3142, 13045, 3232, 12981, 3261, 12941, 3330, 13035, 3360, 13088, 3423, 13188, 3475, 13262, 3543, + 13461, 3573, 13568, 3553, 13672, 3730, 13739, 3683, 13886, 3783, 13943, 3822, 14005, 3944, 13988, 4056, + 14031, 4120, 14137, 4138, 14191, 3999, 14188, 3918, 14096, 3817, 956, 4215, 923, 4138, 878, 4158, + 854, 4226, 875, 4263, 939, 4301, 14391, 4417, 14461, 4396, 14532, 4438, 14554, 4326, 14406, 4299, + 14318, 4200, 14161, 4268, 14107, 4158, 13996, 4157, 13982, 4256, 14031, 4333, 14138, 4339, 14167, 4477, + 14197, 4555, 14314, 4451, -6366, 4655, -6294, 4642, -6201, 4644, -6250, 4603, -6287, 4597, -6414, 4639, + -6439, 4673, -6401, 4704, -6181, 4911, -6229, 4909, -6359, 4940, -6452, 4987, -6417, 4996, -6286, 4971, + -6184, 4929, -12351, 4851, -12401, 4837, -12566, 4883, -12595, 4918, -12685, 4953, -12703, 4981, -12806, 4999, + -12844, 5054, -12836, 5077, -12731, 5055, -12670, 5040, -12576, 5030, -12542, 4995, -12492, 4948, -12392, 4906, + -5613, 5069, -5680, 4981, -5614, 5015, -5547, 4994, -5582, 4959, -5494, 4931, -5447, 4956, -5348, 4925, + -5379, 4852, -5309, 4869, -5296, 4816, -5265, 4754, -5307, 4666, -5352, 4662, -5418, 4681, -5396, 4763, + -5424, 4775, -5540, 4688, -5600, 4692, -5529, 4739, -5625, 4763, -5733, 4757, -5927, 4760, -5942, 4790, + -5880, 4825, -5923, 4852, -5839, 4913, -5736, 5072, -5674, 5129, -5587, 5163, -5541, 5159, -5560, 5132, + -13271, 5404, -13175, 5412, -13205, 5298, -13118, 5218, -13158, 5218, -13218, 5264, -13255, 5310, -13305, 5341, + -13324, 5385, -13318, 5417, 14365, 5075, 14465, 4898, 14317, 4931, 14256, 4786, 14353, 4684, 14351, 4614, + 14275, 4674, 14209, 4597, 14191, 4681, 14202, 4778, 14190, 4886, 14214, 4962, 14218, 5095, 14159, 5194, + 14168, 5330, 14261, 5376, 14221, 5423, 14265, 5437, 14291, 5370, 14326, 5274, 14324, 5176, -679, 5226, + -856, 5167, -998, 5182, -917, 5286, -969, 5388, -833, 5466, -757, 5513, -673, 5517, -566, 5455, + -620, 5387, -603, 5315, 1269, 5561, 1209, 5480, 1104, 5536, 1090, 5578, 1237, 5611, -15301, 5712, + -15401, 5673, -15452, 5699, -15467, 5746, -15376, 5782, -15323, 5797, -15256, 5790, -15214, 5759, -301, 5864, + -407, 5755, -306, 5769, -196, 5768, -222, 5687, -312, 5597, -209, 5591, -111, 5462, -43, 5446, + 18, 5333, 47, 5293, 168, 5274, 156, 5210, 105, 5181, 145, 5129, 55, 5077, -79, 5077, + -249, 5050, -296, 5070, -362, 5023, -454, 5034, -525, 4996, -578, 5016, -431, 5121, -341, 5143, + -498, 5159, -527, 5199, -422, 5230, -477, 5284, -458, 5350, -309, 5340, -295, 5398, -363, 5462, + -484, 5479, -508, 5506, -472, 5551, -505, 5578, -559, 5531, -564, 5628, -615, 5679, -579, 5782, + -501, 5863, -421, 5855, -16558, 5991, -16619, 5975, -16685, 5994, -16746, 6021, -16647, 6038, -16567, 6029, + -7927, 6216, -7966, 6163, -8010, 6172, -8036, 6202, -8032, 6209, -7993, 6239, -7952, 6236, -8190, 6271, + -8307, 6216, -8377, 6218, -8399, 6245, -8325, 6291, -8188, 6290, -17173, 6378, -17111, 6359, -17049, 6369, + -16968, 6343, -16869, 6330, -16877, 6319, -16953, 6298, -17029, 6319, -17067, 6338, -17155, 6332, -17179, 6341, + -8516, 6566, -8498, 6522, -8446, 6537, -8388, 6511, -8279, 6477, -8164, 6446, -8155, 6398, -8082, 6406, + -8010, 6373, -8099, 6341, -8255, 6365, -8311, 6410, -8410, 6357, -8552, 6305, -8587, 6364, -8722, 6354, + -8635, 6404, -8622, 6482, -8588, 6574, -1451, 6646, -1474, 6581, -1361, 6513, -1491, 6436, -1779, 6368, + -1866, 6350, -1997, 6364, -2276, 6396, -2178, 6440, -2396, 6489, -2218, 6508, -2223, 6538, -2433, 6561, + -2365, 6626, -2213, 6641, -2058, 6573, -1906, 6628, -1780, 6599, -1617, 6653, -7587, 6715, -7699, 6710, + -7724, 6759, -7681, 6815, -7590, 6829, -7511, 6801, -7510, 6758, -7522, 6744, -17501, 6658, -17434, 6634, + -17457, 6706, -17186, 6691, -16990, 6598, -17089, 6554, -17253, 6544, -17256, 6446, -17296, 6425, -17389, 6428, + -17465, 6463, -17598, 6492, -17621, 6536, -17722, 6552, -17836, 6539, -17890, 6574, -17869, 6611, -17988, 6587, + -17943, 6540, -18000, 6498, -18000, 6896, -17755, 6820, -17493, 6721, -9565, 6911, -9627, 6876, -9762, 6906, + -9843, 6895, -9980, 6940, -9892, 6971, -9822, 7014, -9716, 6986, -9656, 6968, -9626, 6949, 18000, 7083, + 17890, 7078, 17873, 7110, 18000, 7152, -17869, 7089, -18000, 7083, -18000, 7152, -17987, 7156, -17902, 7156, + -17758, 7127, -17766, 7113, -9055, 6950, -9055, 6848, -8922, 6926, -8802, 6862, -8832, 6787, -8735, 6720, + -8631, 6792, -8558, 6878, -8552, 6988, -8410, 6981, -8262, 6966, -8128, 6916, -8122, 6867, -8196, 6813, + -8126, 6760, -8139, 6711, -8334, 6641, -8474, 6626, -8577, 6656, -8607, 6606, -8703, 6521, -8732, 6478, + -8848, 6410, -8991, 6403, -9070, 6361, -9077, 6296, -9193, 6284, -9316, 6202, -9424, 6090, -9463, 6011, + -9468, 5895, -9322, 5878, -9276, 5785, -9230, 5709, -9090, 5728, -8904, 5685, -8804, 5647, -8732, 5600, + -8607, 5572, -8501, 5530, -8336, 5524, -8227, 5515, -8244, 5428, -8212, 5328, -8140, 5216, -7991, 5121, + -7914, 5153, -7860, 5256, -7912, 5414, -7983, 5467, -7823, 5514, -7710, 5584, -7654, 5653, -7662, 5720, + -7730, 5805, -7852, 5880, -7734, 5985, -7777, 6076, -7811, 6232, -7741, 6255, -7570, 6228, -7467, 6218, + -7384, 6244, -7291, 6211, -7168, 6153, -7137, 6114, -6959, 6106, -6962, 6022, -6929, 5896, -6837, 5880, + -6765, 5821, -6620, 5877, -6525, 5987, -6458, 6034, -6380, 5944, -6250, 5817, -6140, 5697, -6180, 5634, + -6047, 5578, -5957, 5520, -5798, 5495, -5733, 5463, -5694, 5378, -5616, 5365, -5576, 5327, -5568, 5215, + -5641, 5177, -5713, 5142, -5877, 5106, -6003, 5024, -6172, 5008, -6386, 5029, -6536, 5030, -6640, 5023, + -6724, 4951, -6851, 4907, -6995, 4774, -7110, 4682, -7026, 4699, -6865, 4830, -6655, 4913, -6506, 4923, + -6417, 4874, -6512, 4807, -6480, 4699, -6447, 4624, -6317, 4574, -6152, 4588, -6052, 4701, -6045, 4628, + -5980, 4592, -6104, 4527, -6325, 4467, -6425, 4427, -6536, 4355, -6612, 4362, -6616, 4447, -6443, 4529, + -6603, 4526, -6714, 4514, -6696, 4481, -6803, 4433, -6906, 4398, -7012, 4368, -7069, 4303, -7081, 4287, + -7083, 4234, -7049, 4181, -7008, 4178, -7018, 4215, -6988, 4192, -6997, 4164, -7064, 4148, -7112, 4149, + -7186, 4132, -7229, 4127, -7288, 4122, -7371, 4093, -7224, 4112, -7195, 4093, -7334, 4063, -7398, 4063, + -7395, 4075, -7426, 4047, -7396, 4043, -7418, 3971, -7491, 3894, -7498, 3920, -7520, 3925, -7553, 3950, + -7532, 3896, -7508, 3878, -7506, 3840, -7538, 3802, -7594, 3722, -7603, 3726, -7572, 3794, -7623, 3832, + -7635, 3915, -7654, 3872, -7633, 3808, -7696, 3823, -7630, 3792, -7626, 3697, -7597, 3690, -7587, 3655, + -7573, 3555, -7636, 3481, -7740, 3451, -7805, 3393, -7855, 3386, -7906, 3349, -7920, 3316, -8030, 3251, + -8086, 3203, -8134, 3144, -8149, 3073, -8131, 3004, -8098, 2918, -8054, 2847, -8053, 2804, -8006, 2688, + -8009, 2621, -8013, 2582, -8038, 2521, -8068, 2508, -8117, 2520, -8133, 2564, -8171, 2587, -8224, 2673, + -8271, 2750, -8286, 2789, -8265, 2855, -8293, 2910, -8371, 2994, -8410, 3009, -8511, 2964, -8529, 2969, + -8577, 3015, -8640, 3040, -8753, 3027, -8842, 3038, -8918, 3032, -8960, 3018, -8941, 2989, -8943, 2949, + -8922, 2929, -8941, 2916, -8978, 2931, -9015, 2912, -9088, 2915, -9163, 2968, -9250, 2955, -9323, 2978, + -9385, 2971, -9469, 2948, -9560, 2874, -9659, 2831, -9714, 2783, -9737, 2738, -9738, 2669, -9733, 2621, + -9714, 2587, -9753, 2499, -9770, 2427, -9778, 2293, -9787, 2244, -9770, 2190, -9739, 2141, -9719, 2064, + -9653, 1989, -9629, 1932, -9590, 1883, -9484, 1856, -9443, 1814, -9355, 1842, -9279, 1852, -9204, 1870, + -9141, 1888, -9077, 1928, -9053, 1987, -9045, 2071, -9028, 2100, -8960, 2126, -8854, 2149, -8766, 2146, + -8705, 2154, -8681, 2133, -8685, 2085, -8738, 2026, -8762, 1965, -8744, 1947, -8759, 1904, -8784, 1826, + -8809, 1852, -8830, 1850, -8830, 1835, -8811, 1835, -8812, 1808, -8829, 1764, -8820, 1749, -8830, 1713, + -8824, 1704, -8836, 1653, -8855, 1627, -8873, 1623, -8893, 1589, -8860, 1571, -8852, 1586, -8822, 1573, + -8812, 1569, -8790, 1586, -8762, 1588, -8752, 1580, -8737, 1585, -8690, 1576, -8644, 1578, -8612, 1589, + -8600, 1601, -8568, 1595, -8544, 1589, -8518, 1591, -8498, 1600, -8453, 1586, -8437, 1584, -8406, 1565, + -8377, 1542, -8341, 1527, -8315, 1500, -8323, 1490, -8328, 1468, -8318, 1431, -8341, 1397, -8352, 1357, + -8355, 1313, -8350, 1287, -8347, 1242, -8363, 1232, -8372, 1189, -8365, 1163, -8386, 1137, -8381, 1110, + -8366, 1094, -8340, 1040, -8302, 999, -8255, 957, -8219, 921, -8221, 900, -8181, 895, -8171, 903, + -8144, 879, -8095, 886, -8052, 911, -7991, 931, -7957, 961, -7902, 955, -7906, 945, -7850, 942, + -7806, 925, -7773, 895, -7735, 867, -7684, 864, -7609, 934, -7567, 944, -7566, 977, -7548, 1062, + -7491, 1108, -7428, 1110, -7420, 1131, -7341, 1123, -7263, 1173, -7224, 1196, -7175, 1244, -7140, 1238, + -7114, 1211, -7133, 1178, -7136, 1154, -7195, 1142, -7162, 1097, -7163, 1045, -7207, 987, -7170, 907, + -7126, 914, -7104, 986, -7135, 1021, -7140, 1097, -7016, 1138, -7029, 1185, -6994, 1216, -6958, 1146, + -6888, 1144, -6823, 1089, -6819, 1055, -6730, 1055, -6623, 1065, -6566, 1020, -6489, 1008, -6433, 1039, + -6432, 1064, -6308, 1070, -6188, 1072, -6273, 1042, -6239, 995, -6159, 987, -6083, 938, -6067, 858, + -6015, 860, -5976, 837, -5910, 800, -5848, 735, -5845, 683, -5808, 681, -5754, 632, -5715, 597, + -5595, 577, -5584, 595, -5503, 603, -5396, 576, -5362, 565, -5288, 541, -5182, 457, -5166, 416, + -5132, 420, -5107, 365, -5051, 190, -4997, 174, -4995, 105, -5070, 22, -5039, -8, -4862, -24, + -4858, -124, -4782, -58, -4657, -94, -4491, -155, -4442, -214, -4458, -269, -4342, -238, -4147, -291, + -3998, -287, -3850, -370, -3722, -482, -3645, -511, -3560, -515, -3524, -546, -3490, -674, -3473, -734, + -3513, -900, -3564, -965, -3705, -1104, -3768, -1217, -3842, -1304, -3867, -1306, -3895, -1379, -3888, -1567, + -3916, -1721, -3927, -1787, -3958, -1826, -3976, -1960, -4077, -2090, -4094, -2194, -4175, -2237, -4199, -2297, + -4307, -2297, -4465, -2335, -4535, -2380, -4647, -2409, -4765, -2489, -4850, -2588, -4864, -2662, -4847, -2718, + -4866, -2819, -4889, -2867, -4959, -2922, -5070, -3098, -5158, -3178, -5226, -3225, -5271, -3320, -5337, -3377, + -5381, -3440, -5494, -3495, -5567, -3475, -5622, -3486, -5714, -3443, -5782, -3446, -5843, -3391, -5850, -3443, + -5723, -3529, -5736, -3598, -5674, -3641, -5679, -3690, -5775, -3818, -5923, -3872, -6124, -3893, -6234, -3883, + -6213, -3942, -6233, -4017, -6215, -4068, -6275, -4103, -6377, -4117, -6473, -4080, -6512, -4106, -6498, -4206, + -6430, -4236, -6376, -4204, -6346, -4256, -6438, -4287, -6518, -4350, -6533, -4450, -6557, -4504, -6651, -4504, + -6729, -4555, -6758, -4630, -6660, -4703, -6564, -4724, -6599, -4813, -6717, -4870, -6782, -4987, -6873, -5026, + -6914, -5073, -6882, -5177, -6815, -5235, -6857, -5230, -6946, -5229, -6994, -5254, -7085, -5290, -7101, -5383, + -7143, -5386, -7256, -5353, -7370, -5284, -7495, -5226, -7526, -5163, -7498, -5104, -7548, -5038, -7561, -4867, + -7518, -4771, -7413, -4694, -7564, -4665, -7469, -4576, -7435, -4410, -7324, -4445, -7272, -4238, -7339, -4212, + -7370, -4337, -7433, -4322, -7402, -4179, -7368, -3994, -7322, -3926, -7351, -3828, -7359, -3716, -7317, -3712, + -7255, -3551, -7186, -3391, -7144, -3242, -7167, -3092, -7137, -3010, -7149, -2886, -7091, -2764, -7072, -2571, + -7040, -2363, -7009, -2139, -7016, -1976, -7037, -1835, -7138, -1777, -7146, -1736, -7344, -1636, -7524, -1527, + -7601, -1465, -7642, -1382, -7626, -1353, -7711, -1222, -7809, -1038, -7904, -839, -7945, -793, -7976, -719, + -8054, -654, -8125, -614, -8093, -569, -8141, -474, -8110, -404, -8030, -340, -7977, -266, -7999, -222, + -8037, -269, -8097, -225, -8076, -197, -8093, -106, -8058, -91, -8040, -28, -8002, 36, -8009, 77, + -7954, 98, -7886, 138, -7899, 169, -7862, 177, -7866, 227, -7843, 263, -7793, 270, -7751, 333, + -7713, 385, -7750, 409, -7731, 467, -7753, 558, -7732, 585, -7748, 669, -7788, 722, -7821, 751, + -7843, 805, -7818, 832, -7844, 839, -7862, 872, -7912, 900, -7956, 893, -7976, 858, -8016, 833, + -8038, 830, -8048, 809, -8000, 755, -8028, 742, -8042, 727, -8089, 722, -8106, 782, -8119, 765, + -8152, 771, -8172, 811, -8213, 818, -8239, 829, -8282, 829, -8285, 807, -8297, 823, -8351, 845, + -8371, 866, -8360, 883, -8363, 905, -8391, 929, -8430, 949, -8465, 962, -8471, 991, -8498, 1009, + -8491, 980, -8511, 956, -8534, 983, -8566, 993, -8580, 1013, -8579, 1044, -8566, 1075, -8594, 1090, + -8571, 1109, -8606, 1140, -8653, 1181, -8675, 1214, -8717, 1246, -8767, 1291, -8756, 1306, -8739, 1291, + -8732, 1298, -8749, 1330, -8779, 1338, -8790, 1315, -8848, 1316, -8884, 1326, -8926, 1346, -8981, 1352, + -9010, 1374, -9061, 1391, -9123, 1393, -9169, 1413, -9223, 1454, -9336, 1562, -9388, 1594, -9469, 1620, + -9525, 1613, -9605, 1575, -9656, 1565, -9726, 1592, -9801, 1611, -9895, 1657, -9970, 1671, -10083, 1717, + -10167, 1765, -10192, 1792, -10248, 1798, -10350, 1829, -10392, 1875, -10499, 1932, -10549, 1995, -10573, 2043, + -10540, 2053, -10550, 2082, -10527, 2108, -10527, 2142, -10560, 2187, -10569, 2227, -10603, 2277, -10691, 2377, + -10792, 2455, -10840, 2517, -10926, 2558, -10944, 2582, -10929, 2644, -10980, 2668, -11039, 2716, -11064, 2786, + -11118, 2794, -11176, 2847, -11223, 2895, -11227, 2927, -11281, 3002, -11316, 3079, -11315, 3117, -11387, 3157, + -11421, 3152, -11478, 3180, -11494, 3139, -11477, 3091, -11467, 3016, -11433, 2975, -11359, 2906, -11342, 2883, + -11327, 2875, -11314, 2841, -11296, 2843, -11276, 2778, -11246, 2753, -11224, 2717, -11162, 2666, -11128, 2573, + -11099, 2529, -11071, 2483, -11066, 2430, -11017, 2427, -10977, 2381, -10941, 2336, -10943, 2319, -10985, 2282, + -11003, 2282, -11029, 2343, -11095, 2400, -11167, 2448, -11218, 2474, -11215, 2547, -11230, 2601, -11278, 2632, + -11346, 2677, -11360, 2664, -11385, 2690, -11447, 2714, -11506, 2772, -11498, 2780, -11457, 2774, -11420, 2812, + -11416, 2857, -11493, 2928, -11552, 2956, -11589, 3018, -11626, 3084, -11672, 3164, -11713, 3254, -11730, 3305, + -11794, 3362, -11841, 3374, -11852, 3403, -11908, 3408, -11944, 3435, -12037, 3445, -12062, 3461, -12074, 3516, + -12171, 3616, -12255, 3755, -12251, 3778, -12295, 3811, -12373, 3895, -12387, 3977, -12440, 4031, -12418, 4114, + -12421, 4200, -12453, 4277, -12414, 4371, -12390, 4552, -12408, 4686, -12440, 4772, -12469, 4818, -12457, 4838, + -12312, 4804, -12259, 4710, -12234, 4736, -12250, 4818, -12284, 4900, -12297, 4900, -12491, 4998, -12562, 5042, + -12744, 5083, -12799, 5172, -12785, 5233, -12913, 5276, -12931, 5356, -13051, 5429, -13054, 5480, -13109, 5518, + -13197, 5550, -13225, 5637, -13354, 5718, -13408, 5812, -13504, 5819, -13663, 5821, -13780, 5850, -13987, 5954, + -14083, 5973, -14257, 6008, -14396, 6000, -14593, 6046, -14711, 6088, -14822, 6067, -14802, 5998, -14857, 5991, + -14973, 5971, -15061, 5937, -15172, 5916, -15186, 5975, -15141, 6073, -15035, 6103, -15062, 6128, -15190, 6073, + -15258, 6006, -15402, 5935, -15329, 5886, -15423, 5815, -15531, 5773, -15631, 5742, -15656, 5698, -15812, 5646, + -15843, 5599, -15960, 5557, -16029, 5564, -16122, 5536, -16224, 5502, -16307, 5469, -16479, 5440, -16494, 5457, + -16385, 5504, -16287, 5535, -16180, 5590, -16056, 5601, -16007, 5642, -15868, 5702, -15846, 5722, -15772, 5757, + -15755, 5833, -15704, 5892, -15819, 5862, -15852, 5879, -15906, 5842, -15971, 5893, -15998, 5857, -16036, 5907, + -16135, 5867, -16197, 5867, -16205, 5927, -16187, 5963, -16252, 5999, -16382, 5980, -16466, 6027, -16535, 6051, + -16535, 6107, -16612, 6150, -16573, 6208, -16492, 6263, -16456, 6315, -16375, 6322, -16307, 6306, -16226, 6354, + -16153, 6346, -16077, 6377, -16096, 6422, -16152, 6440, -16078, 6479, -16139, 6478, -16245, 6456, -16276, 6434, + -16355, 6456, -16496, 6445, -16643, 6469, -16684, 6509, -16811, 6567, -16671, 6609, -16447, 6658, -16365, 6658, + -16379, 6608, -16168, 6612, -16249, 6674, -16372, 6712, -16443, 6762, -16539, 6804, -16676, 6836, -16620, 6888, + -16443, 6892, -16317, 6937, -16293, 6986, -16191, 7033, -16093, 7045, -15904, 7089, -15812, 7082, -15658, 7136, + -15507, 7115, -15434, 7070, -15390, 7089, -15221, 7083, -15227, 7060, -15074, 7043, -14972, 7053, -14761, 7021, + -14569, 7012, -14492, 6999, -14359, 7015, -14207, 6985, -14099, 6971, -13912, 6947, -13755, 6899, -13650, 6890, + -13563, 6932, -13441, 6963, -13293, 6951, -13143, 6994, -12979, 7019, -12911, 6978, -12836, 7001, -12814, 7048, + -12745, 7038, -12576, 6948, -12442, 7016, -12429, 6940, -12306, 6956, -12268, 6986, -12147, 6980, -11994, 6938, + -11760, 6901, -11623, 6884, -11525, 6891, -11390, 6840, -11530, 6790, -11350, 6769, -11080, 6781, -10995, 6798, + -10888, 6738, -10779, 6789, -10881, 6831, -10817, 6865, -10695, 6870, -10615, 6880, -10534, 6856, -10434, 6802, + -10322, 6810, -10145, 6765, -9990, 6781, -9844, 6778, -9856, 6840, -9767, 6858, -9612, 6824, -9613, 6729, + -9549, 6809, -9468, 6806, -9423, 6907, -9530, 6969, -9647, 7009, -9639, 7119, -9521, 7192, -9389, 7176, + -9288, 7132, -9152, 7019, -9241, 6970, -11417, 7312, -11467, 7265, -11244, 7296, -11105, 7245, -10992, 7296, + -10901, 7263, -10819, 7165, -10769, 7207, -10840, 7309, -10752, 7324, -10652, 7308, -10540, 7267, -10477, 7170, + -10446, 7099, -10279, 7050, -10098, 7002, -10109, 6958, -10273, 6950, -10209, 6912, -10243, 6875, -10424, 6891, + -10596, 6918, -10712, 6912, -10900, 6878, -11197, 6860, -11331, 6854, -11385, 6901, -11522, 6928, -11611, 6917, + -11734, 6996, -11667, 7007, -11513, 7024, -11372, 7019, -11242, 7037, -11435, 7060, -11649, 7052, -11790, 7054, + -11843, 7091, -11611, 7131, -11766, 7130, -11940, 7156, -11856, 7231, -11787, 7271, -11519, 7331, -10450, 7342, + -10538, 7276, -10694, 7346, -10660, 7360, -10526, 7364, -7634, 7310, -7625, 7283, -7731, 7286, -7839, 7288, + -7949, 7274, -7978, 7280, -8088, 7333, -8083, 7369, -8035, 7376, -7806, 7365, -8656, 7316, -8577, 7253, + -8485, 7334, -8232, 7375, -8060, 7272, -8075, 7206, -7877, 7235, -7782, 7275, -7561, 7224, -7423, 7177, + -7410, 7133, -7224, 7156, -7120, 7092, -6879, 7053, -6791, 7012, -6697, 6919, -6881, 6872, -6645, 6807, + -6486, 6785, -6342, 6693, -6185, 6686, -6216, 6616, -6392, 6500, -6515, 6543, -6672, 6639, -6802, 6626, + -6814, 6569, -6709, 6511, -6573, 6465, -6532, 6438, -6467, 6339, -6501, 6267, -6628, 6295, -6878, 6375, + -6737, 6288, -6633, 6228, -6617, 6193, -6888, 6233, -7102, 6291, -7224, 6340, -7189, 6368, -7338, 6419, + -7483, 6468, -7482, 6439, -7771, 6423, -7856, 6457, -7790, 6531, -7602, 6533, -7396, 6545, -7429, 6581, + -7394, 6631, -7265, 6728, -7293, 6773, -7331, 6807, -7484, 6855, -7687, 6889, -7623, 6915, -7729, 6977, + -7817, 6983, -7896, 7017, -7949, 6987, -8131, 6974, -8494, 6997, -8706, 7026, -8868, 7041, -8951, 7076, + -8847, 7122, -8989, 7122, -9021, 7224, -8944, 7313, -8841, 7354, -8583, 7380, -10036, 7384, -9916, 7363, + -9738, 7376, -9712, 7347, -9805, 7299, -9654, 7256, -9672, 7166, -9836, 7127, -9932, 7136, -10001, 7174, + -10250, 7251, -10248, 7283, -10044, 7271, -10154, 7336, 14360, 7321, 14209, 7321, 14004, 7332, 13986, 7337, + 14081, 7377, 14206, 7386, 14348, 7348, -9320, 7277, -9427, 7202, -9541, 7206, -9603, 7294, -9602, 7344, + -9550, 7386, -9450, 7413, -9242, 7410, -9051, 7386, -9200, 7297, -12046, 7140, -12309, 7090, -12362, 7134, + -12593, 7187, -12559, 7219, -12481, 7302, -12394, 7368, -12492, 7429, -12154, 7445, -12011, 7424, -11756, 7419, + -11658, 7390, -11551, 7348, -11677, 7322, -11922, 7252, -12046, 7182, 15073, 7508, 14958, 7469, 14798, 7478, + 14612, 7517, 14636, 7550, 14822, 7535, -9361, 7498, -9416, 7459, -9561, 7467, -9682, 7493, -9629, 7538, + -9485, 7565, -9398, 7530, 14509, 7556, 14430, 7482, 14061, 7485, 13896, 7461, 13697, 7526, 13751, 7595, + 13883, 7614, 14147, 7609, -9850, 7672, -9774, 7626, -9770, 7574, -9816, 7500, -9981, 7490, -10088, 7506, + -10086, 7564, -10250, 7556, -10257, 7634, -10149, 7631, -9998, 7665, -9858, 7659, -10821, 7620, -10782, 7585, + -10693, 7601, -10588, 7597, -10570, 7548, -10631, 7501, -10970, 7485, -11222, 7442, -11374, 7439, -11387, 7472, + -11179, 7516, -11631, 7504, -11771, 7522, -11635, 7620, -11540, 7648, -11259, 7614, -11081, 7555, -10907, 7547, + -11050, 7643, -10958, 7679, -10855, 7668, 5754, 7072, 5694, 7063, 5368, 7076, 5341, 7121, 5160, 7147, + 5146, 7201, 5248, 7223, 5244, 7277, 5443, 7363, 5351, 7375, 5590, 7463, 5563, 7508, 5787, 7561, + 6117, 7625, 6450, 7644, 6621, 7681, 6816, 7694, 6885, 7654, 6818, 7623, 6464, 7574, 6158, 7526, + 5848, 7431, 5699, 7333, 5542, 7237, 5562, 7154, -9468, 7710, -9357, 7678, -9161, 7678, -9074, 7645, + -9097, 7607, -8982, 7585, -8919, 7561, -8784, 7557, -8638, 7548, -8479, 7570, -8275, 7578, -8113, 7571, + -8006, 7534, -7983, 7492, -8046, 7466, -8195, 7444, -8323, 7456, -8610, 7441, -8815, 7439, -8976, 7452, + -9242, 7484, -9277, 7539, -9289, 7588, -9389, 7632, -9596, 7644, -9712, 7675, -9675, 7716, -11620, 7765, + -11634, 7688, -11711, 7653, -11804, 7648, -11990, 7605, -12150, 7590, -12285, 7612, -12116, 7686, -11910, 7751, + -11757, 7750, 10697, 7697, 10724, 7648, 10815, 7672, 11108, 7671, 11333, 7622, 11413, 7585, 11389, 7533, + 11278, 7503, 11015, 7448, 10940, 7418, 11064, 7404, 11212, 7379, 11302, 7398, 11353, 7334, 11397, 7359, + 11557, 7375, 11878, 7359, 11902, 7312, 12320, 7297, 12326, 7374, 12538, 7356, 12698, 7357, 12859, 7304, + 12905, 7240, 12846, 7198, 12972, 7119, 13129, 7079, 13225, 7184, 13386, 7139, 13556, 7166, 13750, 7135, + 13823, 7163, 13987, 7149, 13915, 7242, 14047, 7285, 14950, 7220, 15035, 7161, 15297, 7084, 15701, 7103, + 15900, 7087, 15983, 7045, 15971, 6972, 16094, 6944, 16228, 6964, 16405, 6967, 16594, 6947, 16784, 6958, + 16958, 6869, 17082, 6901, 17001, 6965, 17045, 7010, 17364, 6982, 17572, 6988, 17860, 6940, 18000, 6896, + 18000, 6498, 17999, 6497, 17871, 6453, 17741, 6461, 17831, 6408, 17891, 6325, 17937, 6298, 17949, 6257, + 17923, 6230, 17736, 6252, 17457, 6177, 17368, 6165, 17215, 6095, 17070, 6034, 17033, 5988, 16890, 6057, + 16630, 5979, 16584, 6016, 16488, 5973, 16354, 5987, 16322, 5921, 16202, 5824, 16205, 5784, 16319, 5762, + 16306, 5616, 16213, 5612, 16170, 5529, 16212, 5486, 16037, 5434, 16002, 5320, 15853, 5296, 15823, 5194, + 15679, 5101, 15642, 5170, 15599, 5316, 15543, 5538, 15591, 5677, 15676, 5736, 15681, 5783, 15836, 5806, + 16015, 5931, 16187, 6034, 16367, 6114, 16447, 6255, 16326, 6247, 16266, 6164, 16012, 6054, 15930, 6177, + 15672, 6143, 15422, 5976, 15504, 5915, 15281, 5888, 15127, 5878, 15134, 5950, 14978, 5966, 14854, 5916, + 14549, 5934, 14220, 5904, 13896, 5709, 13513, 5473, 13670, 5460, 13719, 5398, 13816, 5376, 13880, 5425, + 13990, 5419, 14135, 5309, 14138, 5224, 14060, 5124, 14051, 5005, 14006, 4845, 13855, 4700, 13822, 4631, + 13686, 4514, 13552, 4399, 13487, 4340, 13354, 4281, 13291, 4280, 13228, 4328, 13094, 4255, 13078, 4222, + 13040, 4228, 12997, 4194, 12967, 4160, 12971, 4088, 12919, 4066, 12901, 4049, 12863, 4019, 12797, 4003, + 12753, 3976, 12750, 3932, 12739, 3921, 12778, 3905, 12835, 3861, 12921, 3743, 12946, 3678, 12947, 3563, + 12909, 3508, 12819, 3489, 12739, 3448, 12649, 3439, 12637, 3493, 12656, 3568, 12612, 3673, 12686, 3689, + 12617, 3775, 12569, 3794, 12557, 3775, 12528, 3767, 12524, 3786, 12498, 3795, 12471, 3811, 12499, 3855, + 12522, 3867, 12513, 3885, 12539, 3939, 12532, 3955, 12474, 3966, 12427, 3993, 12287, 3964, 12213, 3917, + 12105, 3890, 12159, 3936, 12138, 3975, 12217, 4042, 12164, 4095, 12077, 4059, 11964, 3990, 11902, 3925, + 11804, 3920, 11753, 3874, 11806, 3806, 11888, 3790, 11891, 3745, 11970, 3716, 12082, 3787, 12171, 3748, + 12236, 3745, 12252, 3693, 12110, 3665, 12064, 3611, 11966, 3561, 11915, 3491, 12023, 3436, 12062, 3338, + 12123, 3246, 12191, 3169, 12189, 3095, 12126, 3068, 12150, 3014, 12209, 2983, 12194, 2902, 12168, 2823, + 12113, 2814, 12040, 2705, 11959, 2574, 11866, 2455, 11728, 2362, 11589, 2278, 11476, 2267, 11415, 2222, + 11381, 2255, 11324, 2205, 11184, 2155, 11079, 2140, 11044, 2034, 10989, 2028, 10963, 2101, 10986, 2140, + 10852, 2172, 10805, 2155, 10672, 2070, 10588, 1975, 10566, 1906, 10643, 1800, 10736, 1670, 10827, 1608, + 10888, 1528, 10934, 1343, 10920, 1167, 10837, 1101, 10722, 1036, 10641, 953, 10516, 860, 10480, 924, + 10508, 992, 10433, 1049, 10350, 1063, 10309, 1115, 10259, 1219, 10169, 1265, 10083, 1263, 10098, 1341, + 10010, 1341, 10002, 1231, 9948, 1085, 9915, 996, 9922, 924, 9987, 921, 10028, 830, 10046, 743, + 10102, 686, 10162, 674, 10214, 622, 10237, 613, 10296, 552, 10338, 486, 10344, 418, 10333, 373, + 10343, 338, 10350, 279, 10385, 252, 10425, 163, 10423, 129, 10352, 123, 10257, 197, 10139, 276, + 10127, 327, 10070, 394, 10056, 477, 10020, 531, 10031, 604, 10009, 646, 9969, 685, 9952, 734, + 9899, 791, 9850, 838, 9834, 779, 9815, 835, 9826, 897, 9855, 993, 9846, 1068, 9876, 1144, + 9843, 1203, 9851, 1312, 9810, 1364, 9778, 1484, 9760, 1610, 9716, 1693, 9651, 1643, 9537, 1571, + 9481, 1580, 9419, 1604, 9453, 1728, 9432, 1821, 9354, 1937, 9366, 1973, 9308, 1986, 9237, 2067, + 9208, 2119, 9203, 2170, 9183, 2218, 9142, 2277, 9050, 2281, 9059, 2239, 9027, 2184, 8985, 2204, + 8970, 2186, 8942, 2197, 8903, 2206, 8889, 2169, 8821, 2170, 8698, 2150, 8703, 2074, 8650, 2015, + 8506, 1948, 8394, 1830, 8319, 1767, 8219, 1702, 8219, 1656, 8169, 1631, 8079, 1595, 8032, 1590, + 8003, 1514, 8023, 1384, 8029, 1301, 7986, 1206, 7986, 1036, 7934, 1031, 7889, 955, 7919, 922, + 7828, 893, 7794, 825, 7754, 797, 7659, 890, 7613, 1030, 7575, 1131, 7540, 1178, 7486, 1274, + 7462, 1399, 7444, 1462, 7353, 1599, 7312, 1793, 7282, 1921, 7282, 2042, 7263, 2136, 7118, 2076, + 7047, 2088, 6916, 2209, 6964, 2245, 6935, 2284, 6818, 2369, 6744, 2394, 6715, 2466, 6637, 2543, + 6453, 2524, 6291, 2522, 6150, 2508, 5962, 2538, 5853, 2561, 5740, 2574, 5697, 2697, 5649, 2714, + 5572, 2696, 5472, 2648, 5349, 2681, 5248, 2758, 5152, 2787, 5085, 2881, 5012, 3015, 4958, 2999, + 4894, 3032, 4857, 2993, 4797, 2998, 4818, 2953, 4809, 2931, 4842, 2855, 4881, 2769, 4930, 2746, + 4947, 2711, 5015, 2669, 5021, 2628, 5011, 2594, 5024, 2561, 5053, 2533, 5066, 2500, 5081, 2475, + 5074, 2548, 5101, 2601, 5129, 2611, 5159, 2580, 5161, 2522, 5139, 2463, 5158, 2425, 5176, 2429, + 5179, 2402, 5258, 2418, 5340, 2415, 5401, 2412, 5469, 2480, 5544, 2544, 5607, 2606, 5636, 2640, + 5649, 2631, 5639, 2590, 5626, 2571, 5640, 2492, 5685, 2424, 5740, 2388, 5814, 2375, 5873, 2357, + 5918, 2299, 5945, 2266, 5981, 2253, 5981, 2231, 5944, 2171, 5928, 2143, 5886, 2111, 5849, 2043, + 5803, 2048, 5783, 2024, 5767, 1974, 5779, 1907, 5769, 1894, 5723, 1895, 5661, 1857, 5651, 1809, + 5628, 1788, 5566, 1788, 5527, 1763, 5527, 1723, 5479, 1695, 5424, 1705, 5357, 1671, 5311, 1665, + 5239, 1638, 5219, 1594, 5217, 1560, 5117, 1518, 4957, 1471, 4868, 1400, 4824, 1395, 4794, 1401, + 4735, 1359, 4672, 1340, 4588, 1335, 4563, 1329, 4541, 1303, 4514, 1295, 4499, 1270, 4449, 1272, + 4418, 1259, 4348, 1264, 4322, 1322, 4325, 1377, 4309, 1406, 4289, 1480, 4260, 1521, 4281, 1526, + 4270, 1572, 4282, 1591, 4278, 1635, 4265, 1677, 4235, 1708, 4227, 1747, 4175, 1783, 4122, 1867, + 4094, 1949, 4025, 2017, 3980, 2034, 3914, 2129, 3902, 2199, 3907, 2258, 3849, 2369, 3802, 2408, + 3748, 2429, 3715, 2486, 3721, 2508, 3693, 2560, 3664, 2583, 3625, 2657, 3564, 2738, 3513, 2806, + 3463, 2806, 3479, 2861, 3483, 2896, 3496, 2936, 3492, 2950, 3464, 2910, 3443, 2834, 3415, 2782, + 3392, 2765, 3359, 2797, 3314, 2842, 3242, 2985, 3232, 2976, 3273, 2871, 3335, 2770, 3410, 2614, + 3447, 2560, 3480, 2503, 3569, 2393, 3549, 2375, 3553, 2310, 3669, 2220, 3687, 2200, 3719, 2102, + 3697, 2084, 3711, 1981, 3748, 1861, 3786, 1837, 3841, 1800, 3899, 1684, 3927, 1592, 3981, 1544, + 4118, 1449, 4173, 1392, 4228, 1334, 4259, 1300, 4308, 1270, 4332, 1239, 4329, 1197, 4272, 1174, + 4315, 1146, 4347, 1128, 4367, 1086, 4412, 1045, 4461, 1044, 4556, 1070, 4665, 1082, 4753, 1113, + 4802, 1119, 4838, 1138, 4895, 1141, 4927, 1143, 4973, 1158, 5026, 1168, 5073, 1202, 5111, 1202, + 5113, 1175, 5104, 1117, 5105, 1064, 5083, 1028, 5055, 920, 5007, 808, 4945, 680, 4859, 534, + 4774, 422, 4656, 286, 4556, 205, 4407, 105, 4314, 29, 4204, -92, 4181, -145, 4159, -168, + 4088, -208, 4064, -250, 4026, -257, 4012, -328, 3980, -368, 3960, -435, 3920, -468, 3874, -591, + 3880, -648, 3944, -684, 3947, -710, 3919, -770, 3925, -801, 3919, -849, 3954, -911, 3995, -1010, + 4032, -1032, 4048, -1077, 4044, -1176, 4056, -1264, 4060, -1420, 4078, -1469, 4048, -1541, 4009, -1610, + 3945, -1672, 3854, -1710, 3741, -1759, 3628, -1866, 3590, -1884, 3520, -1955, 3479, -1978, 3470, -2050, + 3518, -2125, 3537, -2184, 3539, -2214, 3556, -2209, 3553, -2307, 3537, -2354, 3561, -2371, 3546, -2412, + 3504, -2448, 3422, -2482, 3301, -2536, 3257, -2573, 3266, -2615, 3292, -2622, 3283, -2674, 3258, -2747, + 3246, -2830, 3220, -2875, 3152, -2926, 3133, -2940, 3090, -2991, 3062, -3042, 3006, -3114, 2893, -3217, + 2822, -3277, 2746, -3323, 2642, -3361, 2591, -3367, 2578, -3394, 2517, -3380, 2468, -3399, 2359, -3379, + 2299, -3392, 2257, -3386, 2154, -3426, 2069, -3442, 2007, -3480, 1962, -3482, 1919, -3446, 1886, -3444, + 1842, -3400, 1838, -3414, 1824, -3387, 1825, -3328, 1793, -3261, 1825, -3243, 1822, -3166, 1757, -3073, + 1706, -2988, 1635, -2858, 1560, -2782, 1521, -2709, 1499, -2612, 1474, -2539, 1441, -2385, 1439, -2266, + 1426, -2211, 1387, -2170, 1335, -2087, 1283, -1967, 1261, -1905, 1179, -1807, 1173, -1730, 1164, -1667, + 1178, -1579, 1212, -1488, 1218, -1445, 1250, -1355, 1274, -1314, 1331, -1248, 1363, -1204, 1374, -1130, + 1369, -1073, 1339, -1037, 1312, -977, 1288, -917, 1293, -896, 1324, -856, 1293, -760, 1273, -693, + 1223, -629, 1232, -610, 1218, -579, 1192, -504, 1109, -398, 1007, -297, 941, -214, 880, -111, + 883, -78, 905, -46, 929, 27, 949, 101, 931, 116, 965, 228, 980, 307, 940, 373, + 895, 390, 874, 435, 849, 450, 850, 477, 746, 441, 708, 446, 670, 424, 590, 426, + 536, 489, 503, 561, 433, 627, 357, 626, 269, 626, 187, 614, 106, 593, -51, 534, + -106, 500, -196, 471, -286, 499, -331, 498, -401, 518, -465, 517, -583, 499, -653, 471, + -752, 434, -771, 436, -797, 436, -900, 483, -991, 559, -1077, 614, -1144, 679, -1171, 686, + -1243, 726, -1295, 780, -1312, 816, -1325, 890, -1369, 949, -1407, 989, -1433, 1002, -1458, 1021, + -1469, 1066, -1484, 1088, -1513, 1104, -1566, 1146, -1609, 1152, -1631, 1181, -1631, 1196, -1661, 1217, + -1668, 1238, -1684, 1315, -1671, 1360, -1713, 1437, -1762, 1473, -1719, 1492, -1670, 1562, -1646, 1614, + -1655, 1667, -1627, 1717, -1615, 1811, -1626, 1910, -1638, 1959, -1628, 2009, -1654, 2057, -1706, 2100, + -1702, 2142, -1697, 2189, -1659, 2216, -1626, 2268, -1633, 2302, -1598, 2372, -1543, 2436, -1509, 2452, + -1482, 2510, -1480, 2564, -1444, 2625, -1377, 2662, -1314, 2764, -1262, 2804, -1169, 2815, -1090, 2883, + -1040, 2910, -956, 2993, -981, 3118, -943, 3204, -930, 3256, -866, 3324, -765, 3370, -691, 3411, + -624, 3515, -593, 3576, -519, 3576, -459, 3533, -364, 3540, -260, 3518, -217, 3517, -121, 3571, + -13, 3589, 50, 3630, 147, 3661, 316, 3678, 482, 3687, 532, 3672, 626, 3711, 733, 3712, + 774, 3689, 842, 3695, 951, 3735, 1021, 3723, 1018, 3672, 1103, 3709, 1110, 3690, 1060, 3641, + 1059, 3595, 1094, 3570, 1081, 3483, 1015, 3433, 1034, 3379, 1086, 3377, 1111, 3329, 1149, 3314, + 1266, 3279, 1308, 3288, 1392, 3271, 1525, 3227, 1571, 3138, 1661, 3118, 1802, 3076, 1909, 3027, + 1957, 3053, 2005, 3099, 1982, 3175, 2013, 3224, 2085, 3271, 2154, 3284, 2290, 3264, 2324, 3219, + 2361, 3219, 2393, 3202, 2492, 3190, 2516, 3157, 2650, 3159, 2746, 3132, 2845, 3103, 2891, 3087, + 2968, 3119, 3010, 3147, 3098, 3156, 3169, 3143, 3196, 3093, 3219, 3126, 3299, 3102, 3377, 3097, + 3427, 3122, 3456, 3155, 3449, 3161, 3475, 3207, 3496, 3283, 3510, 3308, 3513, 3309, 3548, 3391, + 3598, 3461, 3600, 3464, 3591, 3541, 3615, 3582, 3578, 3628, 3616, 3665, 3555, 3657, 3471, 3680, + 3403, 3622, 3251, 3611, 3170, 3664, 3062, 3668, 3039, 3626, 2970, 3614, 2873, 3668, 2764, 3666, + 2705, 3765, 2632, 3821, 2680, 3899, 2617, 3946, 2728, 4042, 2882, 4046, 2924, 4122, 3115, 4109, + 3235, 4174, 3351, 4202, 3517, 4204, 3691, 4134, 3835, 4095, 3951, 4110, 4037, 4101, 4155, 4154, + 4170, 4196, 4145, 4265, 4088, 4301, 4032, 4313, 3996, 4344, 3868, 4428, 3754, 4466, 3668, 4524, + 3740, 4540, 3823, 4624, 3767, 4664, 3915, 4704, 3912, 4726, 3822, 4710, 3743, 4702, 3676, 4670, + 3582, 4665, 3496, 4627, 3502, 4565, 3551, 4541, 3653, 4547, 3633, 4511, 3524, 4494, 3388, 4436, + 3333, 4456, 3355, 4503, 3245, 4533, 3263, 4552, 3359, 4585, 3330, 4608, 3174, 4633, 3168, 4671, + 3075, 4658, 3038, 4603, 2960, 4529, 2963, 4504, 2914, 4482, 2884, 4491, 2856, 4371, 2804, 4329, + 2767, 4258, 2800, 4201, 2812, 4162, 2899, 4130, 2881, 4105, 2762, 4100, 2719, 4069, 2636, 4015, + 2604, 4062, 2606, 4082, 2545, 4085, 2493, 4095, 2371, 4069, 2441, 4013, 2390, 3996, 2334, 3996, + 2281, 4048, 2263, 4026, 2285, 3966, 2335, 3919, 2297, 3897, 2353, 3851, 2403, 3822, 2404, 3766, + 2312, 3792, 2341, 3741, 2278, 3731, 2315, 3642, 2249, 3641, 2167, 3685, 2130, 3765, 2112, 3831, + 2073, 3877, 2022, 3934, 2015, 3963, 1998, 3970, 1996, 3992, 1941, 4025, 1932, 4073, 1940, 4141, + 1954, 4172, 1937, 4188, 1916, 4196, 1888, 4228, 1845, 4248, 1751, 4285, 1693, 4321, 1602, 4351, + 1517, 4424, 1538, 4432, 1492, 4474, 1490, 4508, 1426, 4523, 1395, 4480, 1366, 4514, 1368, 4548, + 1372, 4550, 1394, 4559, 1314, 4574, 1233, 4538, 1238, 4489, 1226, 4460, 1259, 4409, 1353, 4359, + 1403, 4276, 1514, 4196, 1593, 4196, 1617, 4174, 1589, 4154, 1679, 4118, 1752, 4088, 1838, 4036, + 1848, 4017, 1829, 3981, 1774, 4028, 1687, 4044, 1645, 3980, 1717, 3942, 1705, 3890, 1664, 3884, + 1610, 3799, 1568, 3791, 1569, 3821, 1589, 3875, 1611, 3896, 1572, 3954, 1541, 4005, 1500, 4017, + 1470, 4060, 1406, 4079, 1363, 4119, 1289, 4125, 1211, 4170, 1119, 4236, 1051, 4293, 1020, 4392, + 970, 4404, 889, 4437, 843, 4423, 785, 4377, 744, 4369, 653, 4313, 456, 4340, 310, 4308, + 299, 4247, 304, 4189, 209, 4123, 81, 4101, 72, 4068, 11, 4012, -28, 3931, 11, 3874, + -47, 3829, -68, 3764, -144, 3744, -215, 3667, -342, 3666, -437, 3668, -500, 3632, -538, 3595, + -587, 3603, -624, 3637, -652, 3694, -745, 3710, -786, 3684, -838, 3698, -890, 3687, -875, 3765, + -884, 3827, -929, 3836, -953, 3874, -945, 3939, -905, 3976, -898, 4016, -877, 4076, -879, 4118, + -899, 4154, -903, 4188, -898, 4259, -939, 4303, -798, 4375, -675, 4357, -541, 4357, -435, 4340, + -352, 4346, -190, 4342, -138, 4402, -119, 4601, -223, 4706, -296, 4757, -449, 4796, -459, 4868, + -330, 4890, -162, 4864, -193, 4978, -99, 4935, 134, 5013, 164, 5095, 251, 5115, 332, 5135, + 383, 5162, 471, 5309, 607, 5351, 691, 5348, 710, 5369, 794, 5375, 812, 5353, 880, 5402, + 857, 5440, 853, 5496, 812, 5552, 809, 5654, 826, 5681, 854, 5711, 942, 5717, 978, 5745, + 1058, 5773, 1055, 5722, 1025, 5689, 1037, 5661, 1091, 5646, 1067, 5608, 1037, 5619, 965, 5547, + 992, 5498, 994, 5460, 1095, 5436, 1094, 5401, 1196, 5420, 1252, 5447, 1365, 5408, 1412, 5376, + 1480, 5405, 1636, 5451, 1762, 5485, 1862, 5468, 1870, 5444, 1966, 5443, 1989, 5487, 2127, 5519, + 2106, 5603, 2109, 5678, 2158, 5741, 2252, 5775, 2332, 5701, 2412, 5703, 2431, 5779, 2443, 5838, + 2406, 5826, 2343, 5861, 2334, 5919, 2460, 5947, 2586, 5961, 2695, 5945, 2798, 5948, 2912, 6003, + 2807, 6050, 2626, 6042, 2450, 6006, 2287, 5985, 2229, 6039, 2132, 6072, 2154, 6171, 2106, 6261, + 2154, 6319, 2244, 6382, 2473, 6490, 2540, 6511, 2529, 6553, 2390, 6601, 2218, 6572, 2121, 6503, + 2137, 6441, 1978, 6361, 1785, 6275, 1712, 6134, 1783, 6064, 1879, 6008, 1787, 5895, 1683, 5872, + 1645, 5704, 1588, 5610, 1467, 5620, 1410, 5541, 1294, 5536, 1263, 5631, 1179, 5744, 1103, 5886, + 1036, 5947, 838, 5831, 705, 5808, 567, 5859, 531, 5966, 499, 6197, 591, 6261, 855, 6345, + 1053, 6449, 1236, 6588, 1476, 6781, 1644, 6856, 1918, 6982, 2138, 7026, 2302, 7020, 2455, 7103, + 2637, 7099, 2817, 7119, 3129, 7045, 3001, 7019, 3110, 6956, 3213, 6991, 3378, 6930, 3651, 6906, + 4029, 6793, 4106, 6746, 4113, 6679, 4002, 6627, 3838, 6600, 3392, 6676, 3318, 6663, 3481, 6590, + 3494, 6441, 3623, 6411, 3701, 6385, 3714, 6433, 3652, 6478, 3718, 6514, 3959, 6452, 4044, 6476, + 3976, 6550, 4209, 6648, 4302, 6642, 4395, 6607, 4453, 6676, 4370, 6735, 4419, 6795, 4345, 6857, + 4625, 6825, 4682, 6769, 4556, 6757, 4556, 6701, 4635, 6667, 4789, 6688, 4814, 6752, 5023, 6800, + 5372, 6886, 5447, 6881, 5349, 6820, 5473, 6810, 5544, 6844, 5732, 6847, 5880, 6888, 5994, 6828, + 6108, 6894, 6003, 6952, 6055, 6985, 6350, 6955, 6489, 6923, 6851, 6809, 6918, 6862, 6816, 6914, + 6814, 6936, 6693, 6945, 6726, 6993, 6672, 7071, 6669, 7103, 6854, 7193, 6920, 7284, 6994, 7304, + 7259, 7278, 7280, 7222, 7185, 7141, 7247, 7109, 7279, 7039, 7256, 6902, 7367, 6841, 7324, 6774, + 7128, 6632, 7242, 6617, 7282, 6653, 7392, 6679, 7419, 6728, 7505, 6776, 7447, 6833, 7494, 6899, + 7384, 6907, 7360, 6963, 7440, 7063, 7310, 7145, 7489, 7212, 7466, 7283, 7516, 7286, 7568, 7230, + 7529, 7134, 7636, 7115, 7590, 7187, 7758, 7227, 7965, 7232, 8150, 7175, 8061, 7258, 8051, 7365, + 8225, 7385, 8466, 7381, 8682, 7394, 8601, 7446, 8717, 7512, 8832, 7514, 9026, 7564, 9290, 7577, + 9323, 7605, 9586, 7614, 9668, 7592, 9892, 7645, 10076, 7643, 10104, 7686, 10199, 7729, 10435, 7770, + 10607, 7737, 10471, 7713, 4911, 4128, 4962, 4057, 5008, 4053, 5039, 4026, 4957, 4018, 4940, 3940, + 4922, 3905, 4886, 3882, 4888, 3832, 4920, 3758, 5015, 3737, 5084, 3687, 5226, 3670, 5383, 3697, + 5392, 3720, 5374, 3791, 5388, 3895, 5310, 3929, 5336, 3998, 5269, 4003, 5292, 4088, 5386, 4063, + 5474, 4095, 5401, 4155, 5372, 4212, 5292, 4187, 5281, 4114, 5250, 4178, 5245, 4203, 5269, 4244, + 5250, 4279, 5134, 4313, 5089, 4403, 5034, 4428, 5031, 4461, 5128, 4451, 5132, 4525, 5217, 4541, + 5304, 4526, 5322, 4623, 5304, 4685, 5204, 4680, 5119, 4705, 5003, 4661, 4910, 4640, 4865, 4581, + 4768, 4564, 4668, 4461, 4759, 4366, 4749, 4299, 4858, 4181, -9384, 7752, -9430, 7749, -9617, 7756, + -9644, 7783, -9442, 7782, -9372, 7763, -11019, 7770, -11205, 7741, -11353, 7773, -11272, 7805, -11126, 7815, + -10985, 7800, 2472, 7785, 2249, 7744, 2073, 7768, 2142, 7794, 2081, 7825, 2288, 7845, 2328, 7808, + -10966, 7860, -11088, 7841, -11254, 7841, -11253, 7855, -11150, 7885, -11096, 7880, -9583, 7806, -9731, 7785, + -9812, 7808, -9855, 7846, -9863, 7887, -9734, 7883, -9675, 7877, -9556, 7842, -10006, 7832, -9967, 7791, + -10130, 7802, -10295, 7834, -10518, 7838, -10421, 7868, -10542, 7892, -10549, 7930, -10353, 7917, -10083, 7880, + 10508, 7831, 9944, 7792, 10126, 7923, 10209, 7935, 10284, 7928, 10537, 7871, 1825, 7970, 2154, 7896, + 1903, 7856, 1847, 7783, 1759, 7764, 1712, 7681, 1591, 7677, 1376, 7738, 1467, 7774, 1317, 7802, + 1122, 7887, 1044, 7965, 1317, 8001, 1372, 7966, 1514, 7967, 1552, 8002, 1699, 8005, 2545, 8041, + 2741, 8006, 2592, 7952, 2302, 7940, 2008, 7957, 1990, 7984, 1846, 7986, 1737, 8032, 2046, 8060, + 2191, 8036, 2292, 8066, 5114, 8055, 4979, 8042, 4889, 8034, 4875, 8018, 4759, 8001, 4650, 8025, + 4707, 8056, 4485, 8059, 4680, 8077, 4832, 8078, 4852, 8051, 4910, 8075, 5004, 8092, 5152, 8070, + 9994, 7888, 9776, 7876, 9497, 7904, 9331, 7943, 9255, 8014, 9118, 8034, 9378, 8102, 9594, 8125, + 9788, 8075, 10019, 7978, -8702, 7966, -8581, 7934, -8719, 7904, -8904, 7829, -9080, 7822, -9288, 7834, + -9395, 7875, -9394, 7911, -9315, 7938, -9497, 7937, -9608, 7971, -9671, 8016, -9602, 8060, -9532, 8091, + -9430, 8098, -9474, 8121, -9241, 8126, -9113, 8072, -8945, 8051, -8781, 8032, -6850, 8311, -6583, 8303, + -6368, 8290, -6185, 8263, -6189, 8236, -6433, 8193, -6675, 8173, -6766, 8150, -6548, 8151, -6784, 8090, + -6947, 8062, -7118, 7980, -7324, 7963, -7388, 7943, -7691, 7932, -7553, 7920, -7622, 7902, -7539, 7853, + -7634, 7818, -7789, 7790, -7836, 7751, -7976, 7721, -7962, 7698, -7791, 7702, -7789, 7678, -8056, 7618, + -8317, 7645, -8611, 7630, -8760, 7642, -8949, 7647, -8962, 7695, -8777, 7718, -8826, 7790, -8765, 7797, + -8498, 7754, -8634, 7818, -8796, 7837, -8715, 7876, -8538, 7900, -8509, 7935, -8651, 7974, -8693, 8025, + -8420, 8021, -8341, 8010, -8185, 8046, -8410, 8058, -8760, 8052, -8937, 8086, -9020, 8126, -9137, 8155, + -9159, 8189, -9010, 8208, -8893, 8212, -8697, 8228, -8550, 8265, -8426, 8260, -8318, 8232, -8242, 8286, + -8110, 8302, -7931, 8313, -7625, 8317, -7572, 8306, -7283, 8323, -7067, 8317, -2710, 8352, -2085, 8273, + -2269, 8234, -2652, 8230, -3190, 8220, -3140, 8202, -2786, 8213, -2484, 8179, -2290, 8209, -2207, 8173, + -2317, 8115, -2062, 8152, -1577, 8191, -1277, 8172, -1221, 8129, -1629, 8058, -1685, 8035, -2005, 8018, + -1773, 8013, -1890, 7940, -1970, 7875, -1967, 7764, -1847, 7699, -2004, 7694, -2168, 7663, -1983, 7610, + -1960, 7525, -2067, 7516, -1937, 7430, -2159, 7422, -2043, 7382, -2076, 7346, -2217, 7331, -2357, 7331, + -2231, 7263, -2230, 7218, -2428, 7260, -2479, 7233, -2344, 7208, -2213, 7147, -2175, 7066, -2354, 7047, + -2431, 7086, -2554, 7143, -2520, 7075, -2636, 7023, -2373, 7018, -2235, 7013, -2503, 6926, -2775, 6847, + -3067, 6813, -3178, 6812, -3281, 6774, -3420, 6668, -3635, 6598, -3704, 6594, -3838, 6569, -3981, 6546, + -4067, 6484, -4068, 6414, -4119, 6348, -4282, 6268, -4242, 6190, -4287, 6107, -4338, 6010, -4479, 6004, + -4626, 6085, -4826, 6086, -4923, 6141, -4990, 6238, -5163, 6363, -5214, 6428, -5228, 6518, -5366, 6610, + -5330, 6684, -5397, 6719, -5298, 6836, -5148, 6873, -5108, 6915, -5087, 6993, -5201, 6957, -5256, 6943, + -5346, 6928, -5468, 6961, -5475, 7029, -5436, 7082, -5343, 7084, -5139, 7057, -5311, 7120, -5400, 7155, + -5500, 7141, -5583, 7165, -5472, 7259, -5533, 7296, -5612, 7365, -5732, 7471, -5860, 7510, -5859, 7552, + -6127, 7610, -6339, 7618, -6606, 7613, -6850, 7606, -6966, 7638, -7140, 7701, -6878, 7732, -6676, 7738, + -7104, 7764, -7330, 7804, -7316, 7843, -6937, 7891, -6571, 7939, -6532, 7976, -6802, 8012, -6715, 8052, + -6369, 8121, -6223, 8132, -6265, 8177, -6028, 8203, -5721, 8219, -5413, 8220, -5304, 8189, -5039, 8244, + -4800, 8206, -4660, 8199, -4452, 8166, -4690, 8220, -4676, 8263, -4341, 8323, -3990, 8318, -3862, 8355, + -3509, 8365, +}; diff --git a/Apps/SatelliteTracker/main/Source/WorldMap.h b/Apps/SatelliteTracker/main/Source/WorldMap.h new file mode 100644 index 0000000..f7fc7a8 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/WorldMap.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Land outlines as rings of (longitude, latitude) pairs in 1/100 degree. Rings are closed implicitly. */ +extern const uint16_t WORLD_MAP_RING_COUNT; +extern const uint16_t WORLD_MAP_RING_LENGTHS[]; +extern const int16_t WORLD_MAP_POINTS[]; + +#ifdef __cplusplus +} +#endif diff --git a/Apps/SatelliteTracker/main/Source/main.cpp b/Apps/SatelliteTracker/main/Source/main.cpp new file mode 100644 index 0000000..f9a4c9b --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/main.cpp @@ -0,0 +1,86 @@ +#include "DeviceLocation.h" +#include "SatelliteTracker.h" + +#include +#include +#include +#include + +#include + +#include + +extern "C" { + +int main(int argc, char* argv[]) { + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + struct AppEventSubscription sub {}; + check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + + bool downloads_ready = ctx->downloader.init(&event_group); + + lvgl_lock(); + satelliteTrackerInit(ctx.get()); + lvgl_unlock(); + + WindowId window = window_manager_create_ext(app_instance_id, satelliteTrackerCreateWidgets, satelliteTrackerDestroyWidgets, ctx.get()); + ctx->window = window; + + DeviceLocation device_location; + + bool should_close = false; + while (!should_close) { + // The timeout lets a changed location setting take effect without another wake-up source. + task_event_group_wait_any(&event_group, nullptr, pdMS_TO_TICKS(2000)); + + struct AppEvent event; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + should_close = true; + break; + } + } + if (should_close) break; + + if (downloads_ready) { + DownloadResult result; + while (ctx->downloader.process(result)) { + lvgl_lock(); + satelliteTrackerHandleDownload(ctx.get(), result); + lvgl_unlock(); + } + } + + device_location.update(&event_group, ctx->settings.useDeviceLocation); + Location fix; + if (device_location.poll(fix)) { + lvgl_lock(); + satelliteTrackerSetDeviceLocation(ctx.get(), &fix); + lvgl_unlock(); + } else if (!device_location.isActive() && ctx->deviceFixValid) { + lvgl_lock(); + satelliteTrackerSetDeviceLocation(ctx.get(), nullptr); + lvgl_unlock(); + } + } + device_location.stop(); + + window_manager_remove(window); + ctx->downloader.deinit(); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&event_group); + lvgl_lock(); + satelliteTrackerTeardown(ctx.get()); + lvgl_unlock(); + + return 0; +} + +} diff --git a/Apps/SatelliteTracker/main/Source/sgp4/LICENSE b/Apps/SatelliteTracker/main/Source/sgp4/LICENSE new file mode 100644 index 0000000..cf1ab25 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/sgp4/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/Apps/SatelliteTracker/main/Source/sgp4/README.md b/Apps/SatelliteTracker/main/Source/sgp4/README.md new file mode 100644 index 0000000..46824ea --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/sgp4/README.md @@ -0,0 +1,6 @@ +# SGP4 + +`SGP4.c` and `SGP4.h` are unmodified copies of the C port from https://github.com/aholinch/sgp4 (`src/c`, commit 552cb1489a52c3023ae70cb6c7e239e84c5950fe). +They are released under the Unlicense, see `LICENSE` in this folder. + +The port is based on the SGP4 code that David Vallado and the Center for Space Standards and Innovation published with "Revisiting Spacetrack Report #3" (2006). diff --git a/Apps/SatelliteTracker/main/Source/sgp4/SGP4.c b/Apps/SatelliteTracker/main/Source/sgp4/SGP4.c new file mode 100644 index 0000000..1a58ef0 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/sgp4/SGP4.c @@ -0,0 +1,1995 @@ +#include +#include +#include "SGP4.h" + +/* ---------------------------------------------------------------- +* +* sgp4unit.cpp +* +* this file contains the sgp4 procedures for analytical propagation +* of a satellite. the code was originally released in the 1980 and 1986 +* spacetrack papers. a detailed discussion of the theory and history +* may be found in the 2006 aiaa paper by vallado, crawford, hujsak, +* and kelso. +* +* companion code for +* fundamentals of astrodynamics and applications +* 2013 +* by david vallado +* +* (w) 719-573-2600, email dvallado@agi.com, davallado@gmail.com +* +* current : +* 7 dec 15 david vallado +* fix jd, jdfrac +* changes : +* 3 nov 14 david vallado +* update to msvs2013 c++ +* 30 aug 10 david vallado +* delete unused variables in initl +* replace pow integer 2, 3 with multiplies for speed +* 3 nov 08 david vallado +* put returns in for error codes +* 29 sep 08 david vallado +* fix atime for faster operation in dspace +* add operationmode for afspc (a) or improved (i) +* performance mode +* 16 jun 08 david vallado +* update small eccentricity check +* 16 nov 07 david vallado +* misc fixes for better compliance +* 20 apr 07 david vallado +* misc fixes for constants +* 11 aug 06 david vallado +* chg lyddane choice back to strn3, constants, misc doc +* 15 dec 05 david vallado +* misc fixes +* 26 jul 05 david vallado +* fixes for paper +* note that each fix is preceded by a +* comment with "sgp4fix" and an explanation of +* what was changed +* 10 aug 04 david vallado +* 2nd printing baseline working +* 14 may 01 david vallado +* 2nd edition baseline +* 80 norad +* original baseline +* ---------------------------------------------------------------- */ + + + + + /* ----------------------------------------------------------------------------- + * + * procedure dpper + * + * this procedure provides deep space long period periodic contributions + * to the mean elements. by design, these periodics are zero at epoch. + * this used to be dscom which included initialization, but it's really a + * recurring function. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * e3 - + * ee2 - + * peo - + * pgho - + * pho - + * pinco - + * plo - + * se2 , se3 , sgh2, sgh3, sgh4, sh2, sh3, si2, si3, sl2, sl3, sl4 - + * t - + * xh2, xh3, xi2, xi3, xl2, xl3, xl4 - + * zmol - + * zmos - + * ep - eccentricity 0.0 - 1.0 + * inclo - inclination - needed for lyddane modification + * nodep - right ascension of ascending node + * argpp - argument of perigee + * mp - mean anomaly + * + * outputs : + * ep - eccentricity 0.0 - 1.0 + * inclp - inclination + * nodep - right ascension of ascending node + * argpp - argument of perigee + * mp - mean anomaly + * + * locals : + * alfdp - + * betdp - + * cosip , sinip , cosop , sinop , + * dalf - + * dbet - + * dls - + * f2, f3 - + * pe - + * pgh - + * ph - + * pinc - + * pl - + * sel , ses , sghl , sghs , shl , shs , sil , sinzf , sis , + * sll , sls + * xls - + * xnoh - + * zf - + * zm - + * + * coupling : + * none. + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + void dpper + ( + double e3, double ee2, double peo, double pgho, double pho, + double pinco, double plo, double se2, double se3, double sgh2, + double sgh3, double sgh4, double sh2, double sh3, double si2, + double si3, double sl2, double sl3, double sl4, double t, + double xgh2, double xgh3, double xgh4, double xh2, double xh3, + double xi2, double xi3, double xl2, double xl3, double xl4, + double zmol, double zmos, + char init, + ElsetRec *rec, + char opsmode + ) + { + /* --------------------- local variables ------------------------ */ + double alfdp, betdp, cosip, cosop, dalf, dbet, dls, + f2, f3, pe, pgh, ph, pinc, pl, + sel, ses, sghl, sghs, shll, shs, sil, + sinip, sinop, sinzf, sis, sll, sls, xls, + xnoh, zf, zm, zel, zes, znl, zns; + + + /* ---------------------- constants ----------------------------- */ + zns = 1.19459e-5; + zes = 0.01675; + znl = 1.5835218e-4; + zel = 0.05490; + + /* --------------- calculate time varying periodics ----------- */ + zm = zmos + zns * t; + // be sure that the initial call has time set to zero + if (init == 'y') + zm = zmos; + zf = zm + 2.0 * zes * sin(zm); + sinzf = sin(zf); + f2 = 0.5 * sinzf * sinzf - 0.25; + f3 = -0.5 * sinzf * cos(zf); + ses = se2* f2 + se3 * f3; + sis = si2 * f2 + si3 * f3; + sls = sl2 * f2 + sl3 * f3 + sl4 * sinzf; + sghs = sgh2 * f2 + sgh3 * f3 + sgh4 * sinzf; + shs = sh2 * f2 + sh3 * f3; + zm = zmol + znl * t; + if (init == 'y') + zm = zmol; + zf = zm + 2.0 * zel * sin(zm); + sinzf = sin(zf); + f2 = 0.5 * sinzf * sinzf - 0.25; + f3 = -0.5 * sinzf * cos(zf); + sel = ee2 * f2 + e3 * f3; + sil = xi2 * f2 + xi3 * f3; + sll = xl2 * f2 + xl3 * f3 + xl4 * sinzf; + sghl = xgh2 * f2 + xgh3 * f3 + xgh4 * sinzf; + shll = xh2 * f2 + xh3 * f3; + pe = ses + sel; + pinc = sis + sil; + pl = sls + sll; + pgh = sghs + sghl; + ph = shs + shll; + + if (init == 'n') + { + pe = pe - peo; + pinc = pinc - pinco; + pl = pl - plo; + pgh = pgh - pgho; + ph = ph - pho; + rec->inclp = rec->inclp + pinc; + rec->ep = rec->ep + pe; + sinip = sin(rec->inclp); + cosip = cos(rec->inclp); + + /* ----------------- apply periodics directly ------------ */ + // sgp4fix for lyddane choice + // strn3 used original inclination - this is technically feasible + // gsfc used perturbed inclination - also technically feasible + // probably best to readjust the 0.2 limit value and limit discontinuity + // 0.2 rad = 11.45916 deg + // use next line for original strn3 approach and original inclination + // if (inclo >= 0.2) + // use next line for gsfc version and perturbed inclination + if (rec->inclp >= 0.2) + { + ph = ph / sinip; + pgh = pgh - cosip * ph; + rec->argpp = rec->argpp + pgh; + rec->nodep = rec->nodep + ph; + rec->mp = rec->mp + pl; + } + else + { + /* ---- apply periodics with lyddane modification ---- */ + sinop = sin(rec->nodep); + cosop = cos(rec->nodep); + alfdp = sinip * sinop; + betdp = sinip * cosop; + dalf = ph * cosop + pinc * cosip * sinop; + dbet = -ph * sinop + pinc * cosip * cosop; + alfdp = alfdp + dalf; + betdp = betdp + dbet; + rec->nodep = fmod(rec->nodep, twopi); + // sgp4fix for afspc written intrinsic functions + // nodep used without a trigonometric function ahead + if ((rec->nodep < 0.0) && (opsmode == 'a')) + rec->nodep = rec->nodep + twopi; + xls = rec->mp + rec->argpp + cosip * rec->nodep; + dls = pl + pgh - pinc * rec->nodep * sinip; + xls = xls + dls; + xls = fmod(xls,twopi); + xnoh = rec->nodep; + rec->nodep = atan2(alfdp, betdp); + // sgp4fix for afspc written intrinsic functions + // nodep used without a trigonometric function ahead + if ((rec->nodep < 0.0) && (opsmode == 'a')) + rec->nodep = rec->nodep + twopi; + if (fabs(xnoh - rec->nodep) > pi) + { + if (rec->nodep < xnoh) + { + rec->nodep = rec->nodep + twopi; + } + else + { + rec->nodep = rec->nodep - twopi; + } + } + rec->mp = rec->mp + pl; + rec->argpp = xls - rec->mp - cosip * rec->nodep; + } + } // if init == 'n' + + } // dpper + + /*----------------------------------------------------------------------------- + * + * procedure dscom + * + * this procedure provides deep space common items used by both the secular + * and periodics subroutines. input is provided as shown. this routine + * used to be called dpper, but the functions inside weren't well organized. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * epoch - + * ep - eccentricity + * argpp - argument of perigee + * tc - + * inclp - inclination + * nodep - right ascension of ascending node + * np - mean motion + * + * outputs : + * sinim , cosim , sinomm , cosomm , snodm , cnodm + * day - + * e3 - + * ee2 - + * em - eccentricity + * emsq - eccentricity squared + * gam - + * peo - + * pgho - + * pho - + * pinco - + * plo - + * rtemsq - + * se2, se3 - + * sgh2, sgh3, sgh4 - + * sh2, sh3, si2, si3, sl2, sl3, sl4 - + * s1, s2, s3, s4, s5, s6, s7 - + * ss1, ss2, ss3, ss4, ss5, ss6, ss7, sz1, sz2, sz3 - + * sz11, sz12, sz13, sz21, sz22, sz23, sz31, sz32, sz33 - + * xgh2, xgh3, xgh4, xh2, xh3, xi2, xi3, xl2, xl3, xl4 - + * nm - mean motion + * z1, z2, z3, z11, z12, z13, z21, z22, z23, z31, z32, z33 - + * zmol - + * zmos - + * + * locals : + * a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 - + * betasq - + * cc - + * ctem, stem - + * x1, x2, x3, x4, x5, x6, x7, x8 - + * xnodce - + * xnoi - + * zcosg , zsing , zcosgl , zsingl , zcosh , zsinh , zcoshl , zsinhl , + * zcosi , zsini , zcosil , zsinil , + * zx - + * zy - + * + * coupling : + * none. + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + void dscom + ( + double epoch, double ep, double argpp, double tc, double inclp, + double nodep, double np, + ElsetRec *rec + ) + { + /* -------------------------- constants ------------------------- */ + const double zes = 0.01675; + const double zel = 0.05490; + const double c1ss = 2.9864797e-6; + const double c1l = 4.7968065e-7; + const double zsinis = 0.39785416; + const double zcosis = 0.91744867; + const double zcosgs = 0.1945905; + const double zsings = -0.98088458; + + /* --------------------- local variables ------------------------ */ + int lsflg; + double a1, a2, a3, a4, a5, a6, a7, + a8, a9, a10, betasq, cc, ctem, stem, + x1, x2, x3, x4, x5, x6, x7, + x8, xnodce, xnoi, zcosg, zcosgl, zcosh, zcoshl, + zcosi, zcosil, zsing, zsingl, zsinh, zsinhl, zsini, + zsinil, zx, zy; + + rec->nm = np; + rec->em = ep; + rec->snodm = sin(nodep); + rec->cnodm = cos(nodep); + rec->sinomm = sin(argpp); + rec->cosomm = cos(argpp); + rec->sinim = sin(inclp); + rec->cosim = cos(inclp); + rec->emsq = rec->em * rec->em; + betasq = 1.0 - rec->emsq; + rec->rtemsq = sqrt(betasq); + + /* ----------------- initialize lunar solar terms --------------- */ + rec->peo = 0.0; + rec->pinco = 0.0; + rec->plo = 0.0; + rec->pgho = 0.0; + rec->pho = 0.0; + rec->day = epoch + 18261.5 + tc / 1440.0; + xnodce = fmod(4.5236020 - 9.2422029e-4 * rec->day, twopi); + stem = sin(xnodce); + ctem = cos(xnodce); + zcosil = 0.91375164 - 0.03568096 * ctem; + zsinil = sqrt(1.0 - zcosil * zcosil); + zsinhl = 0.089683511 * stem / zsinil; + zcoshl = sqrt(1.0 - zsinhl * zsinhl); + rec->gam = 5.8351514 + 0.0019443680 * rec->day; + zx = 0.39785416 * stem / zsinil; + zy = zcoshl * ctem + 0.91744867 * zsinhl * stem; + zx = atan2(zx, zy); + zx = rec->gam + zx - xnodce; + zcosgl = cos(zx); + zsingl = sin(zx); + + /* ------------------------- do solar terms --------------------- */ + zcosg = zcosgs; + zsing = zsings; + zcosi = zcosis; + zsini = zsinis; + zcosh = rec->cnodm; + zsinh = rec->snodm; + cc = c1ss; + xnoi = 1.0 / rec->nm; + + for (lsflg = 1; lsflg <= 2; lsflg++) + { + a1 = zcosg * zcosh + zsing * zcosi * zsinh; + a3 = -zsing * zcosh + zcosg * zcosi * zsinh; + a7 = -zcosg * zsinh + zsing * zcosi * zcosh; + a8 = zsing * zsini; + a9 = zsing * zsinh + zcosg * zcosi * zcosh; + a10 = zcosg * zsini; + a2 = rec->cosim * a7 + rec->sinim * a8; + a4 = rec->cosim * a9 + rec->sinim * a10; + a5 = -rec->sinim * a7 + rec->cosim * a8; + a6 = -rec->sinim * a9 + rec->cosim * a10; + + x1 = a1 * rec->cosomm + a2 * rec->sinomm; + x2 = a3 * rec->cosomm + a4 * rec->sinomm; + x3 = -a1 * rec->sinomm + a2 * rec->cosomm; + x4 = -a3 * rec->sinomm + a4 * rec->cosomm; + x5 = a5 * rec->sinomm; + x6 = a6 * rec->sinomm; + x7 = a5 * rec->cosomm; + x8 = a6 * rec->cosomm; + + rec->z31 = 12.0 * x1 * x1 - 3.0 * x3 * x3; + rec->z32 = 24.0 * x1 * x2 - 6.0 * x3 * x4; + rec->z33 = 12.0 * x2 * x2 - 3.0 * x4 * x4; + rec->z1 = 3.0 * (a1 * a1 + a2 * a2) + rec->z31 * rec->emsq; + rec->z2 = 6.0 * (a1 * a3 + a2 * a4) + rec->z32 * rec->emsq; + rec->z3 = 3.0 * (a3 * a3 + a4 * a4) + rec->z33 * rec->emsq; + rec->z11 = -6.0 * a1 * a5 + rec->emsq * (-24.0 * x1 * x7 - 6.0 * x3 * x5); + rec->z12 = -6.0 * (a1 * a6 + a3 * a5) + rec->emsq * + (-24.0 * (x2 * x7 + x1 * x8) - 6.0 * (x3 * x6 + x4 * x5)); + rec->z13 = -6.0 * a3 * a6 + rec->emsq * (-24.0 * x2 * x8 - 6.0 * x4 * x6); + rec->z21 = 6.0 * a2 * a5 + rec->emsq * (24.0 * x1 * x5 - 6.0 * x3 * x7); + rec->z22 = 6.0 * (a4 * a5 + a2 * a6) + rec->emsq * + (24.0 * (x2 * x5 + x1 * x6) - 6.0 * (x4 * x7 + x3 * x8)); + rec->z23 = 6.0 * a4 * a6 + rec->emsq * (24.0 * x2 * x6 - 6.0 * x4 * x8); + rec->z1 = rec->z1 + rec->z1 + betasq * rec->z31; + rec->z2 = rec->z2 + rec->z2 + betasq * rec->z32; + rec->z3 = rec->z3 + rec->z3 + betasq * rec->z33; + rec->s3 = cc * xnoi; + rec->s2 = -0.5 * rec->s3 / rec->rtemsq; + rec->s4 = rec->s3 * rec->rtemsq; + rec->s1 = -15.0 * rec->em * rec->s4; + rec->s5 = x1 * x3 + x2 * x4; + rec->s6 = x2 * x3 + x1 * x4; + rec->s7 = x2 * x4 - x1 * x3; + + /* ----------------------- do lunar terms ------------------- */ + if (lsflg == 1) + { + rec->ss1 = rec->s1; + rec->ss2 = rec->s2; + rec->ss3 = rec->s3; + rec->ss4 = rec->s4; + rec->ss5 = rec->s5; + rec->ss6 = rec->s6; + rec->ss7 = rec->s7; + rec->sz1 = rec->z1; + rec->sz2 = rec->z2; + rec->sz3 = rec->z3; + rec->sz11 = rec->z11; + rec->sz12 = rec->z12; + rec->sz13 = rec->z13; + rec->sz21 = rec->z21; + rec->sz22 = rec->z22; + rec->sz23 = rec->z23; + rec->sz31 = rec->z31; + rec->sz32 = rec->z32; + rec->sz33 = rec->z33; + zcosg = zcosgl; + zsing = zsingl; + zcosi = zcosil; + zsini = zsinil; + zcosh = zcoshl * rec->cnodm + zsinhl * rec->snodm; + zsinh = rec->snodm * zcoshl - rec->cnodm * zsinhl; + cc = c1l; + } + } + + rec->zmol = fmod(4.7199672 + 0.22997150 * rec->day - rec->gam, twopi); + rec->zmos = fmod(6.2565837 + 0.017201977 * rec->day, twopi); + + /* ------------------------ do solar terms ---------------------- */ + rec->se2 = 2.0 * rec->ss1 * rec->ss6; + rec->se3 = 2.0 * rec->ss1 * rec->ss7; + rec->si2 = 2.0 * rec->ss2 * rec->sz12; + rec->si3 = 2.0 * rec->ss2 * (rec->sz13 - rec->sz11); + rec->sl2 = -2.0 * rec->ss3 * rec->sz2; + rec->sl3 = -2.0 * rec->ss3 * (rec->sz3 - rec->sz1); + rec->sl4 = -2.0 * rec->ss3 * (-21.0 - 9.0 * rec->emsq) * zes; + rec->sgh2 = 2.0 * rec->ss4 * rec->sz32; + rec->sgh3 = 2.0 * rec->ss4 * (rec->sz33 - rec->sz31); + rec->sgh4 = -18.0 * rec->ss4 * zes; + rec->sh2 = -2.0 * rec->ss2 * rec->sz22; + rec->sh3 = -2.0 * rec->ss2 * (rec->sz23 - rec->sz21); + + /* ------------------------ do lunar terms ---------------------- */ + rec->ee2 = 2.0 * rec->s1 * rec->s6; + rec->e3 = 2.0 * rec->s1 * rec->s7; + rec->xi2 = 2.0 * rec->s2 * rec->z12; + rec->xi3 = 2.0 * rec->s2 * (rec->z13 - rec->z11); + rec->xl2 = -2.0 * rec->s3 * rec->z2; + rec->xl3 = -2.0 * rec->s3 * (rec->z3 - rec->z1); + rec->xl4 = -2.0 * rec->s3 * (-21.0 - 9.0 * rec->emsq) * zel; + rec->xgh2 = 2.0 * rec->s4 * rec->z32; + rec->xgh3 = 2.0 * rec->s4 * (rec->z33 - rec->z31); + rec->xgh4 = -18.0 * rec->s4 * zel; + rec->xh2 = -2.0 * rec->s2 * rec->z22; + rec->xh3 = -2.0 * rec->s2 * (rec->z23 - rec->z21); + + } // dscom + + /*----------------------------------------------------------------------------- + * + * procedure dsinit + * + * this procedure provides deep space contributions to mean motion dot due + * to geopotential resonance with half day and one day orbits. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * xke - reciprocal of tumin + * cosim, sinim- + * emsq - eccentricity squared + * argpo - argument of perigee + * s1, s2, s3, s4, s5 - + * ss1, ss2, ss3, ss4, ss5 - + * sz1, sz3, sz11, sz13, sz21, sz23, sz31, sz33 - + * t - time + * tc - + * gsto - greenwich sidereal time rad + * mo - mean anomaly + * mdot - mean anomaly dot (rate) + * no - mean motion + * nodeo - right ascension of ascending node + * nodedot - right ascension of ascending node dot (rate) + * xpidot - + * z1, z3, z11, z13, z21, z23, z31, z33 - + * eccm - eccentricity + * argpm - argument of perigee + * inclm - inclination + * mm - mean anomaly + * xn - mean motion + * nodem - right ascension of ascending node + * + * outputs : + * em - eccentricity + * argpm - argument of perigee + * inclm - inclination + * mm - mean anomaly + * nm - mean motion + * nodem - right ascension of ascending node + * irez - flag for resonance 0-none, 1-one day, 2-half day + * atime - + * d2201, d2211, d3210, d3222, d4410, d4422, d5220, d5232, d5421, d5433 - + * dedt - + * didt - + * dmdt - + * dndt - + * dnodt - + * domdt - + * del1, del2, del3 - + * ses , sghl , sghs , sgs , shl , shs , sis , sls + * theta - + * xfact - + * xlamo - + * xli - + * xni + * + * locals : + * ainv2 - + * aonv - + * cosisq - + * eoc - + * f220, f221, f311, f321, f322, f330, f441, f442, f522, f523, f542, f543 - + * g200, g201, g211, g300, g310, g322, g410, g422, g520, g521, g532, g533 - + * sini2 - + * temp - + * temp1 - + * theta - + * xno2 - + * + * coupling : + * getgravconst- no longer used + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + void dsinit + ( + double tc, double xpidot, + ElsetRec *rec + ) + { + /* --------------------- local variables ------------------------ */ + + double ainv2, aonv = 0.0, cosisq, eoc, f220, f221, f311, + f321, f322, f330, f441, f442, f522, f523, + f542, f543, g200, g201, g211, g300, g310, + g322, g410, g422, g520, g521, g532, g533, + ses, sgs, sghl, sghs, shs, shll, sis, + sini2, sls, temp, temp1, theta, xno2, q22, + q31, q33, root22, root44, root54, rptim, root32, + root52, x2o3, znl, emo, zns, emsqo; + + q22 = 1.7891679e-6; + q31 = 2.1460748e-6; + q33 = 2.2123015e-7; + root22 = 1.7891679e-6; + root44 = 7.3636953e-9; + root54 = 2.1765803e-9; + rptim = 4.37526908801129966e-3; // this equates to 7.29211514668855e-5 rad/sec + root32 = 3.7393792e-7; + root52 = 1.1428639e-7; + x2o3 = 2.0 / 3.0; + znl = 1.5835218e-4; + zns = 1.19459e-5; + + // sgp4fix identify constants and allow alternate values + // just xke is used here so pass it in rather than have multiple calls + // getgravconst( whichconst, tumin, mu, radiusearthkm, xke, j2, j3, j4, j3oj2 ); + + /* -------------------- deep space initialization ------------ */ + rec->irez = 0; + if ((rec->nm < 0.0052359877) && (rec->nm > 0.0034906585)) + rec->irez = 1; + if ((rec->nm >= 8.26e-3) && (rec->nm <= 9.24e-3) && (rec->em >= 0.5)) + rec->irez = 2; + + /* ------------------------ do solar terms ------------------- */ + ses = rec->ss1 * zns * rec->ss5; + sis = rec->ss2 * zns * (rec->sz11 + rec->sz13); + sls = -zns * rec->ss3 * (rec->sz1 + rec->sz3 - 14.0 - 6.0 * rec->emsq); + sghs = rec->ss4 * zns * (rec->sz31 + rec->sz33 - 6.0); + shs = -zns * rec->ss2 * (rec->sz21 + rec->sz23); + // sgp4fix for 180 deg incl + if ((rec->inclm < 5.2359877e-2) || (rec->inclm > pi - 5.2359877e-2)) + shs = 0.0; + if (rec->sinim != 0.0) + shs = shs / rec->sinim; + sgs = sghs - rec->cosim * shs; + + /* ------------------------- do lunar terms ------------------ */ + rec->dedt = ses + rec->s1 * znl * rec->s5; + rec->didt = sis + rec->s2 * znl * (rec->z11 + rec->z13); + rec->dmdt = sls - znl * rec->s3 * (rec->z1 + rec->z3 - 14.0 - 6.0 * rec->emsq); + sghl = rec->s4 * znl * (rec->z31 + rec->z33 - 6.0); + shll = -znl * rec->s2 * (rec->z21 + rec->z23); + // sgp4fix for 180 deg incl + if ((rec->inclm < 5.2359877e-2) || (rec->inclm > pi - 5.2359877e-2)) + shll = 0.0; + rec->domdt = sgs + sghl; + rec->dnodt = shs; + if (rec->sinim != 0.0) + { + rec->domdt = rec->domdt - rec->cosim / rec->sinim * shll; + rec->dnodt = rec->dnodt + shll / rec->sinim; + } + + /* ----------- calculate deep space resonance effects -------- */ + rec->dndt = 0.0; + theta = fmod(rec->gsto + tc * rptim, twopi); + rec->em = rec->em + rec->dedt * rec->t; + rec->inclm = rec->inclm + rec->didt * rec->t; + rec->argpm = rec->argpm + rec->domdt * rec->t; + rec->nodem = rec->nodem + rec->dnodt * rec->t; + rec->mm = rec->mm + rec->dmdt * rec->t; + // sgp4fix for negative inclinations + // the following if statement should be commented out + //if (inclm < 0.0) + // { + // inclm = -inclm; + // argpm = argpm - pi; + // nodem = nodem + pi; + // } + + /* -------------- initialize the resonance terms ------------- */ + if (rec->irez != 0) + { + aonv = pow(rec->nm / rec->xke, x2o3); + + /* ---------- geopotential resonance for 12 hour orbits ------ */ + if (rec->irez == 2) + { + cosisq = rec->cosim * rec->cosim; + emo = rec->em; + rec->em = rec->ecco; + emsqo = rec->emsq; + rec->emsq = rec->eccsq; + eoc = rec->em * rec->emsq; + g201 = -0.306 - (rec->em - 0.64) * 0.440; + + if (rec->em <= 0.65) + { + g211 = 3.616 - 13.2470 * rec->em + 16.2900 * rec->emsq; + g310 = -19.302 + 117.3900 * rec->em - 228.4190 * rec->emsq + 156.5910 * eoc; + g322 = -18.9068 + 109.7927 * rec->em - 214.6334 * rec->emsq + 146.5816 * eoc; + g410 = -41.122 + 242.6940 * rec->em - 471.0940 * rec->emsq + 313.9530 * eoc; + g422 = -146.407 + 841.8800 * rec->em - 1629.014 * rec->emsq + 1083.4350 * eoc; + g520 = -532.114 + 3017.977 * rec->em - 5740.032 * rec->emsq + 3708.2760 * eoc; + } + else + { + g211 = -72.099 + 331.819 * rec->em - 508.738 * rec->emsq + 266.724 * eoc; + g310 = -346.844 + 1582.851 * rec->em - 2415.925 * rec->emsq + 1246.113 * eoc; + g322 = -342.585 + 1554.908 * rec->em - 2366.899 * rec->emsq + 1215.972 * eoc; + g410 = -1052.797 + 4758.686 * rec->em - 7193.992 * rec->emsq + 3651.957 * eoc; + g422 = -3581.690 + 16178.110 * rec->em - 24462.770 * rec->emsq + 12422.520 * eoc; + if (rec->em > 0.715) + g520 = -5149.66 + 29936.92 * rec->em - 54087.36 * rec->emsq + 31324.56 * eoc; + else + g520 = 1464.74 - 4664.75 * rec->em + 3763.64 * rec->emsq; + } + if (rec->em < 0.7) + { + g533 = -919.22770 + 4988.6100 * rec->em - 9064.7700 * rec->emsq + 5542.21 * eoc; + g521 = -822.71072 + 4568.6173 * rec->em - 8491.4146 * rec->emsq + 5337.524 * eoc; + g532 = -853.66600 + 4690.2500 * rec->em - 8624.7700 * rec->emsq + 5341.4 * eoc; + } + else + { + g533 = -37995.780 + 161616.52 * rec->em - 229838.20 * rec->emsq + 109377.94 * eoc; + g521 = -51752.104 + 218913.95 * rec->em - 309468.16 * rec->emsq + 146349.42 * eoc; + g532 = -40023.880 + 170470.89 * rec->em - 242699.48 * rec->emsq + 115605.82 * eoc; + } + + sini2 = rec->sinim * rec->sinim; + f220 = 0.75 * (1.0 + 2.0 * rec->cosim + cosisq); + f221 = 1.5 * sini2; + f321 = 1.875 * rec->sinim * (1.0 - 2.0 * rec->cosim - 3.0 * cosisq); + f322 = -1.875 * rec->sinim * (1.0 + 2.0 * rec->cosim - 3.0 * cosisq); + f441 = 35.0 * sini2 * f220; + f442 = 39.3750 * sini2 * sini2; + f522 = 9.84375 * rec->sinim * (sini2 * (1.0 - 2.0 * rec->cosim - 5.0 * cosisq) + + 0.33333333 * (-2.0 + 4.0 * rec->cosim + 6.0 * cosisq)); + f523 = rec->sinim * (4.92187512 * sini2 * (-2.0 - 4.0 * rec->cosim + + 10.0 * cosisq) + 6.56250012 * (1.0 + 2.0 * rec->cosim - 3.0 * cosisq)); + f542 = 29.53125 * rec->sinim * (2.0 - 8.0 * rec->cosim + cosisq * + (-12.0 + 8.0 * rec->cosim + 10.0 * cosisq)); + f543 = 29.53125 * rec->sinim * (-2.0 - 8.0 * rec->cosim + cosisq * + (12.0 + 8.0 * rec->cosim - 10.0 * cosisq)); + xno2 = rec->nm * rec->nm; + ainv2 = aonv * aonv; + temp1 = 3.0 * xno2 * ainv2; + temp = temp1 * root22; + rec->d2201 = temp * f220 * g201; + rec->d2211 = temp * f221 * g211; + temp1 = temp1 * aonv; + temp = temp1 * root32; + rec->d3210 = temp * f321 * g310; + rec->d3222 = temp * f322 * g322; + temp1 = temp1 * aonv; + temp = 2.0 * temp1 * root44; + rec->d4410 = temp * f441 * g410; + rec->d4422 = temp * f442 * g422; + temp1 = temp1 * aonv; + temp = temp1 * root52; + rec->d5220 = temp * f522 * g520; + rec->d5232 = temp * f523 * g532; + temp = 2.0 * temp1 * root54; + rec->d5421 = temp * f542 * g521; + rec->d5433 = temp * f543 * g533; + rec->xlamo = fmod(rec->mo + rec->nodeo + rec->nodeo - theta - theta, twopi); + rec->xfact = rec->mdot + rec->dmdt + 2.0 * (rec->nodedot + rec->dnodt - rptim) - rec->no_unkozai; + rec->em = emo; + rec->emsq = emsqo; + } + + /* ---------------- synchronous resonance terms -------------- */ + if (rec->irez == 1) + { + g200 = 1.0 + rec->emsq * (-2.5 + 0.8125 * rec->emsq); + g310 = 1.0 + 2.0 * rec->emsq; + g300 = 1.0 + rec->emsq * (-6.0 + 6.60937 * rec->emsq); + f220 = 0.75 * (1.0 + rec->cosim) * (1.0 + rec->cosim); + f311 = 0.9375 * rec->sinim * rec->sinim * (1.0 + 3.0 * rec->cosim) - 0.75 * (1.0 + rec->cosim); + f330 = 1.0 + rec->cosim; + f330 = 1.875 * f330 * f330 * f330; + rec->del1 = 3.0 * rec->nm * rec->nm * aonv * aonv; + rec->del2 = 2.0 * rec->del1 * f220 * g200 * q22; + rec->del3 = 3.0 * rec->del1 * f330 * g300 * q33 * aonv; + rec->del1 = rec->del1 * f311 * g310 * q31 * aonv; + rec->xlamo = fmod(rec->mo + rec->nodeo + rec->argpo - theta, twopi); + rec->xfact = rec->mdot + xpidot - rptim + rec->dmdt + rec->domdt + rec->dnodt - rec->no_unkozai; + } + + /* ------------ for sgp4, initialize the integrator ---------- */ + rec->xli = rec->xlamo; + rec->xni = rec->no_unkozai; + rec->atime = 0.0; + rec->nm = rec->no_unkozai + rec->dndt; + } + + } // dsinit + + /*----------------------------------------------------------------------------- + * + * procedure dspace + * + * this procedure provides deep space contributions to mean elements for + * perturbing third body. these effects have been averaged over one + * revolution of the sun and moon. for earth resonance effects, the + * effects have been averaged over no revolutions of the satellite. + * (mean motion) + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * d2201, d2211, d3210, d3222, d4410, d4422, d5220, d5232, d5421, d5433 - + * dedt - + * del1, del2, del3 - + * didt - + * dmdt - + * dnodt - + * domdt - + * irez - flag for resonance 0-none, 1-one day, 2-half day + * argpo - argument of perigee + * argpdot - argument of perigee dot (rate) + * t - time + * tc - + * gsto - gst + * xfact - + * xlamo - + * no - mean motion + * atime - + * em - eccentricity + * ft - + * argpm - argument of perigee + * inclm - inclination + * xli - + * mm - mean anomaly + * xni - mean motion + * nodem - right ascension of ascending node + * + * outputs : + * atime - + * em - eccentricity + * argpm - argument of perigee + * inclm - inclination + * xli - + * mm - mean anomaly + * xni - + * nodem - right ascension of ascending node + * dndt - + * nm - mean motion + * + * locals : + * delt - + * ft - + * theta - + * x2li - + * x2omi - + * xl - + * xldot - + * xnddt - + * xndt - + * xomi - + * + * coupling : + * none - + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + void dspace(double tc, ElsetRec *rec) + { + int iretn; + double delt, ft, theta, x2li, x2omi, xl, xldot, xnddt, xndt, xomi, g22, g32, + g44, g52, g54, fasx2, fasx4, fasx6, rptim, step2, stepn, stepp; + + xndt = 0; + xnddt = 0; + xldot = 0; + + fasx2 = 0.13130908; + fasx4 = 2.8843198; + fasx6 = 0.37448087; + g22 = 5.7686396; + g32 = 0.95240898; + g44 = 1.8014998; + g52 = 1.0508330; + g54 = 4.4108898; + rptim = 4.37526908801129966e-3; // this equates to 7.29211514668855e-5 rad/sec + stepp = 720.0; + stepn = -720.0; + step2 = 259200.0; + + /* ----------- calculate deep space resonance effects ----------- */ + rec->dndt = 0.0; + theta = fmod(rec->gsto + tc * rptim, twopi); + rec->em = rec->em + rec->dedt * rec->t; + + rec->inclm = rec->inclm + rec->didt * rec->t; + rec->argpm = rec->argpm + rec->domdt * rec->t; + rec->nodem = rec->nodem + rec->dnodt * rec->t; + rec->mm = rec->mm + rec->dmdt * rec->t; + + // sgp4fix for negative inclinations + // the following if statement should be commented out + // if (inclm < 0.0) + // { + // inclm = -inclm; + // argpm = argpm - pi; + // nodem = nodem + pi; + // } + + /* - update resonances : numerical (euler-maclaurin) integration - */ + /* ------------------------- epoch restart ---------------------- */ + // sgp4fix for propagator problems + // the following integration works for negative time steps and periods + // the specific changes are unknown because the original code was so convoluted + + // sgp4fix take out atime = 0.0 and fix for faster operation + ft = 0.0; + if (rec->irez != 0) + { + // sgp4fix streamline check + if ((rec->atime == 0.0) || (rec->t * rec->atime <= 0.0) || (fabs(rec->t) < fabs(rec->atime))) + { + rec->atime = 0.0; + rec->xni = rec->no_unkozai; + rec->xli = rec->xlamo; + } + // sgp4fix move check outside loop + if (rec->t > 0.0) + delt = stepp; + else + delt = stepn; + + iretn = 381; // added for do loop + while (iretn == 381) + { + /* ------------------- dot terms calculated ------------- */ + /* ----------- near - synchronous resonance terms ------- */ + if (rec->irez != 2) + { + xndt = rec->del1 * sin(rec->xli - fasx2) + rec->del2 * sin(2.0 * (rec->xli - fasx4)) + + rec->del3 * sin(3.0 * (rec->xli - fasx6)); + xldot = rec->xni + rec->xfact; + xnddt = rec->del1 * cos(rec->xli - fasx2) + + 2.0 * rec->del2 * cos(2.0 * (rec->xli - fasx4)) + + 3.0 * rec->del3 * cos(3.0 * (rec->xli - fasx6)); + xnddt = xnddt * xldot; + } + else + { + /* --------- near - half-day resonance terms -------- */ + xomi = rec->argpo + rec->argpdot * rec->atime; + x2omi = xomi + xomi; + x2li = rec->xli + rec->xli; + xndt = rec->d2201 * sin(x2omi + rec->xli - g22) + rec->d2211 * sin(rec->xli - g22) + + rec->d3210 * sin(xomi + rec->xli - g32) + rec->d3222 * sin(-xomi + rec->xli - g32) + + rec->d4410 * sin(x2omi + x2li - g44) + rec->d4422 * sin(x2li - g44) + + rec->d5220 * sin(xomi + rec->xli - g52) + rec->d5232 * sin(-xomi + rec->xli - g52) + + rec->d5421 * sin(xomi + x2li - g54) + rec->d5433 * sin(-xomi + x2li - g54); + xldot = rec->xni + rec->xfact; + xnddt = rec->d2201 * cos(x2omi + rec->xli - g22) + rec->d2211 * cos(rec->xli - g22) + + rec->d3210 * cos(xomi + rec->xli - g32) + rec->d3222 * cos(-xomi + rec->xli - g32) + + rec->d5220 * cos(xomi + rec->xli - g52) + rec->d5232 * cos(-xomi + rec->xli - g52) + + 2.0 * (rec->d4410 * cos(x2omi + x2li - g44) + + rec->d4422 * cos(x2li - g44) + rec->d5421 * cos(xomi + x2li - g54) + + rec->d5433 * cos(-xomi + x2li - g54)); + xnddt = xnddt * xldot; + } + + /* ----------------------- integrator ------------------- */ + // sgp4fix move end checks to end of routine + if (fabs(rec->t - rec->atime) >= stepp) + { + iretn = 381; + } + else // exit here + { + ft = rec->t - rec->atime; + iretn = 0; + } + + if (iretn == 381) + { + rec->xli = rec->xli + xldot * delt + xndt * step2; + rec->xni = rec->xni + xndt * delt + xnddt * step2; + rec->atime = rec->atime + delt; + } + } // while iretn = 381 + + + rec->nm = rec->xni + xndt * ft + xnddt * ft * ft * 0.5; + xl = rec->xli + xldot * ft + xndt * ft * ft * 0.5; + if (rec->irez != 1) + { + rec->mm = xl - 2.0 * rec->nodem + 2.0 * theta; + rec->dndt = rec->nm - rec->no_unkozai; + } + else + { + rec->mm = xl - rec->nodem - rec->argpm + theta; + rec->dndt = rec->nm - rec->no_unkozai; + } + rec->nm = rec->no_unkozai + rec->dndt; + } + + } // dsspace + + /*----------------------------------------------------------------------------- + * + * procedure initl + * + * this procedure initializes the spg4 propagator. all the initialization is + * consolidated here instead of having multiple loops inside other routines. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * satn - satellite number - not needed, placed in satrec + * xke - reciprocal of tumin + * j2 - j2 zonal harmonic + * ecco - eccentricity 0.0 - 1.0 + * epoch - epoch time in days from jan 0, 1950. 0 hr + * inclo - inclination of satellite + * no - mean motion of satellite + * + * outputs : + * ainv - 1.0 / a + * ao - semi major axis + * con41 - + * con42 - 1.0 - 5.0 cos(i) + * cosio - cosine of inclination + * cosio2 - cosio squared + * eccsq - eccentricity squared + * method - flag for deep space 'd', 'n' + * omeosq - 1.0 - ecco * ecco + * posq - semi-parameter squared + * rp - radius of perigee + * rteosq - square root of (1.0 - ecco*ecco) + * sinio - sine of inclination + * gsto - gst at time of observation rad + * no - mean motion of satellite + * + * locals : + * ak - + * d1 - + * del - + * adel - + * po - + * + * coupling : + * getgravconst- no longer used + * gstime - find greenwich sidereal time from the julian date + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + void initl(double epoch, ElsetRec *rec) + { + /* --------------------- local variables ------------------------ */ + double ak, d1, del, adel, po, x2o3; + + // sgp4fix use old way of finding gst + double ds70; + double ts70, tfrac, c1, thgr70, fk5r, c1p2p; + + /* ----------------------- earth constants ---------------------- */ + // sgp4fix identify constants and allow alternate values + // only xke and j2 are used here so pass them in directly + // getgravconst( whichconst, tumin, mu, radiusearthkm, xke, j2, j3, j4, j3oj2 ); + x2o3 = 2.0 / 3.0; + + /* ------------- calculate auxillary epoch quantities ---------- */ + rec->eccsq = rec->ecco * rec->ecco; + rec->omeosq = 1.0 - rec->eccsq; + rec->rteosq = sqrt(rec->omeosq); + rec->cosio = cos(rec->inclo); + rec->cosio2 = rec->cosio * rec->cosio; + + /* ------------------ un-kozai the mean motion ----------------- */ + ak = pow(rec->xke / rec->no_kozai, x2o3); + d1 = 0.75 * rec->j2 * (3.0 * rec->cosio2 - 1.0) / (rec->rteosq * rec->omeosq); + del = d1 / (ak * ak); + adel = ak * (1.0 - del * del - del * + (1.0 / 3.0 + 134.0 * del * del / 81.0)); + del = d1 / (adel * adel); + rec->no_unkozai = rec->no_kozai / (1.0 + del); + + rec->ao = pow(rec->xke / (rec->no_unkozai), x2o3); + rec->sinio = sin(rec->inclo); + po = rec->ao * rec->omeosq; + rec->con42 = 1.0 - 5.0 * rec->cosio2; + rec->con41 = -rec->con42 - rec->cosio2 - rec->cosio2; + rec->ainv = 1.0 / rec->ao; + rec->posq = po * po; + rec->rp = rec->ao * (1.0 - rec->ecco); + rec->method = 'n'; + + // sgp4fix modern approach to finding sidereal time + // if (opsmode == 'a') + // { + // sgp4fix use old way of finding gst + // count integer number of days from 0 jan 1970 + ts70 = epoch - 7305.0; + ds70 = floor(ts70 + 1.0e-8); + tfrac = ts70 - ds70; + // find greenwich location at epoch + c1 = 1.72027916940703639e-2; + thgr70 = 1.7321343856509374; + fk5r = 5.07551419432269442e-15; + c1p2p = c1 + twopi; + double gsto1 = fmod(thgr70 + c1*ds70 + c1p2p*tfrac + ts70*ts70*fk5r, twopi); + if (gsto1 < 0.0) + gsto1 = gsto1 + twopi; + // } + // else + rec->gsto = gstime(epoch + 2433281.5); + + } // initl + + /*----------------------------------------------------------------------------- + * + * procedure sgp4init + * + * this procedure initializes variables for sgp4. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * opsmode - mode of operation afspc or improved 'a', 'i' + * whichconst - which set of constants to use 72, 84 + * satn - satellite number + * bstar - sgp4 type drag coefficient kg/m2er + * ecco - eccentricity + * epoch - epoch time in days from jan 0, 1950. 0 hr + * argpo - argument of perigee (output if ds) + * inclo - inclination + * mo - mean anomaly (output if ds) + * no - mean motion + * nodeo - right ascension of ascending node + * + * outputs : + * satrec - common values for subsequent calls + * return code - non-zero on error. + * 1 - mean elements, ecc >= 1.0 or ecc < -0.001 or a < 0.95 er + * 2 - mean motion less than 0.0 + * 3 - pert elements, ecc < 0.0 or ecc > 1.0 + * 4 - semi-latus rectum < 0.0 + * 5 - epoch elements are sub-orbital + * 6 - satellite has decayed + * + * locals : + * cnodm , snodm , cosim , sinim , cosomm , sinomm + * cc1sq , cc2 , cc3 + * coef , coef1 + * cosio4 - + * day - + * dndt - + * em - eccentricity + * emsq - eccentricity squared + * eeta - + * etasq - + * gam - + * argpm - argument of perigee + * nodem - + * inclm - inclination + * mm - mean anomaly + * nm - mean motion + * perige - perigee + * pinvsq - + * psisq - + * qzms24 - + * rtemsq - + * s1, s2, s3, s4, s5, s6, s7 - + * sfour - + * ss1, ss2, ss3, ss4, ss5, ss6, ss7 - + * sz1, sz2, sz3 + * sz11, sz12, sz13, sz21, sz22, sz23, sz31, sz32, sz33 - + * tc - + * temp - + * temp1, temp2, temp3 - + * tsi - + * xpidot - + * xhdot1 - + * z1, z2, z3 - + * z11, z12, z13, z21, z22, z23, z31, z32, z33 - + * + * coupling : + * getgravconst- + * initl - + * dscom - + * dpper - + * dsinit - + * sgp4 - + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + bool sgp4init ( char opsmode,ElsetRec *satrec) + { + /* --------------------- local variables ------------------------ */ + + double cc1sq, + cc2, cc3, coef, coef1, cosio4, + eeta, etasq, perige, pinvsq, psisq, qzms24, + sfour,tc, temp, temp1, temp2, temp3, tsi, xpidot, + xhdot1,qzms2t, ss, x2o3, r[3], v[3], + delmotemp, qzms2ttemp, qzms24temp; + + + double epoch = (satrec->jdsatepoch + satrec->jdsatepochF) - 2433281.5; + + /* ------------------------ initialization --------------------- */ + // sgp4fix divisor for divide by zero check on inclination + // the old check used 1.0 + cos(pi-1.0e-9), but then compared it to + // 1.5 e-12, so the threshold was changed to 1.5e-12 for consistency + const double temp4 = 1.5e-12; + + /* ----------- set all near earth variables to zero ------------ */ + satrec->isimp = 0; satrec->method = 'n'; satrec->aycof = 0.0; + satrec->con41 = 0.0; satrec->cc1 = 0.0; satrec->cc4 = 0.0; + satrec->cc5 = 0.0; satrec->d2 = 0.0; satrec->d3 = 0.0; + satrec->d4 = 0.0; satrec->delmo = 0.0; satrec->eta = 0.0; + satrec->argpdot = 0.0; satrec->omgcof = 0.0; satrec->sinmao = 0.0; + satrec->t = 0.0; satrec->t2cof = 0.0; satrec->t3cof = 0.0; + satrec->t4cof = 0.0; satrec->t5cof = 0.0; satrec->x1mth2 = 0.0; + satrec->x7thm1 = 0.0; satrec->mdot = 0.0; satrec->nodedot = 0.0; + satrec->xlcof = 0.0; satrec->xmcof = 0.0; satrec->nodecf = 0.0; + + /* ----------- set all deep space variables to zero ------------ */ + satrec->irez = 0; satrec->d2201 = 0.0; satrec->d2211 = 0.0; + satrec->d3210 = 0.0; satrec->d3222 = 0.0; satrec->d4410 = 0.0; + satrec->d4422 = 0.0; satrec->d5220 = 0.0; satrec->d5232 = 0.0; + satrec->d5421 = 0.0; satrec->d5433 = 0.0; satrec->dedt = 0.0; + satrec->del1 = 0.0; satrec->del2 = 0.0; satrec->del3 = 0.0; + satrec->didt = 0.0; satrec->dmdt = 0.0; satrec->dnodt = 0.0; + satrec->domdt = 0.0; satrec->e3 = 0.0; satrec->ee2 = 0.0; + satrec->peo = 0.0; satrec->pgho = 0.0; satrec->pho = 0.0; + satrec->pinco = 0.0; satrec->plo = 0.0; satrec->se2 = 0.0; + satrec->se3 = 0.0; satrec->sgh2 = 0.0; satrec->sgh3 = 0.0; + satrec->sgh4 = 0.0; satrec->sh2 = 0.0; satrec->sh3 = 0.0; + satrec->si2 = 0.0; satrec->si3 = 0.0; satrec->sl2 = 0.0; + satrec->sl3 = 0.0; satrec->sl4 = 0.0; satrec->gsto = 0.0; + satrec->xfact = 0.0; satrec->xgh2 = 0.0; satrec->xgh3 = 0.0; + satrec->xgh4 = 0.0; satrec->xh2 = 0.0; satrec->xh3 = 0.0; + satrec->xi2 = 0.0; satrec->xi3 = 0.0; satrec->xl2 = 0.0; + satrec->xl3 = 0.0; satrec->xl4 = 0.0; satrec->xlamo = 0.0; + satrec->zmol = 0.0; satrec->zmos = 0.0; satrec->atime = 0.0; + satrec->xli = 0.0; satrec->xni = 0.0; + + /* ------------------------ earth constants ----------------------- */ + // sgp4fix identify constants and allow alternate values + // this is now the only call for the constants + getgravconst(satrec->whichconst, satrec); + + //------------------------------------------------------------------------- + + satrec->error = 0; + satrec->operationmode = opsmode; + + + // single averaged mean elements + satrec->am = satrec->em = satrec->im = satrec->Om = satrec->mm = satrec->nm = 0.0; + + /* ------------------------ earth constants ----------------------- */ + // sgp4fix identify constants and allow alternate values no longer needed + // getgravconst( whichconst, tumin, mu, radiusearthkm, xke, j2, j3, j4, j3oj2 ); + ss = 78.0 / satrec->radiusearthkm + 1.0; + // sgp4fix use multiply for speed instead of pow + qzms2ttemp = (120.0 - 78.0) / satrec->radiusearthkm; + qzms2t = qzms2ttemp * qzms2ttemp * qzms2ttemp * qzms2ttemp; + x2o3 = 2.0 / 3.0; + + satrec->init = 'y'; + satrec->t = 0.0; + + // sgp4fix remove satn as it is not needed in initl + + initl(epoch,satrec); + + satrec->a = pow(satrec->no_unkozai * satrec->tumin, (-2.0 / 3.0)); + satrec->alta = satrec->a * (1.0 + satrec->ecco) - 1.0; + satrec->altp = satrec->a * (1.0 - satrec->ecco) - 1.0; + satrec->error = 0; + + // sgp4fix remove this check as it is unnecessary + // the mrt check in sgp4 handles decaying satellite cases even if the starting + // condition is below the surface of te earth + // if (rp < 1.0) + // { + // satrec->error = 5; + // } + + if ((satrec->omeosq >= 0.0) || (satrec->no_unkozai >= 0.0)) + { + satrec->isimp = 0; + if (satrec->rp < (220.0 / satrec->radiusearthkm + 1.0)) + satrec->isimp = 1; + sfour = ss; + qzms24 = qzms2t; + perige = (satrec->rp - 1.0) * satrec->radiusearthkm; + + /* - for perigees below 156 km, s and qoms2t are altered - */ + if (perige < 156.0) + { + sfour = perige - 78.0; + if (perige < 98.0) + sfour = 20.0; + // sgp4fix use multiply for speed instead of pow + qzms24temp = (120.0 - sfour) / satrec->radiusearthkm; + qzms24 = qzms24temp * qzms24temp * qzms24temp * qzms24temp; + sfour = sfour / satrec->radiusearthkm + 1.0; + } + pinvsq = 1.0 / satrec->posq; + + tsi = 1.0 / (satrec->ao - sfour); + satrec->eta = satrec->ao * satrec->ecco * tsi; + etasq = satrec->eta * satrec->eta; + eeta = satrec->ecco * satrec->eta; + psisq = fabs(1.0 - etasq); + coef = qzms24 * pow(tsi, 4.0); + coef1 = coef / pow(psisq, 3.5); + cc2 = coef1 * satrec->no_unkozai * (satrec->ao * (1.0 + 1.5 * etasq + eeta * + (4.0 + etasq)) + 0.375 * satrec->j2 * tsi / psisq * satrec->con41 * + (8.0 + 3.0 * etasq * (8.0 + etasq))); + satrec->cc1 = satrec->bstar * cc2; + cc3 = 0.0; + if (satrec->ecco > 1.0e-4) + cc3 = -2.0 * coef * tsi * satrec->j3oj2 * satrec->no_unkozai * satrec->sinio / satrec->ecco; + satrec->x1mth2 = 1.0 - satrec->cosio2; + satrec->cc4 = 2.0* satrec->no_unkozai * coef1 * satrec->ao * satrec->omeosq * + (satrec->eta * (2.0 + 0.5 * etasq) + satrec->ecco * + (0.5 + 2.0 * etasq) - satrec->j2 * tsi / (satrec->ao * psisq) * + (-3.0 * satrec->con41 * (1.0 - 2.0 * eeta + etasq * + (1.5 - 0.5 * eeta)) + 0.75 * satrec->x1mth2 * + (2.0 * etasq - eeta * (1.0 + etasq)) * cos(2.0 * satrec->argpo))); + satrec->cc5 = 2.0 * coef1 * satrec->ao * satrec->omeosq * (1.0 + 2.75 * + (etasq + eeta) + eeta * etasq); + cosio4 = satrec->cosio2 * satrec->cosio2; + temp1 = 1.5 * satrec->j2 * pinvsq * satrec->no_unkozai; + temp2 = 0.5 * temp1 * satrec->j2 * pinvsq; + temp3 = -0.46875 * satrec->j4 * pinvsq * pinvsq * satrec->no_unkozai; + satrec->mdot = satrec->no_unkozai + 0.5 * temp1 * satrec->rteosq * satrec->con41 + 0.0625 * + temp2 * satrec->rteosq * (13.0 - 78.0 * satrec->cosio2 + 137.0 * cosio4); + satrec->argpdot = -0.5 * temp1 * satrec->con42 + 0.0625 * temp2 * + (7.0 - 114.0 * satrec->cosio2 + 395.0 * cosio4) + + temp3 * (3.0 - 36.0 * satrec->cosio2 + 49.0 * cosio4); + xhdot1 = -temp1 * satrec->cosio; + satrec->nodedot = xhdot1 + (0.5 * temp2 * (4.0 - 19.0 * satrec->cosio2) + + 2.0 * temp3 * (3.0 - 7.0 * satrec->cosio2)) * satrec->cosio; + xpidot = satrec->argpdot + satrec->nodedot; + satrec->omgcof = satrec->bstar * cc3 * cos(satrec->argpo); + satrec->xmcof = 0.0; + if (satrec->ecco > 1.0e-4) + satrec->xmcof = -x2o3 * coef * satrec->bstar / eeta; + satrec->nodecf = 3.5 * satrec->omeosq * xhdot1 * satrec->cc1; + satrec->t2cof = 1.5 * satrec->cc1; + // sgp4fix for divide by zero with xinco = 180 deg + if (fabs(satrec->cosio + 1.0) > 1.5e-12) + satrec->xlcof = -0.25 * satrec->j3oj2 * satrec->sinio * (3.0 + 5.0 * satrec->cosio) / (1.0 + satrec->cosio); + else + satrec->xlcof = -0.25 * satrec->j3oj2 * satrec->sinio * (3.0 + 5.0 * satrec->cosio) / temp4; + satrec->aycof = -0.5 * satrec->j3oj2 * satrec->sinio; + // sgp4fix use multiply for speed instead of pow + delmotemp = 1.0 + satrec->eta * cos(satrec->mo); + satrec->delmo = delmotemp * delmotemp * delmotemp; + satrec->sinmao = sin(satrec->mo); + satrec->x7thm1 = 7.0 * satrec->cosio2 - 1.0; + + /* --------------- deep space initialization ------------- */ + if ((2 * pi / satrec->no_unkozai) >= 225.0) + { + satrec->method = 'd'; + satrec->isimp = 1; + tc = 0.0; + satrec->inclm = satrec->inclo; + + dscom(epoch, satrec->ecco, satrec->argpo, tc, satrec->inclo, satrec->nodeo, satrec->no_unkozai,satrec); + + + satrec->ep=satrec->ecco; + satrec->inclp=satrec->inclo; + satrec->nodep=satrec->nodeo; + satrec->argpp=satrec->argpo; + satrec->mp=satrec->mo; + + + dpper(satrec->e3, satrec->ee2, satrec->peo, satrec->pgho, + satrec->pho, satrec->pinco, satrec->plo, satrec->se2, + satrec->se3, satrec->sgh2, satrec->sgh3, satrec->sgh4, + satrec->sh2, satrec->sh3, satrec->si2, satrec->si3, + satrec->sl2, satrec->sl3, satrec->sl4, satrec->t, + satrec->xgh2, satrec->xgh3, satrec->xgh4, satrec->xh2, + satrec->xh3, satrec->xi2, satrec->xi3, satrec->xl2, + satrec->xl3, satrec->xl4, satrec->zmol, satrec->zmos, satrec->init,satrec, + satrec->operationmode); + + + satrec->ecco=satrec->ep; + satrec->inclo=satrec->inclp; + satrec->nodeo=satrec->nodep; + satrec->argpo=satrec->argpp; + satrec->mo=satrec->mp; + + + satrec->argpm = 0.0; + satrec->nodem = 0.0; + satrec->mm = 0.0; + + dsinit(tc, xpidot, satrec); + } + + /* ----------- set variables if not deep space ----------- */ + if (satrec->isimp != 1) + { + cc1sq = satrec->cc1 * satrec->cc1; + satrec->d2 = 4.0 * satrec->ao * tsi * cc1sq; + temp = satrec->d2 * tsi * satrec->cc1 / 3.0; + satrec->d3 = (17.0 * satrec->ao + sfour) * temp; + satrec->d4 = 0.5 * temp * satrec->ao * tsi * (221.0 * satrec->ao + 31.0 * sfour) * satrec->cc1; + satrec->t3cof = satrec->d2 + 2.0 * cc1sq; + satrec->t4cof = 0.25 * (3.0 * satrec->d3 + satrec->cc1 * + (12.0 * satrec->d2 + 10.0 * cc1sq)); + satrec->t5cof = 0.2 * (3.0 * satrec->d4 + + 12.0 * satrec->cc1 * satrec->d3 + + 6.0 * satrec->d2 * satrec->d2 + + 15.0 * cc1sq * (2.0 * satrec->d2 + cc1sq)); + } + } // if omeosq = 0 ... + + /* finally propogate to zero epoch to initialize all others. */ + // sgp4fix take out check to let satellites process until they are actually below earth surface + // if(satrec->error == 0) + + + sgp4(satrec, 0.0, r, v); + + satrec->init = 'n'; + + //sgp4fix return boolean. satrec->error contains any error codes + return TRUE; + } // sgp4init + + /*----------------------------------------------------------------------------- + * + * procedure sgp4 + * + * this procedure is the sgp4 prediction model from space command. this is an + * updated and combined version of sgp4 and sdp4, which were originally + * published separately in spacetrack report #3. this version follows the + * methodology from the aiaa paper (2006) describing the history and + * development of the code. + * + * author : david vallado 719-573-2600 28 jun 2005 + * + * inputs : + * satrec - initialised structure from sgp4init() call. + * tsince - time since epoch (minutes) + * + * outputs : + * r - position vector km + * v - velocity km/sec + * return code - non-zero on error. + * 1 - mean elements, ecc >= 1.0 or ecc < -0.001 or a < 0.95 er + * 2 - mean motion less than 0.0 + * 3 - pert elements, ecc < 0.0 or ecc > 1.0 + * 4 - semi-latus rectum < 0.0 + * 5 - epoch elements are sub-orbital + * 6 - satellite has decayed + * + * locals : + * am - + * axnl, aynl - + * betal - + * cosim , sinim , cosomm , sinomm , cnod , snod , cos2u , + * sin2u , coseo1 , sineo1 , cosi , sini , cosip , sinip , + * cosisq , cossu , sinsu , cosu , sinu + * delm - + * delomg - + * dndt - + * eccm - + * emsq - + * ecose - + * el2 - + * eo1 - + * eccp - + * esine - + * argpm - + * argpp - + * omgadf -c + * pl - + * r - + * rtemsq - + * rdotl - + * rl - + * rvdot - + * rvdotl - + * su - + * t2 , t3 , t4 , tc + * tem5, temp , temp1 , temp2 , tempa , tempe , templ + * u , ux , uy , uz , vx , vy , vz + * inclm - inclination + * mm - mean anomaly + * nm - mean motion + * nodem - right asc of ascending node + * xinc - + * xincp - + * xl - + * xlm - + * mp - + * xmdf - + * xmx - + * xmy - + * nodedf - + * xnode - + * nodep - + * np - + * + * coupling : + * getgravconst- no longer used. Variables are conatined within satrec + * dpper + * dpspace + * + * references : + * hoots, roehrich, norad spacetrack report #3 1980 + * hoots, norad spacetrack report #6 1986 + * hoots, schumacher and glover 2004 + * vallado, crawford, hujsak, kelso 2006 + ----------------------------------------------------------------------------*/ + + bool sgp4 + ( + ElsetRec *satrec, double tsince, + double *r, double *v + ) + { + + double axnl, aynl, betal, cnod, + cos2u, coseo1, cosi, cosip, cosisq, cossu, cosu, + delm, delomg, ecose, el2, eo1, + esine, argpdf, pl, mrt = 0.0, + mvt, rdotl, rl, rvdot, rvdotl, + sin2u, sineo1, sini, sinip, sinsu, sinu, + snod, su, t2, t3, t4, tem5, temp, + temp1, temp2, tempa, tempe, templ, u, ux, + uy, uz, vx, vy, vz, + xinc, xincp, xl, xlm, + xmdf, xmx, xmy, nodedf, xnode, tc, + x2o3, vkmpersec, delmtemp; + + int ktr; + + /* ------------------ set mathematical constants --------------- */ + // sgp4fix divisor for divide by zero check on inclination + // the old check used 1.0 + cos(pi-1.0e-9), but then compared it to + // 1.5 e-12, so the threshold was changed to 1.5e-12 for consistency + const double temp4 = 1.5e-12; + x2o3 = 2.0 / 3.0; + // sgp4fix identify constants and allow alternate values + // getgravconst( whichconst, tumin, mu, radiusearthkm, xke, j2, j3, j4, j3oj2 ); + vkmpersec = satrec->radiusearthkm * satrec->xke / 60.0; + + /* --------------------- clear sgp4 error flag ----------------- */ + satrec->t = tsince; + satrec->error = 0; + + /* ------- update for secular gravity and atmospheric drag ----- */ + xmdf = satrec->mo + satrec->mdot * satrec->t; + argpdf = satrec->argpo + satrec->argpdot * satrec->t; + nodedf = satrec->nodeo + satrec->nodedot * satrec->t; + satrec->argpm = argpdf; + satrec->mm = xmdf; + t2 = satrec->t * satrec->t; + satrec->nodem = nodedf + satrec->nodecf * t2; + tempa = 1.0 - satrec->cc1 * satrec->t; + tempe = satrec->bstar * satrec->cc4 * satrec->t; + templ = satrec->t2cof * t2; + + delomg = 0; + delmtemp = 0; + delm = 0; + temp = 0; + t3 = 0; + t4 = 0; + mrt = 0; + + if (satrec->isimp != 1) + { + delomg = satrec->omgcof * satrec->t; + // sgp4fix use mutliply for speed instead of pow + delmtemp = 1.0 + satrec->eta * cos(xmdf); + delm = satrec->xmcof * + (delmtemp * delmtemp * delmtemp - + satrec->delmo); + temp = delomg + delm; + satrec->mm = xmdf + temp; + satrec->argpm = argpdf - temp; + t3 = t2 * satrec->t; + t4 = t3 * satrec->t; + tempa = tempa - satrec->d2 * t2 - satrec->d3 * t3 - + satrec->d4 * t4; + tempe = tempe + satrec->bstar * satrec->cc5 * (sin(satrec->mm) -satrec->sinmao); + templ = templ + satrec->t3cof * t3 + t4 * (satrec->t4cof + satrec->t * satrec->t5cof); + } + + + tc = 0; + satrec->nm = satrec->no_unkozai; + satrec->em = satrec->ecco; + satrec->inclm = satrec->inclo; + if (satrec->method == 'd') + { + tc = satrec->t; + dspace(tc,satrec); + } // if method = d + + if (satrec->nm <= 0.0) + { + satrec->error = 2; + // sgp4fix add return + return FALSE; + } + + satrec->am = pow((satrec->xke / satrec->nm), x2o3) * tempa * tempa; + satrec->nm = satrec->xke / pow(satrec->am, 1.5); + satrec->em = satrec->em - tempe; + + // fix tolerance for error recognition + // sgp4fix am is fixed from the previous nm check + if ((satrec->em >= 1.0) || (satrec->em < -0.001)/* || (am < 0.95)*/) + { + satrec->error = 1; + // sgp4fix to return if there is an error in eccentricity + return FALSE; + } + // sgp4fix fix tolerance to avoid a divide by zero + if (satrec->em < 1.0e-6) + satrec->em = 1.0e-6; + satrec->mm = satrec->mm + satrec->no_unkozai * templ; + xlm = satrec->mm + satrec->argpm + satrec->nodem; + satrec->emsq = satrec->em * satrec->em; + temp = 1.0 - satrec->emsq; + + satrec->nodem = fmod(satrec->nodem, twopi); + satrec->argpm = fmod(satrec->argpm, twopi); + xlm = fmod(xlm, twopi); + satrec->mm = fmod(xlm - satrec->argpm - satrec->nodem, twopi); + + // sgp4fix recover singly averaged mean elements + satrec->am = satrec->am; + satrec->em = satrec->em; + satrec->im = satrec->inclm; + satrec->Om = satrec->nodem; + satrec->om = satrec->argpm; + satrec->mm = satrec->mm; + satrec->nm = satrec->nm; + + /* ----------------- compute extra mean quantities ------------- */ + satrec->sinim = sin(satrec->inclm); + satrec->cosim = cos(satrec->inclm); + + /* -------------------- add lunar-solar periodics -------------- */ + satrec->ep = satrec->em; + xincp = satrec->inclm; + satrec->inclp = satrec->inclm; + satrec->argpp = satrec->argpm; + satrec->nodep = satrec->nodem; + satrec->mp = satrec->mm; + sinip = satrec->sinim; + cosip = satrec->cosim; + if (satrec->method == 'd') + { + dpper(satrec->e3, satrec->ee2, satrec->peo, satrec->pgho, + satrec->pho, satrec->pinco, satrec->plo, satrec->se2, + satrec->se3, satrec->sgh2, satrec->sgh3, satrec->sgh4, + satrec->sh2, satrec->sh3, satrec->si2, satrec->si3, + satrec->sl2, satrec->sl3, satrec->sl4, satrec->t, + satrec->xgh2, satrec->xgh3, satrec->xgh4, satrec->xh2, + satrec->xh3, satrec->xi2, satrec->xi3, satrec->xl2, + satrec->xl3, satrec->xl4, satrec->zmol, satrec->zmos, + 'n', satrec, satrec->operationmode); + + xincp = satrec->inclp; + if (xincp < 0.0) + { + xincp = -xincp; + satrec->nodep = satrec->nodep + pi; + satrec->argpp = satrec->argpp - pi; + } + if ((satrec->ep < 0.0) || (satrec->ep > 1.0)) + { + satrec->error = 3; + // sgp4fix add return + return FALSE; + } + } // if method = d + + /* -------------------- long period periodics ------------------ */ + if (satrec->method == 'd') + { + sinip = sin(xincp); + cosip = cos(xincp); + satrec->aycof = -0.5*satrec->j3oj2*sinip; + // sgp4fix for divide by zero for xincp = 180 deg + if (fabs(cosip + 1.0) > 1.5e-12) + satrec->xlcof = -0.25 * satrec->j3oj2 * sinip * (3.0 + 5.0 * cosip) / (1.0 + cosip); + else + satrec->xlcof = -0.25 * satrec->j3oj2 * sinip * (3.0 + 5.0 * cosip) / temp4; + } + axnl = satrec->ep * cos(satrec->argpp); + temp = 1.0 / (satrec->am * (1.0 - satrec->ep * satrec->ep)); + aynl = satrec->ep* sin(satrec->argpp) + temp * satrec->aycof; + xl = satrec->mp + satrec->argpp + satrec->nodep + temp * satrec->xlcof * axnl; + + /* --------------------- solve kepler's equation --------------- */ + u = fmod(xl - satrec->nodep, twopi); + eo1 = u; + tem5 = 9999.9; + ktr = 1; + sineo1 = 0; + coseo1 = 0; + // sgp4fix for kepler iteration + // the following iteration needs better limits on corrections + while ((fabs(tem5) >= 1.0e-12) && (ktr <= 10)) + { + sineo1 = sin(eo1); + coseo1 = cos(eo1); + tem5 = 1.0 - coseo1 * axnl - sineo1 * aynl; + tem5 = (u - aynl * coseo1 + axnl * sineo1 - eo1) / tem5; + if (fabs(tem5) >= 0.95) + tem5 = tem5 > 0.0 ? 0.95 : -0.95; + eo1 = eo1 + tem5; + ktr = ktr + 1; + } + + /* ------------- short period preliminary quantities ----------- */ + ecose = axnl*coseo1 + aynl*sineo1; + esine = axnl*sineo1 - aynl*coseo1; + el2 = axnl*axnl + aynl*aynl; + pl = satrec->am*(1.0 - el2); + if (pl < 0.0) + { + satrec->error = 4; + // sgp4fix add return + return FALSE; + } + else + { + rl = satrec->am * (1.0 - ecose); + rdotl = sqrt(satrec->am) * esine / rl; + rvdotl = sqrt(pl) / rl; + betal = sqrt(1.0 - el2); + temp = esine / (1.0 + betal); + sinu = satrec->am / rl * (sineo1 - aynl - axnl * temp); + cosu = satrec->am / rl * (coseo1 - axnl + aynl * temp); + su = atan2(sinu, cosu); + sin2u = (cosu + cosu) * sinu; + cos2u = 1.0 - 2.0 * sinu * sinu; + temp = 1.0 / pl; + temp1 = 0.5 * satrec->j2 * temp; + temp2 = temp1 * temp; + + /* -------------- update for short period periodics ------------ */ + if (satrec->method == 'd') + { + cosisq = cosip * cosip; + satrec->con41 = 3.0*cosisq - 1.0; + satrec->x1mth2 = 1.0 - cosisq; + satrec->x7thm1 = 7.0*cosisq - 1.0; + } + mrt = rl * (1.0 - 1.5 * temp2 * betal * satrec->con41) + + 0.5 * temp1 * satrec->x1mth2 * cos2u; + su = su - 0.25 * temp2 * satrec->x7thm1 * sin2u; + xnode = satrec->nodep + 1.5 * temp2 * cosip * sin2u; + xinc = xincp + 1.5 * temp2 * cosip * sinip * cos2u; + mvt = rdotl - satrec->nm * temp1 * satrec->x1mth2 * sin2u / satrec->xke; + rvdot = rvdotl + satrec->nm * temp1 * (satrec->x1mth2 * cos2u + + 1.5 * satrec->con41) / satrec->xke; + + /* --------------------- orientation vectors ------------------- */ + sinsu = sin(su); + cossu = cos(su); + snod = sin(xnode); + cnod = cos(xnode); + sini = sin(xinc); + cosi = cos(xinc); + xmx = -snod * cosi; + xmy = cnod * cosi; + ux = xmx * sinsu + cnod * cossu; + uy = xmy * sinsu + snod * cossu; + uz = sini * sinsu; + vx = xmx * cossu - cnod * sinsu; + vy = xmy * cossu - snod * sinsu; + vz = sini * cossu; + + /* --------- position and velocity (in km and km/sec) ---------- */ + r[0] = (mrt * ux)* satrec->radiusearthkm; + r[1] = (mrt * uy)* satrec->radiusearthkm; + r[2] = (mrt * uz)* satrec->radiusearthkm; + v[0] = (mvt * ux + rvdot * vx) * vkmpersec; + v[1] = (mvt * uy + rvdot * vy) * vkmpersec; + v[2] = (mvt * uz + rvdot * vz) * vkmpersec; + } // if pl > 0 + + // sgp4fix for decaying satellites + if (mrt < 1.0) + { + satrec->error = 6; + return FALSE; + } + + return TRUE; + } // sgp4 + + + + /* ----------------------------------------------------------------------------- + * + * function getgravconst + * + * this function gets constants for the propagator. note that mu is identified to + * facilitiate comparisons with newer models. the common useage is wgs72. + * + * author : david vallado 719-573-2600 21 jul 2006 + * + * inputs : + * whichconst - which set of constants to use wgs72old, wgs72, wgs84 + * + * outputs : + * tumin - minutes in one time unit + * mu - earth gravitational parameter + * radiusearthkm - radius of the earth in km + * xke - reciprocal of tumin + * j2, j3, j4 - un-normalized zonal harmonic values + * j3oj2 - j3 divided by j2 + * + * locals : + * + * coupling : + * none + * + * references : + * norad spacetrack report #3 + * vallado, crawford, hujsak, kelso 2006 + --------------------------------------------------------------------------- */ + + void getgravconst(int whichconst, ElsetRec *rec) + { + rec->whichconst = whichconst; + switch (whichconst) + { + // -- wgs-72 low precision str#3 constants -- + case wgs72old: + rec->mu = 398600.79964; // in km3 / s2 + rec->radiusearthkm = 6378.135; // km + rec->xke = 0.0743669161; // reciprocal of tumin + rec->tumin = 1.0 / rec->xke; + rec->j2 = 0.001082616; + rec->j3 = -0.00000253881; + rec->j4 = -0.00000165597; + rec->j3oj2 = rec->j3 / rec->j2; + break; + // ------------ wgs-72 constants ------------ + case wgs72: + rec->mu = 398600.8; // in km3 / s2 + rec->radiusearthkm = 6378.135; // km + rec->xke = 60.0 / sqrt(rec->radiusearthkm*rec->radiusearthkm*rec->radiusearthkm / rec->mu); + rec->tumin = 1.0 / rec->xke; + rec->j2 = 0.001082616; + rec->j3 = -0.00000253881; + rec->j4 = -0.00000165597; + rec->j3oj2 = rec->j3 / rec->j2; + break; + default: + case wgs84: + // ------------ wgs-84 constants ------------ + rec->mu = 398600.5; // in km3 / s2 + rec->radiusearthkm = 6378.137; // km + rec->xke = 60.0 / sqrt(rec->radiusearthkm*rec->radiusearthkm*rec->radiusearthkm / rec->mu); + rec->tumin = 1.0 / rec->xke; + rec->j2 = 0.00108262998905; + rec->j3 = -0.00000253215306; + rec->j4 = -0.00000161098761; + rec->j3oj2 = rec->j3 / rec->j2; + break; + } + + } // getgravconst + + /* ----------------------------------------------------------------------------- + * + * function gstime + * + * this function finds the greenwich sidereal time. + * + * author : david vallado 719-573-2600 1 mar 2001 + * + * inputs description range / units + * jdut1 - julian date in ut1 days from 4713 bc + * + * outputs : + * gstime - greenwich sidereal time 0 to 2pi rad + * + * locals : + * temp - temporary variable for doubles rad + * tut1 - julian centuries from the + * jan 1, 2000 12 h epoch (ut1) + * + * coupling : + * none + * + * references : + * vallado 2013, 187, eq 3-45 + * --------------------------------------------------------------------------- */ + + double gstime(double jdut1) + { + double temp, tut1; + + tut1 = (jdut1 - 2451545.0) / 36525.0; + temp = -6.2e-6* tut1 * tut1 * tut1 + 0.093104 * tut1 * tut1 + + (876600.0 * 3600 + 8640184.812866) * tut1 + 67310.54841; // sec + temp = fmod(temp * deg2rad / 240.0, twopi); //360/86400 = 1/240, to deg, to rad + + // ------------------------ check quadrants --------------------- + if (temp < 0.0) + temp += twopi; + + return temp; + } // gstime + + /* ----------------------------------------------------------------------------- + * + * procedure jday + * + * this procedure finds the julian date given the year, month, day, and time. + * the julian date is defined by each elapsed day since noon, jan 1, 4713 bc. + * + * algorithm : calculate the answer in one step for efficiency + * + * author : david vallado 719-573-2600 1 mar 2001 + * + * inputs description range / units + * year - year 1900 .. 2100 + * mon - month 1 .. 12 + * day - day 1 .. 28,29,30,31 + * hr - universal time hour 0 .. 23 + * min - universal time min 0 .. 59 + * sec - universal time sec 0.0 .. 59.999 + * + * outputs : + * jd - julian date days from 4713 bc + * jdfrac - julian date fraction into day days from 4713 bc + * + * locals : + * none. + * + * coupling : + * none. + * + * references : + * vallado 2013, 183, alg 14, ex 3-4 + * --------------------------------------------------------------------------- */ + + void jday(int year, int mon, int day, int hr, int minute, double sec, double *jd, double *jdfrac) + { + + *jd = 367.0 * year - + floor((7 * (year + floor((mon + 9) / 12.0))) * 0.25) + + floor(275 * mon / 9.0) + + day + 1721013.5; // use - 678987.0 to go to mjd directly + *jdfrac = (sec + minute * 60.0 + hr * 3600.0) / 86400.0; + + // check that the day and fractional day are correct + if (fabs(*jdfrac) > 1.0) + { + double dtt = floor(*jdfrac); + *jd = *jd + dtt; + *jdfrac = *jdfrac - dtt; + } + + return; + } // jday diff --git a/Apps/SatelliteTracker/main/Source/sgp4/SGP4.h b/Apps/SatelliteTracker/main/Source/sgp4/SGP4.h new file mode 100644 index 0000000..7e3f4d4 --- /dev/null +++ b/Apps/SatelliteTracker/main/Source/sgp4/SGP4.h @@ -0,0 +1,279 @@ +#ifndef __sgp4header__ +#define __sgp4header__ + +#define wgs72old 1 +#define wgs72 2 +#define wgs84 3 +#define pi 3.14159265358979323846 + +#define twopi (2.0*pi) +#define deg2rad (pi/180.0) + +#ifdef __STDC_VERSION__ +#include +#else +typedef int bool; +#endif +#define TRUE 1 +#define FALSE 0 + +/** + * This class implements the elsetrec data type from Vallado's SGP4 code. + * + * From SGP4.h + * #define SGP4Version "SGP4 Version 2016-03-09" + * + * @author aholinch + * + */ +typedef struct ElsetRec { + int whichconst; + char satid[6]; + int epochyr; + int epochtynumrev; + int error; + char operationmode; + char init; + char method; + double a; + double altp; + double alta; + double epochdays; + double jdsatepoch; + double jdsatepochF; + double nddot; + double ndot; + double bstar; + double rcse; + double inclo; + double nodeo; + double ecco; + double argpo; + double mo; + double no_kozai; + + // sgp4fix add new variables from tle + char classification; + char intldesg[12]; + int ephtype; + long elnum; + long revnum; + + // sgp4fix add unkozai'd variable + double no_unkozai; + + // sgp4fix add singly averaged variables + double am; + double em; + double im; + double Om; + double om; + double mm; + double nm; + double t; + + // sgp4fix add constant parameters to eliminate mutliple calls during execution + double tumin; + double mu; + double radiusearthkm; + double xke; + double j2; + double j3; + double j4; + double j3oj2; + + // Additional elements to capture relevant TLE and object information: + long dia_mm; // RSO dia in mm + double period_sec; // Period in seconds + char active; // "Active S/C" flag (0=n, 1=y) + char not_orbital; // "Orbiting S/C" flag (0=n, 1=y) + double rcs_m2; // "RCS (m^2)" storage + + // temporary variables because the original authors call the same method with different variables + double ep; + double inclp; + double nodep; + double argpp; + double mp; + + + int isimp; + double aycof; + double con41; + double cc1; + double cc4; + double cc5; + double d2; + double d3; + double d4; + double delmo; + double eta; + double argpdot; + double omgcof; + double sinmao; + double t2cof; + double t3cof; + double t4cof; + double t5cof; + double x1mth2; + double x7thm1; + double mdot; + double nodedot; + double xlcof; + double xmcof; + double nodecf; + + // deep space + int irez; + double d2201; + double d2211; + double d3210; + double d3222; + double d4410; + double d4422; + double d5220; + double d5232; + double d5421; + double d5433; + double dedt; + double del1; + double del2; + double del3; + double didt; + double dmdt; + double dnodt; + double domdt; + double e3; + double ee2; + double peo; + double pgho; + double pho; + double pinco; + double plo; + double se2; + double se3; + double sgh2; + double sgh3; + double sgh4; + double sh2; + double sh3; + double si2; + double si3; + double sl2; + double sl3; + double sl4; + double gsto; + double xfact; + double xgh2; + double xgh3; + double xgh4; + double xh2; + double xh3; + double xi2; + double xi3; + double xl2; + double xl3; + double xl4; + double xlamo; + double zmol; + double zmos; + double atime; + double xli; + double xni; + double snodm; + double cnodm; + double sinim; + double cosim; + double sinomm; + double cosomm; + double day; + double emsq; + double gam; + double rtemsq; + double s1; + double s2; + double s3; + double s4; + double s5; + double s6; + double s7; + double ss1; + double ss2; + double ss3; + double ss4; + double ss5; + double ss6; + double ss7; + double sz1; + double sz2; + double sz3; + double sz11; + double sz12; + double sz13; + double sz21; + double sz22; + double sz23; + double sz31; + double sz32; + double sz33; + double z1; + double z2; + double z3; + double z11; + double z12; + double z13; + double z21; + double z22; + double z23; + double z31; + double z32; + double z33; + double argpm; + double inclm; + double nodem; + double dndt; + double eccsq; + + // for initl + double ainv; + double ao; + double con42; + double cosio; + double cosio2; + double omeosq; + double posq; + double rp; + double rteosq; + double sinio; +} ElsetRec; // end struct + + +void dpper ( double e3, double ee2, double peo, double pgho, double pho, + double pinco, double plo, double se2, double se3, double sgh2, + double sgh3, double sgh4, double sh2, double sh3, double si2, + double si3, double sl2, double sl3, double sl4, double t, + double xgh2, double xgh3, double xgh4, double xh2, double xh3, + double xi2, double xi3, double xl2, double xl3, double xl4, + double zmol, double zmos, char init, + ElsetRec *rec, char opsmode); + +void dscom ( double epoch, double ep, double argpp, double tc, double inclp, + double nodep, double np, ElsetRec *rec); + +void dsinit ( double tc, double xpidot, ElsetRec *rec); + +void dspace(double tc, ElsetRec *rec); + +void initl(double epoch, ElsetRec *rec); + +bool sgp4init ( char opsmode,ElsetRec *satrec); + +bool sgp4 ( ElsetRec *satrec, double tsince, double *r, double *v); + +void getgravconst(int whichconst, ElsetRec *rec); + +double gstime(double jdut1); + +void jday(int year, int mon, int day, int hr, int minute, double sec, double *jd, double *jdfrac); + +#endif diff --git a/Apps/SatelliteTracker/manifest.properties b/Apps/SatelliteTracker/manifest.properties new file mode 100644 index 0000000..c45e96b --- /dev/null +++ b/Apps/SatelliteTracker/manifest.properties @@ -0,0 +1,11 @@ +manifest.version=0.3 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +id=tactility.satellitetracker +version.name=0.1.0 +version.code=1 +name=Satellite Tracker +description=World map with live satellite positions and passes +app.0.id=tactility.satellitetracker +app.0.name=Satellite Tracker +app.0.binary=satellitetracker diff --git a/Apps/SatelliteTracker/tools/gen_world_map.py b/Apps/SatelliteTracker/tools/gen_world_map.py new file mode 100644 index 0000000..e3c8b9d --- /dev/null +++ b/Apps/SatelliteTracker/tools/gen_world_map.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Generates main/Source/WorldMap.c from Natural Earth land polygons. + +Input: ne_110m_land.geojson from https://github.com/nvkelso/natural-earth-vector (geojson/ folder). +Natural Earth data is in the public domain. + +Usage: python gen_world_map.py ne_110m_land.geojson ../main/Source/WorldMap.c +""" +import json +import sys + + +def rings_of(geometry): + if geometry["type"] == "Polygon": + return geometry["coordinates"] + if geometry["type"] == "MultiPolygon": + return [ring for polygon in geometry["coordinates"] for ring in polygon] + return [] + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + return 1 + data = json.load(open(sys.argv[1], encoding="utf-8")) + rings = [] + for feature in data["features"]: + for ring in rings_of(feature["geometry"]): + points = [] + for lon, lat in ring: + p = (round(lon * 100), round(lat * 100)) + if not points or points[-1] != p: + points.append(p) + if len(points) > 1 and points[0] == points[-1]: + points.pop() + if len(points) >= 3: + rings.append(points) + + total = sum(len(r) for r in rings) + with open(sys.argv[2], "w", newline="\n") as out: + out.write("// Generated by tools/gen_world_map.py from Natural Earth ne_110m_land (public domain). Do not edit.\n") + out.write('#include "WorldMap.h"\n\n') + out.write(f"const uint16_t WORLD_MAP_RING_COUNT = {len(rings)};\n\n") + out.write("const uint16_t WORLD_MAP_RING_LENGTHS[] = {\n") + for i in range(0, len(rings), 16): + out.write(" " + ", ".join(str(len(r)) for r in rings[i:i + 16]) + ",\n") + out.write("};\n\n") + out.write(f"const int16_t WORLD_MAP_POINTS[{total * 2}] = {{\n") + flat = [v for r in rings for p in r for v in p] + for i in range(0, len(flat), 16): + out.write(" " + ", ".join(str(v) for v in flat[i:i + 16]) + ",\n") + out.write("};\n") + print(f"{len(rings)} rings, {total} points") + return 0 + + +if __name__ == "__main__": + sys.exit(main())