Hardware components | ||||||
| × | 1 | ||||
| × | 4 | ||||
| × | 1 | ||||
| × | 4 | ||||
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 | ||||
| × | 4 | ||||
| × | 1 | ||||
| × | 2 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 2 | ||||
![]() |
| × | 1 | |||
Software apps and online services | ||||||
![]() |
| |||||
| ||||||
Hand tools and fabrication machines | ||||||
| ||||||
| ||||||
| ||||||
| ||||||
![]() |
| |||||
This small circuit uses a GPS module to determine its location on Earth by getting the exact latitude, longitude, and time (to atomic-clock accuracy). With a bit of physics, it calculates the positions of all eight planets (and Pluto). Nine RGB LEDs light up when Mercury, Venus, our Moon, Mars, Jupiter, Saturn, Uranus, Neptune, or Pluto are overhead. If a planet is below the horizon, its LED stays off.
The circuit has a few main parts: an ATtiny3216; a latching system to save power; a GPS module; nine RGB LEDs; and a battery. Many of the parts have drop-in replacements, and the small (0603) capacitors and resistors are generic.
I chose the ATtiny3216 because I wanted to solve the planet problem using only a sparse lookup table, some physics, and GPS-provided latitude, longitude, and time. It has good community support, is cheap, easy to flash, and supports I²C (needed for the GPS). I went with the larger ATtiny3216 over the ATtiny1616 (same footprint) because I was worried about memory and RAM. After building it, I think the ATtiny1616 probably would have been enough.
The AO3413 and MMBT3904 form the latching circuit. Pressing the ON button briefly supplies power, then the MCU latches itself on until the job is done.
The 220 µF capacitor and SAM-M10Q support the GPS and handle inrush current at startup. The SAM-M10Q is expensive (~$15 USD) and often sold out, but I can usually find it by checking the major distributors.
The LS14250 is a weird 3.6 V battery I found by googling “what is the smallest, most powerful battery.” The IRLML6402 protects the circuit if the battery is inserted backward. Since the GPS works best at 3.0 V, I used a TPS7A0230 LDO (with many drop-in replacements).
The nine RGB LEDs can get bright and draw a lot of current. Using the open-source tinyNeoPixel.h library, I keep brightness under ~50. Don’t turn all nine LEDs to full white (R=255, G=255, B=255). Instead, white should look more like (R=20, G=20, B=20).
I searched for the lowest-power, high-quality GPS module and landed on the SAM-M10Q. SparkFun had example code (major win). I designed the entire board around that GPS, built it, and flashed SparkFun’s code.
Then I hit a wall. The code was far too big for the ATtiny3216.
I spent weeks (maybe months?) trying to slim it down. What made this worse was that I had no serial output - no “when in doubt, print it out.” I only had nine RGB LEDs to communicate with. I ended up inventing my own messy diagnostic language: blink red twice if a packet arrived, blink green if the ring buffer is full, etc.
I finally had code I was confident in… and it still didn’t work. Gen AI wasn’t much help either. Eventually I discovered that adding small delays to the ATtiny3216’s I²C functions made everything behave.
The result is an extremely lightweight C++ implementation (~100 lines) that reliably talks to the SAM-M10Q over I²C on an ATtiny3216.
The I²C code lives in SAMM10Q.h.
The board needs a very lightweight ephemeris. The ATtiny3216 isn’t running anything big. The approach below stores the Sun, Moon, and nine planets in a ~10 KB ephemeris covering one year, with ~99% accuracy using physics approximations.
I had no background in orbital mechanics until I read Fundamentals of Astrodynamics (Bate, Mueller, White), which helped a lot. I’m still a total newbie but this is just my attempt.
The basic idea (99% accurate):
- Use Python and Skyfield to grab GCRS state vectors (position + velocity) for the planets, Moon, and Sun.
- Store these sparse state vectors as the ephemeris.
- On the MCU, find the nearest saved timestamp.
- Use the time delta to propagate positions forward or backward using Newton’s F = MA.
- Use Euler’s method for the planets.
- Propagate Earth too, then subtract Earth’s position to move the reference frame back to Earth-centered.
- Rotate the result to match Earth’s current spin (once every 86, 164.1 seconds).
- Project onto Earth and use a dot product to decide if the object is overhead.
It’s wild that a $1 MCU can have a decent idea of where we are in space and where you are on Earth.
The Moon (the hard part)Ironically, the Moon was the hardest. Outer planets barely need propagation at all. Mars and Venus need Euler to get decent accuracy. But the Moon moves fast, and its dominant gravity source is Earth, not the Sun.
For the Moon, I use smaller time steps and Runge–Kutta 4 (RK4). It’s way slower than Euler, so I only run RK4 once per update — but it gets the Moon back to >99% accuracy.
If you look at the C++ code, the Moon has its own special function:propagate_moon_grcs_fine().
Run build_ephemeris_tables.py first. It samples planet positions every ~6 weeks for a year using Skyfield and generates: .npy files and ephemeris_tables.h (used by the C++ code)Drop that header into your Arduino project. There’s also a test script that randomly checks accuracy against Skyfield truth.
Run it → get a header → include it → optionally test it.
This project is a good example of a bare chip that is powerful and easy to program.
This blank chip can be programmed using a UPDI programmer in the Arduino IDE environment. Follow the excellent steps provided by Adafruit.
On the back of this board, there are three pads: Vin, Gnd, and UPDI programmer. I solder three separate male headers onto these pads. I use clay to help align the headers when I solder. Look at the pics above. Soldering and Assembly step below for more detailed build instructions.
Follow the directions from Adafruit, but I will summarize:
- In Arduino, add the following URL to your Boards Manager preferences: http://drazzy.com/package_drazzy.com_index.json
- Install megaTinyCore from the Boards Manager.
Under Tools, select:
- Board: megaTinyCore: ATtiny3216
- Chip: ATtiny3216
- Clock: 10 MHz
- Programmer: SerialUPDI - SLOW: 57600 baud
- Under Tools, select:Board: megaTinyCore: ATtiny3216Chip: ATtiny3216Clock: 10 MHzProgrammer: SerialUPDI - SLOW: 57600 baud
- Finally, go to Sketch > Upload Using Programmer. Upload a blank sketch to confirm it works.
You should see your UPDI friend blinking and the code being successfully uploaded to the board.
Align the stencil over your board. Spread solder paste over the holes and use a squeegee to push the paste through. Make sure the stencil is flush against the board and everything is nice and clean.
Next, carefully place all the components in their correct spots.
Watch the diode orientation(s) and for the 9 RGB LEDs (the WS2812B). On the PCB, there are two lines around the WS2812B's area. One line has an extra 90-degree bent tip. The side with the bent line should face the “dot” side of the WS2812B. See included pictures.
Reflowing with a Hot Plate
Here’s my process:
- I place the board, with all components and solder paste, on the hot plate while it's off.
- Then I turn it on and wait for the temperature to reach 215°C.
- Around 200°C, you'll see the solder start to melt.
- Once it hits 215°C, turn the hot plate off.
- Carefully move the board (I use needle-nose pliers) onto a cooling surface (like a cookie sheet).
Post-Reflow Cleanup
Check for solder bridges, unintended blobs connecting pads. If you spot any:
- Dab some flux on the bridge.
- Use a hot soldering iron and gently drag across the area.
- Clean off the flux using isopropyl alcohol and a Q-tip. Burnt flux is sticky and turns yellow-brown.
Also, keep your soldering iron tip clean by wiping it with some solder and a damp sponge.
Soldering the PCB's Back Components
The components on the back need to be soldered by hand. Here’s a great video if you need a refresher.
Press the ON button and wait for GPS lock (the first time can take a few minutes). The board flashes red while searching. Once locked, the nine LEDs show which planets are overhead, with >99% accuracy.
The GPS module also has a small blinking LED — when that’s flashing, you’re cooking.
Press the second button to shut everything off.
The circuit uses a high-side MOSFET latch. The MCU holds itself on until it decides to shut down. Because of the pull-ups and lack of a direct ground path, the idle current should be extremely small (microamps). Worst case, it’s still very low.
GPS doesn’t work well indoors. Your phone cheats using WiFi and cell towers — this board doesn’t. Once you get a good outdoor lock, future locks are much faster thanks to the GPS’s low-power backup system.
Warning, The LS14250 battery is not meant to supply 100+ mA. Nine RGB LEDs at full white will probabaly exceed that. Don’t do it. I keep brightness around |RGB| ~ 30.
// ublox gps code - big thanks to sparkfun
// https://github.com/sparkfun/SparkFun_u-blox_SAM-M10Q
// needed to add extra delays to work with attiny3216
#include <Wire.h>
#define UBLOX_ADDR 0x42
const uint8_t UBX_SYNCH_1 = 0xB5;
const uint8_t UBX_SYNCH_2 = 0x62;
const uint8_t UBX_CLASS_NAV = 0x01;
const uint8_t UBX_NAV_PVT = 0x07;
typedef struct {
uint8_t fixType;
uint8_t numSV;
int32_t lon;
int32_t lat;
uint32_t unixTime;
bool dataReceived;
} GPSData_t;
extern GPSData_t gpsData;
bool gpsFound() {
Wire.beginTransmission(UBLOX_ADDR);
uint8_t error = Wire.endTransmission();
delay(20);
return (error == 0);
}
uint16_t getBytesAvailable() {
Wire.beginTransmission(UBLOX_ADDR);
Wire.write(0xFD);
uint8_t err = Wire.endTransmission(false);
if (err != 0) {
Wire.endTransmission();
delay(10);
return 0;
}
delay(5);
uint8_t bytesRead = Wire.requestFrom(UBLOX_ADDR, (uint8_t)2, (uint8_t)1);
delay(3);
if (bytesRead < 2) return 0;
uint8_t msb = Wire.read();
uint8_t lsb = Wire.read();
delay(2);
return ((uint16_t)msb << 8) | lsb;
}
uint16_t readBytesChunk(uint8_t *buffer, uint16_t length) {
if (length > 32) length = 32;
Wire.beginTransmission(UBLOX_ADDR);
Wire.write(0xFF);
uint8_t err = Wire.endTransmission(false);
if (err != 0) {
Wire.endTransmission();
delay(10);
return 0;
}
delay(5);
uint8_t bytesRead = Wire.requestFrom(UBLOX_ADDR, (uint8_t)length, (uint8_t)1);
delay(3);
for (uint8_t i = 0; i < bytesRead; i++) {
if (Wire.available()) buffer[i] = Wire.read();
}
delay(2);
return bytesRead;
}
// Convert date/time to Unix timestamp (UTC)
uint32_t dateTimeToUnix(uint16_t year, uint8_t month, uint8_t day,
uint8_t hour, uint8_t min, uint8_t sec)
{
const uint8_t daysInMonth[] = {31,28,31,30,31,30,31,31,30,31,30,31};
uint32_t days = 0;
// Days from 1970 to current year
for (uint16_t y = 1970; y < year; y++) {
days += 365;
if ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)) {
days++; // leap year
}
}
// Days from months this year
for (uint8_t m = 1; m < month; m++) {
days += daysInMonth[m - 1];
if (m == 2) { // February leap day
if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
days++;
}
}
}
days += (day - 1);
return days * 86400UL + hour * 3600UL + min * 60UL + sec;
}
bool readUBXMessage() {
uint16_t available = getBytesAvailable();
if (available < 8) return false;
uint8_t buffer[120];
uint16_t bufferIndex = 0;
while (available > 0 && bufferIndex < 100) {
uint16_t toRead = (available > 32) ? 32 : available;
uint16_t read = readBytesChunk(&buffer[bufferIndex], toRead);
if (read == 0) break;
bufferIndex += read;
available -= read;
delay(10);
}
if (bufferIndex < 10) return false;
int syncPos = -1;
for (int i = 0; i < bufferIndex - 1; i++) {
if (buffer[i] == UBX_SYNCH_1 && buffer[i+1] == UBX_SYNCH_2) {
syncPos = i;
break;
}
}
if (syncPos < 0 || syncPos + 8 > bufferIndex) return false;
uint8_t msgClass = buffer[syncPos + 2];
uint8_t msgID = buffer[syncPos + 3];
uint16_t payloadLen = buffer[syncPos + 4] | ((uint16_t)buffer[syncPos + 5] << 8);
if (payloadLen > 100 || syncPos + 8 + payloadLen > bufferIndex) return false;
uint8_t checksumA = 0, checksumB = 0;
for (int i = 2; i < 6 + payloadLen; i++) {
checksumA += buffer[syncPos + i];
checksumB += checksumA;
}
uint8_t ckA = buffer[syncPos + 6 + payloadLen];
uint8_t ckB = buffer[syncPos + 7 + payloadLen];
if (checksumA != ckA || checksumB != ckB) return false;
// NAV-PVT message
if (msgClass == UBX_CLASS_NAV && msgID == UBX_NAV_PVT && payloadLen == 92) {
int p = syncPos + 6;
uint16_t year = buffer[p + 4] | ((uint16_t)buffer[p + 5] << 8);
uint8_t month = buffer[p + 6];
uint8_t day = buffer[p + 7];
uint8_t hour = buffer[p + 8];
uint8_t min = buffer[p + 9];
uint8_t sec = buffer[p + 10];
gpsData.unixTime = dateTimeToUnix(year, month, day, hour, min, sec);
gpsData.fixType = buffer[p + 20];
gpsData.numSV = buffer[p + 23];
gpsData.lon = (int32_t)(
buffer[p + 24] |
((uint32_t)buffer[p + 25] << 8) |
((uint32_t)buffer[p + 26] << 16) |
((uint32_t)buffer[p + 27] << 24));
gpsData.lat = (int32_t)(
buffer[p + 28] |
((uint32_t)buffer[p + 29] << 8) |
((uint32_t)buffer[p + 30] << 16) |
((uint32_t)buffer[p + 31] << 24));
gpsData.dataReceived = true;
return true;
}
return false;
}
// static uint8_t ubxBuffer[120];
// bool readUBXMessage() {
// uint16_t available = getBytesAvailable();
// if (available < 8) return false;
// // uint8_t ubxBuffer[120];
// uint16_t bufferIndex = 0;
// // while (available > 0 && bufferIndex < 100) {
// while (available > 0 && bufferIndex < sizeof(ubxBuffer)) {
// uint16_t toRead = (available > 32) ? 32 : available;
// uint16_t read = readBytesChunk(&ubxBuffer[bufferIndex], toRead);
// if (read == 0) break;
// bufferIndex += read;
// available -= read;
// delay(10);
// }
// if (bufferIndex < 10) return false;
// int syncPos = -1;
// for (int i = 0; i < bufferIndex - 1; i++) {
// if (ubxBuffer[i] == UBX_SYNCH_1 && ubxBuffer[i+1] == UBX_SYNCH_2) {
// syncPos = i;
// break;
// }
// }
// if (syncPos < 0 || syncPos + 8 > bufferIndex) return false;
// uint8_t msgClass = ubxBuffer[syncPos + 2];
// uint8_t msgID = ubxBuffer[syncPos + 3];
// uint16_t payloadLen = ubxBuffer[syncPos + 4] | ((uint16_t)ubxBuffer[syncPos + 5] << 8);
// if (payloadLen > 100 || syncPos + 8 + payloadLen > bufferIndex) return false;
// uint8_t checksumA = 0, checksumB = 0;
// for (int i = 2; i < 6 + payloadLen; i++) {
// checksumA += ubxBuffer[syncPos + i];
// checksumB += checksumA;
// }
// uint8_t ckA = ubxBuffer[syncPos + 6 + payloadLen];
// uint8_t ckB = ubxBuffer[syncPos + 7 + payloadLen];
// if (checksumA != ckA || checksumB != ckB) return false;
// // NAV-PVT message
// if (msgClass == UBX_CLASS_NAV && msgID == UBX_NAV_PVT && payloadLen == 92) {
// int p = syncPos + 6;
// uint16_t year = ubxBuffer[p + 4] | ((uint16_t)ubxBuffer[p + 5] << 8);
// uint8_t month = ubxBuffer[p + 6];
// uint8_t day = ubxBuffer[p + 7];
// uint8_t hour = ubxBuffer[p + 8];
// uint8_t min = ubxBuffer[p + 9];
// uint8_t sec = ubxBuffer[p + 10];
// gpsData.unixTime = dateTimeToUnix(year, month, day, hour, min, sec);
// gpsData.fixType = ubxBuffer[p + 20];
// gpsData.numSV = ubxBuffer[p + 23];
// gpsData.lon = (int32_t)(
// ubxBuffer[p + 24] |
// ((uint32_t)ubxBuffer[p + 25] << 8) |
// ((uint32_t)ubxBuffer[p + 26] << 16) |
// ((uint32_t)ubxBuffer[p + 27] << 24));
// gpsData.lat = (int32_t)(
// ubxBuffer[p + 28] |
// ((uint32_t)ubxBuffer[p + 29] << 8) |
// ((uint32_t)ubxBuffer[p + 30] << 16) |
// ((uint32_t)ubxBuffer[p + 31] << 24));
// gpsData.dataReceived = true;
// return true;
// }
// return false;
// }
bool configureNAVPVT() {
delay(200);
uint8_t cfg[] = {
0xB5, 0x62, 0x06, 0x01, 0x08, 0x00,
0x01, 0x07, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
uint8_t ckA = 0, ckB = 0;
for (int i = 2; i < 14; i++) {
ckA += cfg[i];
ckB += ckA;
}
cfg[14] = ckA;
cfg[15] = ckB;
Wire.beginTransmission(UBLOX_ADDR);
Wire.write(0xFF);
for (int i = 0; i < 16; i++) {
Wire.write(cfg[i]);
}
uint8_t result = Wire.endTransmission();
delay(300);
return (result == 0);
}
/////////// space stuf
//////////////////////////////////////////////////////////////////////////
#include "ephemeris_tables.h"
static const double SECONDS_PER_DAY = 86400.0;
static const double JD_UNIX_EPOCH = 2440587.5;
static const double G_AU3_DAY2 = 2.9591220828559093e-4; // AU^3 / day^2
static const double MU_SUN = G_AU3_DAY2 * 1.0;
static const double MU_EARTH_KM = 398600.4418;
static const double AU_KM = 149597870.700;
static const double DAY_SEC = 86400.0;
static const double MOON_DT_SEC = 300.0;
static inline void accel_sun(const double r[3], double a[3]) {
double d2 = r[0]*r[0] + r[1]*r[1] + r[2]*r[2];
double d = sqrt(d2);
double denom = (d*d*d) + 1e-12;
double k = -MU_SUN / denom;
a[0] = k * r[0];
a[1] = k * r[1];
a[2] = k * r[2];
}
static inline void euler_step(double r[3], double v[3], double dt_days) {
double a[3];
accel_sun(r, a);
r[0] += v[0] * dt_days;
r[1] += v[1] * dt_days;
r[2] += v[2] * dt_days;
v[0] += a[0] * dt_days;
v[1] += a[1] * dt_days;
v[2] += a[2] * dt_days;
}
static inline void eci_to_ecef_target_timestamp(const double r_eci[3], double r_ecef[3], double theta) {
double c = cos(theta);
double s = sin(theta);
r_ecef[0] = c * r_eci[0] + s * r_eci[1];
r_ecef[1] = -s * r_eci[0] + c * r_eci[1];
r_ecef[2] = r_eci[2];
}
double earth_rotation_angle_from_unix(int64_t t_unix) {
double JD = JD_UNIX_EPOCH + (double)t_unix / SECONDS_PER_DAY;
double d = JD - 2451545.0;
double theta = 2.0 * M_PI * (
0.7790572732640 +
1.00273781191135448 * d
);
theta = fmod(theta, 2.0 * M_PI);
if (theta < 0.0) theta += 2.0 * M_PI;
return theta;
}
static inline void latlon_to_unit_ecef(double lat_deg, double lon_deg, double out[3]) {
double lat = lat_deg * (M_PI / 180.0);
double lon = lon_deg * (M_PI / 180.0);
double clat = cos(lat);
out[0] = clat * cos(lon);
out[1] = clat * sin(lon);
out[2] = sin(lat);
}
static inline bool is_visible_est(const double planet_ecef[3], double user_lat, double user_lon) {
double user_ecef[3];
latlon_to_unit_ecef(user_lat, user_lon, user_ecef);
double dot = planet_ecef[0]*user_ecef[0] + planet_ecef[1]*user_ecef[1] + planet_ecef[2]*user_ecef[2];
return (dot > 0.0);
}
int closest_time_index(const int64_t *unix_time, int n, int64_t target) {
if (n <= 0) return 0;
if (target <= unix_time[0]) return 0;
if (target >= unix_time[n - 1]) return n - 1;
int left = 0;
int right = n - 1;
while (left <= right) {
int mid = (left + right) >> 1;
int64_t tmid = unix_time[mid];
if (tmid == target) return mid;
if (tmid < target) left = mid + 1;
else right = mid - 1;
}
int64_t dr = target - unix_time[right];
int64_t dl = unix_time[left] - target;
return (dr <= dl) ? right : left;
}
void propagate_planet_grcs(int idx, double dt_days_dbl,
const float planet_table[EPHEMERIS_SAMPLES][6],
double r_geo_out[3], double r_earth[3])
{
double r_p[3] = {
(double)planet_table[idx][0] + (double)sun_sv[idx][0],
(double)planet_table[idx][1] + (double)sun_sv[idx][1],
(double)planet_table[idx][2] + (double)sun_sv[idx][2]
};
double v_p[3] = {
(double)planet_table[idx][3] + (double)sun_sv[idx][3],
(double)planet_table[idx][4] + (double)sun_sv[idx][4],
(double)planet_table[idx][5] + (double)sun_sv[idx][5]
};
euler_step(r_p, v_p, dt_days_dbl);
r_geo_out[0] = r_p[0] - r_earth[0];
r_geo_out[1] = r_p[1] - r_earth[1];
r_geo_out[2] = r_p[2] - r_earth[2];
}
void propagate_moon_grcs_fine(int idx, int64_t dt_sec_i64,
const float moon_table[EPHEMERIS_SAMPLES][6],
double r_moon_out_au[3])
{
// initial in AU / AU/day
double r_km[3] = {
(double)moon_table[idx][0] * AU_KM,
(double)moon_table[idx][1] * AU_KM,
(double)moon_table[idx][2] * AU_KM
};
double v_km_s[3] = {
(double)moon_table[idx][3] * AU_KM / DAY_SEC,
(double)moon_table[idx][4] * AU_KM / DAY_SEC,
(double)moon_table[idx][5] * AU_KM / DAY_SEC
};
double dt_remaining = (double)dt_sec_i64;
double sign = (dt_remaining >= 0.0) ? 1.0 : -1.0;
dt_remaining = fabs(dt_remaining);
while (dt_remaining > 0.0) {
double h = MOON_DT_SEC;
if (h > dt_remaining) h = dt_remaining;
h *= sign;
rk4_step_moon(r_km, v_km_s, h);
dt_remaining -= fabs(h);
}
r_moon_out_au[0] = r_km[0] / AU_KM;
r_moon_out_au[1] = r_km[1] / AU_KM;
r_moon_out_au[2] = r_km[2] / AU_KM;
}
#include <math.h>
#include <stdint.h>
static inline double dot3(const double a[3], const double b[3]) {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}
static inline double norm3(const double a[3]) {
return sqrt(dot3(a,a));
}
// Stumpff
static inline double stumpff_C(double z) {
if (z > 0.0) {
double s = sqrt(z);
return (1.0 - cos(s)) / z;
} else if (z < 0.0) {
double s = sqrt(-z);
return (cosh(s) - 1.0) / (-z);
} else {
return 0.5;
}
}
// Stumpff z
static inline double stumpff_S(double z) {
if (z > 0.0) {
double s = sqrt(z);
double s3 = s*s*s;
return (s - sin(s)) / s3;
} else if (z < 0.0) {
double s = sqrt(-z);
double s3 = s*s*s;
return (sinh(s) - s) / s3;
} else {
return 1.0 / 6.0;
}
}
//// This method isnt discussed in the Instructables. I tested the RK4 approach on a more powerful chip when i wrote the article.
//// I also accidentally uploaded the fast, inaccurate Euler based Moon version in my demo but it just happened to be right when i was testing...
//// On the ATtiny3216 I switched to the rk4 and it slowed things down noticeably. This Kepler method (Im not 100% sure how all of it works)
//// achieves about 98 to 99% accuracy for the Moon and runs much faster.
void propagate_moon_grcs_fine_kepler(int idx, int64_t dt_sec_i64,
const float moon_table[EPHEMERIS_SAMPLES][6],
double r_moon_out_au[3])
{
const double dt = (double)dt_sec_i64;
if (fabs(dt) < 1e-6) {
r_moon_out_au[0] = (double)moon_table[idx][0];
r_moon_out_au[1] = (double)moon_table[idx][1];
r_moon_out_au[2] = (double)moon_table[idx][2];
return;
}
double r0[3] = {
(double)moon_table[idx][0] * AU_KM,
(double)moon_table[idx][1] * AU_KM,
(double)moon_table[idx][2] * AU_KM
};
double v0[3] = {
(double)moon_table[idx][3] * AU_KM / DAY_SEC,
(double)moon_table[idx][4] * AU_KM / DAY_SEC,
(double)moon_table[idx][5] * AU_KM / DAY_SEC
};
const double mu = MU_EARTH_KM;
const double r0_norm = norm3(r0);
const double v0_norm = norm3(v0);
const double vr0 = dot3(r0, v0) / r0_norm; // radial
// 1/semimajor axis
const double alpha = 2.0 / r0_norm - (v0_norm * v0_norm) / mu;
// init guess
const double sqrt_mu = sqrt(mu);
double chi;
if (alpha != 0.0) chi = sqrt_mu * fabs(alpha) * dt;
else chi = sqrt_mu * dt / r0_norm;
for (int it = 0; it < 6; ++it) {
const double z = alpha * chi * chi;
const double C = stumpff_C(z);
const double S = stumpff_S(z);
const double chi2 = chi * chi;
const double chi3 = chi2 * chi;
const double F =
(r0_norm * vr0 / sqrt_mu) * chi2 * C +
(1.0 - alpha * r0_norm) * chi3 * S +
r0_norm * chi -
sqrt_mu * dt;
const double dF =
(r0_norm * vr0 / sqrt_mu) * chi * (1.0 - z * S) +
(1.0 - alpha * r0_norm) * chi2 * C +
r0_norm;
if (fabs(dF) < 1e-14) break;
chi -= F / dF;
}
const double z = alpha * chi * chi;
const double C = stumpff_C(z);
const double S = stumpff_S(z);
const double f = 1.0 - (chi * chi / r0_norm) * C;
const double g = dt - (chi * chi * chi / sqrt_mu) * S;
double r[3] = {
f * r0[0] + g * v0[0],
f * r0[1] + g * v0[1],
f * r0[2] + g * v0[2]
};
r_moon_out_au[0] = r[0] / AU_KM;
r_moon_out_au[1] = r[1] / AU_KM;
r_moon_out_au[2] = r[2] / AU_KM;
}
static inline void rk4_step_moon(double r_km[3], double v_km_s[3], double dt_sec) {
// k1
double a1[3]; accel_earth_moon(r_km, a1);
double k1_r[3] = { v_km_s[0], v_km_s[1], v_km_s[2] };
double k1_v[3] = { a1[0], a1[1], a1[2] };
// k2
double r2[3] = { r_km[0] + 0.5*dt_sec*k1_r[0], r_km[1] + 0.5*dt_sec*k1_r[1], r_km[2] + 0.5*dt_sec*k1_r[2] };
double v2[3] = { v_km_s[0] + 0.5*dt_sec*k1_v[0], v_km_s[1] + 0.5*dt_sec*k1_v[1], v_km_s[2] + 0.5*dt_sec*k1_v[2] };
double a2[3]; accel_earth_moon(r2, a2);
double k2_r[3] = { v2[0], v2[1], v2[2] };
double k2_v[3] = { a2[0], a2[1], a2[2] };
// k3
double r3[3] = { r_km[0] + 0.5*dt_sec*k2_r[0], r_km[1] + 0.5*dt_sec*k2_r[1], r_km[2] + 0.5*dt_sec*k2_r[2] };
double v3[3] = { v_km_s[0] + 0.5*dt_sec*k2_v[0], v_km_s[1] + 0.5*dt_sec*k2_v[1], v_km_s[2] + 0.5*dt_sec*k2_v[2] };
double a3[3]; accel_earth_moon(r3, a3);
double k3_r[3] = { v3[0], v3[1], v3[2] };
double k3_v[3] = { a3[0], a3[1], a3[2] };
// k4
double r4[3] = { r_km[0] + dt_sec*k3_r[0], r_km[1] + dt_sec*k3_r[1], r_km[2] + dt_sec*k3_r[2] };
double v4[3] = { v_km_s[0] + dt_sec*k3_v[0], v_km_s[1] + dt_sec*k3_v[1], v_km_s[2] + dt_sec*k3_v[2] };
double a4[3]; accel_earth_moon(r4, a4);
double k4_r[3] = { v4[0], v4[1], v4[2] };
double k4_v[3] = { a4[0], a4[1], a4[2] };
// combine
r_km[0] += (dt_sec / 6.0) * (k1_r[0] + 2.0*k2_r[0] + 2.0*k3_r[0] + k4_r[0]);
r_km[1] += (dt_sec / 6.0) * (k1_r[1] + 2.0*k2_r[1] + 2.0*k3_r[1] + k4_r[1]);
r_km[2] += (dt_sec / 6.0) * (k1_r[2] + 2.0*k2_r[2] + 2.0*k3_r[2] + k4_r[2]);
v_km_s[0] += (dt_sec / 6.0) * (k1_v[0] + 2.0*k2_v[0] + 2.0*k3_v[0] + k4_v[0]);
v_km_s[1] += (dt_sec / 6.0) * (k1_v[1] + 2.0*k2_v[1] + 2.0*k3_v[1] + k4_v[1]);
v_km_s[2] += (dt_sec / 6.0) * (k1_v[2] + 2.0*k2_v[2] + 2.0*k3_v[2] + k4_v[2]);
}
static inline void accel_earth_moon(const double r_km[3], double a_km[3]) {
double d2 = r_km[0]*r_km[0] + r_km[1]*r_km[1] + r_km[2]*r_km[2];
double d = sqrt(d2);
double denom = (d*d*d) + 1e-12;
double k = -MU_EARTH_KM / denom;
a_km[0] = k * r_km[0];
a_km[1] = k * r_km[1];
a_km[2] = k * r_km[2];
}
static inline bool normalize3(double v[3]) {
double n2 = v[0]*v[0] + v[1]*v[1] + v[2]*v[2];
if (n2 < 1e-18) return false;
double inv = 1.0 / sqrt(n2);
v[0] *= inv;
v[1] *= inv;
v[2] *= inv;
return true;
}
/////////// board stuf
//////////////////////////////////////////////////////////////////////////
#include <Wire.h>
#include <tinyNeoPixel.h>
#include "SAMM10Q.h"
GPSData_t gpsData;
#define LED_PIN 16
#define LED_COUNT 9
#define LATCH_PIN 14
#define OFF_BUTTON 7
#define RED_R 10
#define RED_G 0
#define RED_B 0
tinyNeoPixel strip = tinyNeoPixel(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// flash
bool flashState = false;
unsigned long lastFlash = 0;
const unsigned long FLASH_INTERVAL = 500; // ms
/////////// led stuf
//////////////////////////////////////////////////////////////////////////
void clearLEDs() {for (int i = 0; i < LED_COUNT; i++) {strip.setPixelColor(i, 0, 0, 0);}}
void setLEDOverhead(int i, bool overhead, int r, int g, int b) {
if (overhead) strip.setPixelColor(i, r, g, b);
else strip.setPixelColor(i, 0, 0, 0);
}
void updateLEDs() {
clearLEDs();
if (!gpsData.dataReceived || gpsData.numSV < 3) {
if (flashState) {for (int i = 0; i < LED_COUNT; i++) {strip.setPixelColor(i, RED_R, RED_G, RED_B);}}
return;
}
// uint64_t unix_time = (uint64_t)gpsData.unixTime;
int64_t unix_time = (int64_t)gpsData.unixTime;
// for testing with python
// const uint64_t SECONDS_212_DAYS = 60ULL * 60ULL * 24ULL * 212ULL;
// unix_time += SECONDS_212_DAYS;
double currentLat = gpsData.lat / 10000000.0;
double currentLon = gpsData.lon / 10000000.0;
int idx = closest_time_index(time_table, 8, unix_time);
int64_t t0 = time_table[idx];
int64_t dt_sec_i64 = (int64_t)unix_time - t0;
double dt_days = (double)dt_sec_i64 / SECONDS_PER_DAY;
double earth_theta = earth_rotation_angle_from_unix(unix_time);
// Propagate earth
double r_e[3] = { (double)sun_sv[idx][0], (double)sun_sv[idx][1], (double)sun_sv[idx][2] };
double v_e[3] = { (double)sun_sv[idx][3], (double)sun_sv[idx][4], (double)sun_sv[idx][5] };
euler_step(r_e, v_e, dt_days);
double r_geo[3], r_ecef[3];
propagate_planet_grcs(idx, dt_days, mercury_sv, r_geo, r_e);
normalize3(r_geo); // normalize r_geo, its in units of AU it's small, this was a hard bug to find....
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(0, flashState * is_visible_est(r_ecef, currentLat, currentLon),
MERCURY_R, MERCURY_G, MERCURY_B);
propagate_planet_grcs(idx, dt_days, venus_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(1, flashState * is_visible_est(r_ecef, currentLat, currentLon),
VENUS_R, VENUS_G, VENUS_B);
propagate_planet_grcs(idx, dt_days, mars_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(2, flashState * is_visible_est(r_ecef, currentLat, currentLon),
MARS_R, MARS_G, MARS_B);
propagate_planet_grcs(idx, dt_days, jupiter_barycenter_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(3, flashState * is_visible_est(r_ecef, currentLat, currentLon),
JUPITER_R, JUPITER_G, JUPITER_B);
propagate_planet_grcs(idx, dt_days, saturn_barycenter_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(4, flashState * is_visible_est(r_ecef, currentLat, currentLon),
SATURN_R, SATURN_G, SATURN_B);
propagate_planet_grcs(idx, dt_days, uranus_barycenter_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(5, flashState * is_visible_est(r_ecef, currentLat, currentLon),
URANUS_R, URANUS_G, URANUS_B);
propagate_planet_grcs(idx, dt_days, neptune_barycenter_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(6, flashState * is_visible_est(r_ecef, currentLat, currentLon),
NEPTUNE_R, NEPTUNE_G, NEPTUNE_B);
propagate_planet_grcs(idx, dt_days, pluto_barycenter_sv, r_geo, r_e);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(7, flashState * is_visible_est(r_ecef, currentLat, currentLon),
PLUTO_R, PLUTO_G, PLUTO_B);
propagate_moon_grcs_fine_kepler(idx, dt_sec_i64, moon_sv, r_geo);
normalize3(r_geo);
eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
setLEDOverhead(8, flashState * is_visible_est(r_ecef, currentLat, currentLon),
MOON_R, MOON_G, MOON_B);
// propagate_moon_grcs_fine(idx, dt_sec_i64, moon_sv, r_geo);
// normalize3(r_geo);
// eci_to_ecef_target_timestamp(r_geo, r_ecef, earth_theta);
// setLEDOverhead(8, flashState * is_visible_est(r_ecef, currentLat, currentLon),
// MOON_R, MOON_G, MOON_B);
strip.show();
}
void hackerStartScreen() {
const int loops = 20;
const int frameDelay = 70;
strip.clear();
strip.show();
for (int t = 0; t < loops; t++) {
for (int i = 0; i < LED_COUNT; i++) {
int g = random(5, 15);
strip.setPixelColor(i, 0, g, 0);
}
int head = t % LED_COUNT;
strip.setPixelColor(head, 0, 24, 0);
strip.show();
delay(frameDelay);
if (t % 5 == 0) {
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(
i,
random(5, 20),
random(5, 20),
random(5, 20)
);
}
strip.show();
delay(50);
}
int pulse = (t % 6) * 4;
strip.setPixelColor(4, pulse, 0, pulse);
strip.show();
delay(frameDelay);
}
for (int k = 0; k < 2; k++) {
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, 0, 20, 5);
}
strip.show();
delay(150);
strip.clear();
strip.show();
delay(150);
}
strip.clear();
strip.show();
}
void setup() {
pinMode(LATCH_PIN, OUTPUT);
digitalWrite(LATCH_PIN, HIGH);
strip.begin();
strip.show();
Wire.begin();
delay(1000);
gpsData.dataReceived = false;
gpsData.numSV = 0;
// wit for GPS
int attempts = 0;
while (!gpsFound() && attempts < 10) {
delay(300);
attempts++;
}
if (attempts >= 10) {
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, RED_R, RED_G, RED_B);
}
strip.show();
while (1);
}
configureNAVPVT();
delay(2000);
hackerStartScreen();
}
void loop() {
if (digitalRead(OFF_BUTTON) == LOW) {
delay(50);
if (digitalRead(OFF_BUTTON) == LOW) {
digitalWrite(LATCH_PIN, LOW);
while (true) {
// wait for power to cut
}
}
}
uint16_t avail = getBytesAvailable();
if (avail > 8) {
readUBXMessage();
}
if (millis() - lastFlash >= FLASH_INTERVAL) {
lastFlash = millis();
flashState = !flashState;
updateLEDs();
strip.show();
}
delay(50);
}
// AUTO-GENERATED FILE DO NOT EDIT
// Ephemeris lookup tables (GCRS, Earth-centered)
#pragma once
#include <stdint.h>
#define EPHEMERIS_SAMPLES 8
#define EPHEMERIS_COLUMNS 6
const int64_t time_table[8] = {
1767225600,
1770854400,
1774483200,
1778112000,
1781740800,
1785369600,
1788998400,
1792627200
};
const float sun_sv[8][6] = {
{ 1.742952168e-01f, -8.879228234e-01f, -3.848968446e-01f, 1.720450260e-02f, 2.859745407e-03f, 1.239592675e-03f },
{ 7.873948216e-01f, -5.462082624e-01f, -2.367793471e-01f, 1.066171192e-02f, 1.264979504e-02f, 5.483536050e-03f },
{ 9.936382771e-01f, 7.945988327e-02f, 3.444101661e-02f, -1.221224898e-03f, 1.578411460e-02f, 6.841792259e-03f },
{ 6.996138096e-01f, 6.669433117e-01f, 2.891010344e-01f, -1.211037394e-02f, 1.100643165e-02f, 4.771576263e-03f },
{ 6.317487359e-02f, 9.303498864e-01f, 4.032905400e-01f, -1.689830422e-02f, 1.037285198e-03f, 4.491088039e-04f },
{ -6.039854884e-01f, 7.486671209e-01f, 3.245364130e-01f, -1.354425680e-02f, -9.328189306e-03f, -4.042850342e-03f },
{ -9.811158776e-01f, 2.086497098e-01f, 9.045208991e-02f, -3.609358566e-03f, -1.532396395e-02f, -6.643372588e-03f },
{ -8.769258857e-01f, -4.320504665e-01f, -1.872791946e-01f, 8.420635015e-03f, -1.384192612e-02f, -5.999573506e-03f }
};
const float moon_sv[8][6] = {
{ 9.645412792e-04f, 1.935964799e-03f, 1.070691971e-03f, -5.800898070e-04f, 2.216364373e-04f, 9.959529416e-05f },
{ -4.772208340e-04f, -2.328889677e-03f, -1.273929141e-03f, 5.529845366e-04f, -8.678845916e-05f, -2.591292650e-05f },
{ -3.250438604e-04f, 2.174365567e-03f, 1.158295781e-03f, -6.047159550e-04f, -4.661893763e-05f, -4.821577386e-05f },
{ 6.479381700e-04f, -2.323614666e-03f, -1.210430637e-03f, 5.403386313e-04f, 1.214328804e-04f, 8.690256072e-05f },
{ -1.465585781e-03f, 1.755741658e-03f, 8.596066036e-04f, -5.156788975e-04f, -2.952546638e-04f, -1.808125817e-04f },
{ 1.744159497e-03f, -1.815445954e-03f, -8.715497097e-04f, 4.193811910e-04f, 3.346354060e-04f, 1.995692437e-04f },
{ -2.204057062e-03f, 1.070239814e-03f, 4.519470967e-04f, -3.008191125e-04f, -4.632714845e-04f, -2.618523140e-04f },
{ 2.371959854e-03f, -9.981868789e-04f, -3.976827138e-04f, 2.060530969e-04f, 4.775166453e-04f, 2.621846797e-04f }
};
const float mercury_sv[8][6] = {
{ -4.104369879e-02f, -1.257843256e+00f, -5.601915121e-01f, 3.643174842e-02f, -6.834022235e-03f, -5.931565538e-03f },
{ 1.062130451e+00f, -3.843180537e-01f, -1.787703782e-01f, -9.749501944e-03f, 3.410912305e-02f, 1.906258240e-02f },
{ 7.077797651e-01f, -2.435207665e-01f, -1.084704101e-01f, 1.494452823e-02f, 1.865368569e-03f, -2.269055462e-03f },
{ 1.036048770e+00f, 7.350151539e-01f, 2.905976474e-01f, -2.273311839e-02f, 3.620269895e-02f, 1.933244802e-02f },
{ -2.790478766e-01f, 6.703414917e-01f, 2.998603284e-01f, -4.844259471e-03f, -1.668756641e-02f, -1.026884839e-02f },
{ -2.441948354e-01f, 7.148861289e-01f, 2.692026198e-01f, -1.488841139e-02f, 1.636843570e-02f, 9.823712520e-03f },
{ -1.361233592e+00f, 2.562854253e-02f, 3.207571805e-02f, 3.227943322e-03f, -3.623490408e-02f, -1.852270216e-02f },
{ -5.279079676e-01f, -5.648458004e-01f, -2.943904102e-01f, 1.491479110e-02f, 1.004681177e-02f, 6.088876165e-03f }
};
const float venus_sv[8][6] = {
{ 2.629914880e-01f, -1.544166684e+00f, -6.857994199e-01f, 3.714368492e-02f, 5.501963198e-03f, 1.167043345e-03f },
{ 1.483586073e+00f, -7.186988592e-01f, -3.584403694e-01f, 1.631101221e-02f, 3.038175777e-02f, 1.310501155e-02f },
{ 1.453569412e+00f, 5.979443192e-01f, 2.386465222e-01f, -1.687984727e-02f, 2.709124237e-02f, 1.292033866e-02f },
{ 3.598686159e-01f, 1.236015916e+00f, 5.666619539e-01f, -2.999785542e-02f, 1.764163608e-03f, 1.744478010e-03f },
{ -6.516706347e-01f, 8.405032158e-01f, 4.080871046e-01f, -1.475982182e-02f, -1.732626557e-02f, -7.949272171e-03f },
{ -8.075063229e-01f, 1.084998176e-01f, 4.935474321e-02f, 5.733605009e-03f, -1.413454022e-02f, -7.425196934e-03f },
{ -4.225521982e-01f, -2.031365335e-01f, -1.301778704e-01f, 9.212384932e-03f, -9.307927103e-04f, -9.780081455e-04f },
{ -2.316401005e-01f, -1.182118356e-01f, -8.688353002e-02f, -8.059090469e-04f, 2.311748452e-03f, 1.852902467e-03f }
};
const float mars_sv[8][6] = {
{ 5.146895647e-01f, -2.145644426e+00f, -9.709649086e-01f, 3.132436797e-02f, 7.123501506e-03f, 2.814435167e-03f },
{ 1.671723962e+00f, -1.516398549e+00f, -7.056344151e-01f, 2.200628817e-02f, 2.192665264e-02f, 9.432631545e-03f },
{ 2.254023314e+00f, -4.205077291e-01f, -2.288766652e-01f, 5.024842918e-03f, 2.854785137e-02f, 1.252775732e-02f },
{ 2.091573238e+00f, 7.341658473e-01f, 2.823916674e-01f, -1.216569263e-02f, 2.480435371e-02f, 1.110185962e-02f },
{ 1.323478818e+00f, 1.552977681e+00f, 6.548838019e-01f, -2.294147201e-02f, 1.329389866e-02f, 6.233928259e-03f },
{ 3.018189073e-01f, 1.818280816e+00f, 7.907131314e-01f, -2.406553738e-02f, -5.448129959e-04f, 2.696523443e-04f },
{ -5.758664608e-01f, 1.555323482e+00f, 6.972102523e-01f, -1.657368988e-02f, -1.100257412e-02f, -4.311593715e-03f },
{ -1.031886697e+00f, 9.985242486e-01f, 4.730715752e-01f, -4.975771066e-03f, -1.413780451e-02f, -5.773981102e-03f }
};
const float jupiter_barycenter_sv[8][6] = {
{ -1.519536495e+00f, 3.627354383e+00f, 1.591704130e+00f, 9.973803535e-03f, 8.640224696e-04f, 5.601781304e-04f },
{ -1.206818938e+00f, 3.876997232e+00f, 1.707669139e+00f, 3.592328401e-03f, 1.026295684e-02f, 4.632556345e-03f },
{ -1.293651223e+00f, 4.394422054e+00f, 1.939627528e+00f, -8.104946464e-03f, 1.301821321e-02f, 5.823818501e-03f },
{ -1.872457743e+00f, 4.858017921e+00f, 2.148118258e+00f, -1.878513768e-02f, 7.874692790e-03f, 3.591709305e-03f },
{ -2.784439802e+00f, 4.982466698e+00f, 2.209454060e+00f, -2.334192023e-02f, -2.445992548e-03f, -8.870662423e-04f },
{ -3.717002392e+00f, 4.647373676e+00f, 2.071404934e+00f, -1.973577403e-02f, -1.314769499e-02f, -5.529277958e-03f },
{ -4.348541260e+00f, 3.940153122e+00f, 1.771845579e+00f, -9.529049508e-03f, -1.946357824e-02f, -8.273623884e-03f },
{ -4.486959457e+00f, 3.119149446e+00f, 1.422737002e+00f, 2.791308099e-03f, -1.828474738e-02f, -7.766852621e-03f }
};
const float saturn_barycenter_sv[8][6] = {
{ 9.681663513e+00f, -4.994135201e-01f, -6.338292956e-01f, 1.674159989e-02f, 7.996461354e-03f, 3.381074872e-03f },
{ 1.027244473e+01f, 5.787989125e-02f, -3.957130909e-01f, 1.006169803e-02f, 1.777900755e-02f, 7.627824321e-03f },
{ 1.045060635e+01f, 8.987553120e-01f, -3.439926729e-02f, -1.958517358e-03f, 2.090263553e-02f, 8.987574838e-03f },
{ 1.012273026e+01f, 1.700943232e+00f, 3.103949428e-01f, -1.298500970e-02f, 1.611110009e-02f, 6.917550229e-03f },
{ 9.446669579e+00f, 2.178416252e+00f, 5.147034526e-01f, -1.791024953e-02f, 6.124947220e-03f, 2.593969461e-03f },
{ 8.734120369e+00f, 2.210023642e+00f, 5.259960294e-01f, -1.469346974e-02f, -4.260656890e-03f, -1.900391653e-03f },
{ 8.305838585e+00f, 1.882378101e+00f, 3.818273842e-01f, -4.895741120e-03f, -1.027978212e-02f, -4.504651297e-03f },
{ 8.353122711e+00f, 1.452987552e+00f, 1.938207746e-01f, 6.997304969e-03f, -8.824359626e-03f, -3.865946550e-03f }
};
const float uranus_barycenter_sv[8][6] = {
{ 1.005497169e+01f, 1.455175686e+01f, 6.237381935e+00f, 1.377814356e-02f, 4.500451963e-03f, 2.006680239e-03f },
{ 1.052383137e+01f, 1.496182156e+01f, 6.417477131e+00f, 7.218939718e-03f, 1.426410954e-02f, 6.239303853e-03f },
{ 1.058515072e+01f, 1.565472698e+01f, 6.720197678e+00f, -4.680149723e-03f, 1.737186126e-02f, 7.586157415e-03f },
{ 1.014552498e+01f, 1.630833244e+01f, 7.005878448e+00f, -1.558517013e-02f, 1.256748103e-02f, 5.504475906e-03f },
{ 9.362815857e+00f, 1.663673973e+01f, 7.150608540e+00f, -2.038867958e-02f, 2.571536228e-03f, 1.170490519e-03f },
{ 8.548727036e+00f, 1.651893616e+01f, 7.101912022e+00f, -1.704999804e-02f, -7.820792496e-03f, -3.333012341e-03f },
{ 8.024021149e+00f, 1.604166985e+01f, 6.897400379e+00f, -7.130291779e-03f, -1.384356152e-02f, -5.945140496e-03f },
{ 7.980004787e+00f, 1.546258163e+01f, 6.648752213e+00f, 4.884717520e-03f, -1.238868758e-02f, -5.313022528e-03f }
};
const float neptune_barycenter_sv[8][6] = {
{ 3.004643822e+01f, -1.342440248e-01f, -8.200629354e-01f, 1.712276414e-02f, 5.781460553e-03f, 2.437465591e-03f },
{ 3.065581322e+01f, 3.301571012e-01f, -6.216380596e-01f, 1.056613494e-02f, 1.557077002e-02f, 6.681447849e-03f },
{ 3.085775375e+01f, 1.078485250e+00f, -3.001068532e-01f, -1.330606872e-03f, 1.870425791e-02f, 8.039702661e-03f },
{ 3.055884552e+01f, 1.788601041e+00f, 4.866841249e-03f, -1.223350596e-02f, 1.392570138e-02f, 5.969464779e-03f },
{ 2.991694641e+01f, 2.174608469e+00f, 1.693712026e-01f, -1.703512110e-02f, 3.955663648e-03f, 1.646964811e-03f },
{ 2.924375153e+01f, 2.115490913e+00f, 1.409313232e-01f, -1.369477250e-02f, -6.410674658e-03f, -2.845011186e-03f },
{ 2.886001205e+01f, 1.697996855e+00f, -4.284161702e-02f, -3.773627104e-03f, -1.240737084e-02f, -5.445572082e-03f },
{ 2.895701218e+01f, 1.179771543e+00f, -2.702671885e-01f, 8.242589422e-03f, -1.092634629e-02f, -4.801847506e-03f }
};
const float pluto_barycenter_sv[8][6] = {
{ 1.940164185e+01f, -2.714402199e+01f, -1.437037659e+01f, 1.991874538e-02f, 4.141388927e-03f, 8.197210263e-04f },
{ 2.012862778e+01f, -2.674833298e+01f, -1.423981380e+01f, 1.337065361e-02f, 1.393841114e-02f, 5.067424849e-03f },
{ 2.044854355e+01f, -2.606839371e+01f, -1.398599339e+01f, 1.482431893e-03f, 1.707963832e-02f, 6.429419853e-03f },
{ 2.026797104e+01f, -2.542634964e+01f, -1.374857616e+01f, -9.411965497e-03f, 1.230884250e-02f, 4.362941254e-03f },
{ 1.974476624e+01f, -2.510809135e+01f, -1.365147209e+01f, -1.420509815e-02f, 2.346591093e-03f, 4.421894118e-05f },
{ 1.919061279e+01f, -2.523463631e+01f, -1.374715233e+01f, -1.085628569e-02f, -8.011937141e-03f, -4.443960730e-03f },
{ 1.892626190e+01f, -2.571922874e+01f, -1.399800301e+01f, -9.266966372e-04f, -1.400079951e-02f, -7.040707394e-03f },
{ 1.914299965e+01f, -2.630421638e+01f, -1.429234219e+01f, 1.109794434e-02f, -1.251191739e-02f, -6.393149961e-03f }
};
// planet colors
#define MERCURY_R 18
#define MERCURY_G 18
#define MERCURY_B 18 // gray
#define VENUS_R 30
#define VENUS_G 24
#define VENUS_B 6 // warm yellow
#define MARS_R 30
#define MARS_G 6
#define MARS_B 0 // red/orange
#define JUPITER_R 24
#define JUPITER_G 15
#define JUPITER_B 6 // tan
#define SATURN_R 24
#define SATURN_G 21
#define SATURN_B 9 // pale gold
#define URANUS_R 6
#define URANUS_G 24
#define URANUS_B 24 // cyan
#define NEPTUNE_R 3
#define NEPTUNE_G 6
#define NEPTUNE_B 30 // deep blue
#define PLUTO_R 15
#define PLUTO_G 12
#define PLUTO_B 9 // brown/gray
#define MOON_R 24
#define MOON_G 24
#define MOON_B 24 // bright gray / white
import numpy as np
from datetime import datetime, timezone
from planet_overhead import is_planet_overhead
SECONDS_PER_DAY = 86400.0
JD_UNIX_EPOCH = 2440587.5
G = 2.9591220828559093e-4 # AU^3 / day^2
MASS_SUN = 1.0
MU_SUN = G * MASS_SUN
MU_EARTH = 398600.4418 # km^3 / s^2
AU_KM = 149597870.700
DAY_SEC = 86400.0
MOON_DT = 300.0
def accel_sun(r):
d = np.linalg.norm(r)
return -MU_SUN * r / (d ** 3 + 1e-12)
def euler_step(r, v, dt_days):
a = accel_sun(r)
return r + v * dt_days, v + a * dt_days
def accel_earth_moon(r_km):
d = np.linalg.norm(r_km)
return -MU_EARTH * r_km / (d ** 3 + 1e-12)
def rk4_step(r, v, dt, accel_fn):
def f(r, v):
return v, accel_fn(r)
k1r, k1v = f(r, v)
k2r, k2v = f(r + 0.5 * dt * k1r, v + 0.5 * dt * k1v)
k3r, k3v = f(r + 0.5 * dt * k2r, v + 0.5 * dt * k2v)
k4r, k4v = f(r + dt * k3r, v + dt * k3v)
r_new = r + (dt / 6) * (k1r + 2 * k2r + 2 * k3r + k4r)
v_new = v + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v)
return r_new, v_new
def propagate_planet_grcs(idx, unix_time, target_unix, planet_table, sun_data):
dt_days = (target_unix - unix_time) / SECONDS_PER_DAY
r_p = planet_table[idx, :3] + sun_data[idx, :3]
v_p = planet_table[idx, 3:6] + sun_data[idx, 3:6]
r_e = sun_data[idx, :3]
v_e = sun_data[idx, 3:6]
if abs(dt_days) < 1e-9:
return planet_table[idx, :3].copy()
r_p, v_p = euler_step(r_p, v_p, dt_days)
r_e, v_e = euler_step(r_e, v_e, dt_days)
return r_p - r_e
def propagate_moon_grcs_fine(idx, unix_time, target_unix, moon_table):
dt_total = target_unix - unix_time
if abs(dt_total) < 1e-6:
return moon_table[idx, :3].copy()
r = moon_table[idx, :3] * AU_KM
v = moon_table[idx, 3:] * AU_KM / DAY_SEC
sign = np.sign(dt_total)
remaining = abs(dt_total)
while remaining > 0:
h = min(MOON_DT, remaining) * sign
r, v = rk4_step(r, v, h, accel_earth_moon)
remaining -= abs(h)
return r / AU_KM
def earth_rotation_angle_from_unix(t_unix):
JD = JD_UNIX_EPOCH + t_unix / SECONDS_PER_DAY
d = JD - 2451545.0
theta = 2 * np.pi * (0.7790572732640 + 1.00273781191135448 * d)
return theta % (2 * np.pi)
def eci_to_ecef(r_eci, t_unix):
theta = earth_rotation_angle_from_unix(t_unix)
c, s = np.cos(theta), np.sin(theta)
R = np.array([[c, s, 0], [-s, c, 0], [0, 0, 1]])
return R @ r_eci
def latlon_to_ecef(lat, lon, radius=6371.0):
lat, lon = np.radians(lat), np.radians(lon)
return np.array([
radius * np.cos(lat) * np.cos(lon),
radius * np.cos(lat) * np.sin(lon),
radius * np.sin(lat),
])
def is_visible_est(planet_ecef, user_lat, user_lon):
user = latlon_to_ecef(user_lat, user_lon)
return np.dot(planet_ecef, user) > 0
def closest_time_index(times, target):
return np.searchsorted(times, target, side="left").clip(0, len(times) - 1)
def check_planet_visibility(
planet_name: str,
utc_timestamp,
user_lat: float,
user_lon: float,
data_dir: str = "data",
):
"""
Args:
planet_name: "mars", "moon", "venus", etc
utc_timestamp:
- unix seconds (float or int), OR
- timezone-aware datetime in UTC
user_lat, user_lon: degrees
Returns:
dict with:
est_overhead -> bool (your estimator)
true_overhead -> bool (skyfield)
match -> bool
"""
if isinstance(utc_timestamp, datetime):
if utc_timestamp.tzinfo is None:
raise ValueError("datetime must be timezone-aware UTC")
unix_time = utc_timestamp.timestamp()
dt_utc = utc_timestamp.astimezone(timezone.utc)
else:
unix_time = float(utc_timestamp)
dt_utc = datetime.fromtimestamp(unix_time, tz=timezone.utc)
planet_table = np.load(f"{data_dir}/{planet_name}_sv_table.npy")
time_table = np.load(f"{data_dir}/time_table.npy")
sun_data = np.load(f"{data_dir}/sun_sv_table.npy")
idx = closest_time_index(time_table, unix_time)
if planet_name.lower() == "moon":
r_eci = propagate_moon_grcs_fine(idx, time_table[idx], unix_time, planet_table)
else:
r_eci = propagate_planet_grcs(idx, time_table[idx], unix_time, planet_table, sun_data)
planet_ecef = eci_to_ecef(r_eci, unix_time)
est_overhead = is_visible_est(planet_ecef, user_lat, user_lon)
true_overhead = is_planet_overhead(
planet_name.lower(),
dt_utc,
user_lat,
user_lon,
)
return {
"planet": planet_name.lower(),
"est_overhead": bool(est_overhead),
"true_overhead": bool(true_overhead),
"match": bool(est_overhead == true_overhead),
}
from planet_overhead_est import check_planet_visibility, closest_time_index, earth_rotation_angle_from_unix, \
SECONDS_PER_DAY
from datetime import datetime, timezone
import numpy as np
PLANETS = [
"mercury",
"venus",
"mars",
"jupiter barycenter",
"saturn barycenter",
"uranus barycenter",
"neptune barycenter",
"pluto barycenter",
"moon"
]
now_utc = datetime.now(timezone.utc).timestamp()
lat = 0
lon = 0
planet_name = PLANETS[0]
time_table = np.load(f"data/time_table.npy")
sun_data = np.load(f"data/sun_sv_table.npy")
idx = closest_time_index(time_table, now_utc)
print(idx)
print(now_utc - time_table[idx])
print(earth_rotation_angle_from_unix(now_utc))
print((now_utc - time_table[idx]) / SECONDS_PER_DAY)
for name in PLANETS:
name = name.replace(' ', '_')
result = check_planet_visibility(name, now_utc, lat, lon)
print(result)
"""
build sparse ephemeris lookup table builder (GCRS, Earth-centered!).
"""
from datetime import datetime, timedelta, timezone
import numpy as np
from skyfield.api import load
from tqdm import tqdm
BODIES = [
"sun",
"moon",
"mercury",
"venus",
"mars",
"jupiter BARYCENTER",
"saturn BARYCENTER",
"uranus BARYCENTER",
"neptune BARYCENTER",
"pluto BARYCENTER",
]
START_YEAR = 2026
START_MONTH = 1
START_DAY = 1
START_HOUR = 0
START_MINUTE = 0
START_SECOND = 0
YEARS = 1
# sample spacing (seconds)
RESOLUTION_SECONDS = 60 * 60 * 24 * 7 * 6 # 6 weeks
OUTPUT_DIR = "data"
C_HEADER_PATH = f"{OUTPUT_DIR}/ephemeris_tables.h"
def c_identifier(name: str) -> str:
return name.lower().replace(" ", "_")
def write_c_array_1d(f, ctype, name, array):
f.write(f"const {ctype} {name}[{len(array)}] = {{\n")
for i, v in enumerate(array):
if ctype == "int64_t":
f.write(f" {int(v)}")
else:
f.write(f" {float(v):.9e}f")
if i < len(array) - 1:
f.write(",")
f.write("\n")
f.write("};\n\n")
def write_c_array_2d(f, ctype, name, array):
rows, cols = array.shape
f.write(f"const {ctype} {name}[{rows}][{cols}] = {{\n")
for r in range(rows):
f.write(" { ")
for c in range(cols):
v = float(array[r, c])
f.write(f"{v:.9e}f")
if c < cols - 1:
f.write(", ")
f.write(" }")
if r < rows - 1:
f.write(",")
f.write("\n")
f.write("};\n\n")
ts = load.timescale()
eph = load("de421.bsp")
earth = eph["earth"]
start_dt = datetime(
START_YEAR,
START_MONTH,
START_DAY,
START_HOUR,
START_MINUTE,
START_SECOND,
tzinfo=timezone.utc,
)
num_samples = int(YEARS * 365 * 24 * 3600 // RESOLUTION_SECONDS)
datetimes = [
start_dt + timedelta(seconds=i * RESOLUTION_SECONDS)
for i in range(num_samples)
]
times = ts.from_datetimes(datetimes)
unix_time = np.array(
[int(dt.timestamp()) for dt in datetimes],
dtype=np.int64
)
np.save(f"{OUTPUT_DIR}/time_table.npy", unix_time)
tables = {}
for body_name in tqdm(BODIES):
print(f"Building table for {body_name} ...")
body = eph[body_name]
obs = earth.at(times).observe(body)
table = np.empty((num_samples, 6), dtype=np.float32)
table[:, 0:3] = obs.position.au.T
table[:, 3:6] = obs.velocity.au_per_d.T
key = c_identifier(body_name)
tables[key] = table
np.save(f"{OUTPUT_DIR}/{key}_sv_table.npy", table)
print("ephemeris complete :)")
print("Writing C header...")
with open(C_HEADER_PATH, "w") as f:
f.write("// AUTO-GENERATED FILE DO NOT EDIT\n")
f.write("// Ephemeris lookup tables (GCRS, Earth-centered)\n\n")
f.write("#pragma once\n\n")
f.write("#include <stdint.h>\n\n")
f.write(f"#define EPHEMERIS_SAMPLES {num_samples}\n")
f.write(f"#define EPHEMERIS_COLUMNS 6\n\n")
write_c_array_1d(
f,
ctype="int64_t",
name="time_table",
array=unix_time,
)
for name, table in tables.items():
write_c_array_2d(
f,
ctype="float",
name=f"{name}_sv",
array=table,
)
print(f"C .h written done")
from datetime import datetime, timezone
from skyfield.api import load, wgs84
ts = load.timescale()
eph = load("de421.bsp")
earth = eph["earth"]
def is_planet_overhead(
planet_name: str,
utc_datetime: datetime,
latitude_deg: float,
longitude_deg: float,
) -> bool:
if utc_datetime.tzinfo is None:
raise ValueError("utc_datetime must be timezone-aware UTC")
planet = eph[planet_name.lower()]
observer = wgs84.latlon(latitude_deg, longitude_deg)
t = ts.from_datetime(utc_datetime)
astrometric = (earth + observer).at(t).observe(planet)
apparent = astrometric.apparent()
alt, az, distance = apparent.altaz()
# above horizon
# print(f"{planet_name} is {alt.degrees} over horizon")
return alt.degrees > 0.0
import numpy as np
import random
from planet_overhead import is_planet_overhead
from datetime import datetime, timezone
from tqdm import tqdm
random.seed(42)
names = ["sun", "moon", "mercury", "venus", "mars",
"jupiter BARYCENTER", "saturn BARYCENTER", "uranus BARYCENTER",
"neptune BARYCENTER", "pluto BARYCENTER"]
test_planet = "mercury"
USER_LAT = 0
USER_LON = 0
trials = 1000
"""
FIRST RUN build_ephemeris_tables.py
This is a code script to test your tables for accuracy
what this code does:
we have saved sparse tables of inertial planet (and moon) state vectors (position and velocity).
given a random user time, lat, and lon, we grab the closest planet state vector, sun vector,
and unix timestamp.
using these vectors we approximate the planets position, usually using a simple linear
approximation and the sun. the sun has the biggest effect on planet motion
we then see where these approximated planet locations (xyz) are relative to Earth, and then
spin them around based on Earths rotation. (ECI to ECEF)
once we have our planet locations spun to match the rotating Earth, confirming whether its
overhead is easy. lat / lon are also stuck on the spinning Earth.
"""
SECONDS_PER_DAY = 86400.0 # sidereal
JD_UNIX_EPOCH = 2440587.5 # julian date at unix epoch
G = 2.9591220828559093e-4 # units of au^3 / (day^2)
MASS_EARTH = 3.003e-06 # solar mass
MASS_MOON_RATIO = 0.0123000
MASS_SUN = 1.0
SOLAR_MASS_FRACTION = {
"sun": 1.0,
"mercury": 1.66e-7,
"venus": 2.45e-6,
"earth": 3.03e-6,
"mars": 3.23e-7,
"jupiter barycenter": 9.55e-4,
"saturn barycenter": 2.86e-5,
"uranus barycenter": 4.37e-6,
"neptune barycenter": 5.15e-5,
"pluto barycenter": 7.340e-9
}
MU_SUN = G * MASS_SUN
def accel_sun(r):
"""f = ma => a = F / m => a = r * G * M_sun / |r|^3"""
# thank you, newton
d = np.linalg.norm(r)
return -MU_SUN * r / (d ** 3 + 1e-12)
def euler_step(r, v, dt_days):
"""euler integration linear."""
a = accel_sun(r)
r_new = r + v * dt_days
v_new = v + a * dt_days
return r_new, v_new
def propagate_planet_grcs(idx, unix_time, target_unix, planet_table):
"""
Take one big linear step to move the planet to where it should ~be.
We need the planet vector to have Earth at its origin, so we also move Earth with a big step,
then subtract Earths new position from the propagated planet position. This effectively
slides the frame back to where it should be.
The convenient thing about the GCRS frame is that Earth, by definition, is always at
xyz = [0, 0, 0] and d(xyz)/dt = [0, 0, 0] (at the time the table was made),
so we dont really need Earths state vector to move it we just use the sun table.
"""
dt_sec = target_unix - unix_time
dt_days = dt_sec / SECONDS_PER_DAY
au_xyz = planet_table[idx, 0:3]
au_per_d = planet_table[idx, 3:6]
if abs(dt_days) < 1e-9:
return au_xyz.copy()
r_p = au_xyz.copy() + sun_data[idx, 0:3]
v_p = au_per_d.copy() + sun_data[idx, 3:6]
# earth helio = sun wrt earth
r_e = sun_data[idx, 0:3].copy()
v_e = sun_data[idx, 3:6].copy()
r_p, v_p = euler_step(r_p, v_p, dt_days)
r_e, v_e = euler_step(r_e, v_e, dt_days)
# r_p, v_p = rk4_step_sun_planet_system(r_p, v_p, dt_days)
# r_e, v_e = rk4_step_sun_planet_system(r_e, v_e, dt_days)
r_geo = r_p - r_e
return r_geo
### moon stuff
MU_EARTH = 398600.4418
def accel_earth_moon(r_km):
"""f = ma => a = F / m => a = r * G * M_sun / |r|^3"""
d = np.linalg.norm(r_km)
return -MU_EARTH * r_km / (d ** 3 + 1e-12)
def euler_step_earth_moon(r, v, dt_days):
"""euler integration linear."""
a = accel_earth_moon(r)
# v_new = v + a * dt_days
# r_new = r + v_new * dt_days
r_new = r + v * dt_days
v_new = v + a * dt_days
return r_new, v_new
AU_KM = 149597870.700
DAY_SEC = 86400.0
MOON_DT = 100.0 # the moon needs special care smaller time steps and higher-order integration
def rk4_step_moon_earth_system(r, v, dt):
"""
rk4 integrator for approximating 2nd-order differential equations.
"""
def f(r, v):
return v, accel_earth_moon(r)
k1_r, k1_v = f(r, v)
k2_r, k2_v = f(
r + 0.5 * dt * k1_r,
v + 0.5 * dt * k1_v
)
k3_r, k3_v = f(
r + 0.5 * dt * k2_r,
v + 0.5 * dt * k2_v
)
k4_r, k4_v = f(
r + dt * k3_r,
v + dt * k3_v
)
r_new = r + (dt / 6.0) * (k1_r + 2 * k2_r + 2 * k3_r + k4_r)
v_new = v + (dt / 6.0) * (k1_v + 2 * k2_v + 2 * k3_v + k4_v)
return r_new, v_new
def rk4_step_sun_planet_system(r, v, dt):
"""
rk4 integrator for approximating 2nd-order differential equations.
"""
def f(r, v):
return v, accel_sun(r)
k1_r, k1_v = f(r, v)
k2_r, k2_v = f(
r + 0.5 * dt * k1_r,
v + 0.5 * dt * k1_v
)
k3_r, k3_v = f(
r + 0.5 * dt * k2_r,
v + 0.5 * dt * k2_v
)
k4_r, k4_v = f(
r + dt * k3_r,
v + dt * k3_v
)
r_new = r + (dt / 6.0) * (k1_r + 2 * k2_r + 2 * k3_r + k4_r)
v_new = v + (dt / 6.0) * (k1_v + 2 * k2_v + 2 * k3_v + k4_v)
return r_new, v_new
def propagate_moon_grcs_fine_old(idx, unix_time, target_unix, moon_table):
"""
propagate the Moon around Earth using rk4.
Earth's gravity completely dominates for the moon compared to everything else.
"""
au_xyz = moon_table[idx][0:3]
au_per_d = moon_table[idx][3:]
dt_total = target_unix - unix_time
if abs(dt_total) < 1e-6:
return au_xyz.copy()
r = au_xyz * AU_KM
v = au_per_d * AU_KM / DAY_SEC
sign = np.sign(dt_total)
dt_remaining = abs(dt_total)
dt = MOON_DT
while dt_remaining > 0.0:
h = min(dt, dt_remaining) * sign
# r, v = euler_step_earth_moon(r, v, h)
r, v = rk4_step_moon_earth_system(r, v, h)
dt_remaining -= abs(h)
return r / AU_KM
def propagate_moon_grcs_fine_kelper(idx, unix_time, target_unix, moon_table):
"""
Closed-form 2-body Kepler propagation of the Moon around Earth.
Assumes Earth-only gravity (MU_EARTH), which matches your model.
"""
# --- early exit ---
dt = target_unix - unix_time
if abs(dt) < 1e-6:
return moon_table[idx, 0:3].copy()
# --- initial state (km, km/s) ---
r0 = moon_table[idx, 0:3] * AU_KM
v0 = moon_table[idx, 3:6] * AU_KM / DAY_SEC
dt = float(dt)
r0_norm = np.linalg.norm(r0)
v0_norm = np.linalg.norm(v0)
vr0 = np.dot(r0, v0) / r0_norm
mu = MU_EARTH
# reciprocal of semi-major axis
alpha = 2.0 / r0_norm - (v0_norm ** 2) / mu
# --- solve universal Kepler equation for chi ---
chi = np.sqrt(mu) * abs(alpha) * dt if alpha != 0 else np.sqrt(mu) * dt / r0_norm
def stumpff_C(z):
if z > 0:
s = np.sqrt(z)
return (1.0 - np.cos(s)) / z
elif z < 0:
s = np.sqrt(-z)
return (np.cosh(s) - 1.0) / (-z)
else:
return 0.5
def stumpff_S(z):
if z > 0:
s = np.sqrt(z)
return (s - np.sin(s)) / (s ** 3)
elif z < 0:
s = np.sqrt(-z)
return (np.sinh(s) - s) / (s ** 3)
else:
return 1.0 / 6.0
# Newton solve
for _ in range(6):
z = alpha * chi * chi
C = stumpff_C(z)
S = stumpff_S(z)
F = (
r0_norm * vr0 / np.sqrt(mu) * chi * chi * C
+ (1.0 - alpha * r0_norm) * chi ** 3 * S
+ r0_norm * chi
- np.sqrt(mu) * dt
)
dF = (
r0_norm * vr0 / np.sqrt(mu) * chi * (1.0 - z * S)
+ (1.0 - alpha * r0_norm) * chi * chi * C
+ r0_norm
)
chi -= F / dF
# --- f and g functions ---
z = alpha * chi * chi
C = stumpff_C(z)
S = stumpff_S(z)
f = 1.0 - (chi * chi / r0_norm) * C
g = dt - (chi ** 3 / np.sqrt(mu)) * S
r = f * r0 + g * v0
# return in AU
return r / AU_KM
###########################################################
## earth rotate
def eci_to_ecef_target_timestamp(r_eci, t_unix_start, t_unix_target):
"""
Our GCRS vectors dont care about the spinning Earth (if they did,
classical physics approximations wouldnt work).
GCRS shares the same origin (0, 0, 0) with ECEF, so we just rotate the vectors to match
Earths current spin.
IMHO I cant believe how accurate earth_rotation_angle_from_unix is. This was the part I was most worried about,
but grabbing some numbers online actually worked surprisingly well.
"""
theta = earth_rotation_angle_from_unix(t_unix_start + (t_unix_target - t_unix_start))
c = np.cos(theta)
s = np.sin(theta)
R = np.array([
[c, s, 0.0],
[-s, c, 0.0],
[0.0, 0.0, 1.0]
])
return R @ r_eci
def earth_rotation_angle_from_unix(t_unix):
# unix seconds to Julian Date
JD = JD_UNIX_EPOCH + t_unix / SECONDS_PER_DAY
d = JD - 2451545.0
theta = 2.0 * np.pi * (
0.7790572732640 +
1.00273781191135448 * d
)
return theta % (2.0 * np.pi)
def latlon_to_ecef(lat, lon, radius=6371.0):
# classic equation - z axis goes thru the north pole
lat_rad = np.radians(lat)
lon_rad = np.radians(lon)
x = radius * np.cos(lat_rad) * np.cos(lon_rad)
y = radius * np.cos(lat_rad) * np.sin(lon_rad)
z = radius * np.sin(lat_rad)
return np.array([x, y, z])
def is_visible_est(planet_ecef, user_lat, user_lon):
# once agin the dot product is king
user_ecef = latlon_to_ecef(user_lat, user_lon)
return np.dot(planet_ecef, user_ecef) > 0
#########################
def closest_time_index(unix_time, n, target):
# given our sparse lookup tables (ephemeris), find the closest saved timestamp
if target <= unix_time[0]:
return 0
if target >= unix_time[n - 1]:
return n - 1
left = 0
right = n - 1
while left <= right:
mid = (left + right) // 2
if unix_time[mid] == target:
return mid
elif unix_time[mid] < target:
left = mid + 1
else:
right = mid - 1
if (target - unix_time[right]) <= (unix_time[left] - target):
return right
else:
return left
filename = f"data/{test_planet}_sv_table.npy"
test_planet_table = np.load(filename)
time_table = np.load("data/time_table.npy")
sun_data = np.load("data/sun_sv_table.npy")
accuracy = 0
for i in tqdm(range(trials)):
USER_TIMESTAMP = random.uniform(time_table[0], time_table[-1])
idx = closest_time_index(time_table, len(time_table), USER_TIMESTAMP)
if test_planet == "moon":
r_prop = propagate_moon_grcs_fine_old(idx, time_table[idx], USER_TIMESTAMP, test_planet_table)
else:
r_prop = propagate_planet_grcs(idx, time_table[idx], USER_TIMESTAMP, test_planet_table)
itrs_test_position_est = eci_to_ecef_target_timestamp(r_prop, time_table[idx], USER_TIMESTAMP)
person_lat_lon_est = latlon_to_ecef(USER_LAT, USER_LON)
est_overhead = is_visible_est(itrs_test_position_est, USER_LAT, USER_LON) ## bool
## skyfield truth
dt_utc = datetime.fromtimestamp(USER_TIMESTAMP, tz=timezone.utc)
planet_name_sf = test_planet.lower()
true_overhead = is_planet_overhead(
planet_name_sf,
dt_utc,
USER_LAT,
USER_LON,
)
accuracy += (est_overhead == true_overhead)
print(f"accuracy {100 * accuracy / trials}%")








Comments