Solving GPS Trilateration in SQL
GPS math is really cool. This morning I remembered a problem set I had in undergrad where we had to understand GPS math from first principles. We used either Octave or MATLAB at the time; this morning I was driving and had the idea: can I do this in postgres? If you've read this blog before, you know that getting postgres to do things it wasn't designed to do like solving crossword puzzles is fun.
Today we'll start with a raw L1 NAV GPS message, convert that to a lat/lng, and implement core GPS math... in postgres.
GPS: Basics & Trilateration
I could go into the GPS protocol and message format, but we'll leave that for a future post. For now, let's assume that anywhere in the world, you can reach at least 3 satellites via radio.
The message you get back from each satellite, the L1 NAV message, contains two critical pieces of data:
- Ephemeris Data: Orbital parameters to calculate the satellite's exact position.
- Time of Transmission (T_{tx}): The exact timestamp the signal was fired, measured in nanoseconds.
By measuring how long it takes for a signal to travel from a satellite to your receiver (at the speed of light c), we can derive distance.
1ms = 300km
For these calculations, we measure time in nanoseconds, because that accuracy really matters.
- 1ms = 1,000,000ns.
- In 1ns, light travels 29.98 centimeters.
- In 1ms, light travels about 299.79km.
Imagine a circle with a 300km radius drawn over a map of your state. If your math is off by just 1 millisecond, your search party will have to search that entire area.
Solving IS-GPS-200 Standard in SQL
GPS satellites are not geosynchronous, meaning you don't inherently know their latitude and longitude. The Ephemeris data actually gives us Keplerian orbital elements. Converting Keplerian elements into precise 3D coordinates requires iteratively solving Kepler's equation first:
- M_k is the Mean Anomaly (where the satellite would be if the orbit was a perfect circle).
- e is the eccentricity (how stretched the orbit is).
- E_k is the Eccentric Anomaly (where the satellite actually is in its elliptical orbit).
This is a transcendental equation. It cannot be solved algebraically for E_k. You have to guess an answer, check the error, and refine the guess.
Writing a numerical solver in C++ is easy. Writing a numerical solver in pure SQL requires weaponizing the WITH RECURSIVE CTE, forcing Postgres to loop over itself to execute fixed-point iteration.
CREATE OR REPLACE FUNCTION st_gps_ephemeris_to_wgs84(
sqrt_a float8, e float8, m_0 float8, omega float8,
i_0 float8, omega_0 float8, delta_n float8,
omega_dot float8, i_dot float8,
cuc float8, cus float8, crc float8, crs float8, cic float8, cis float8,
toe float8, current_t float8
)
RETURNS geometry AS $$
WITH constants AS (
SELECT
3.986005e14 AS mu, -- Earth's gravitational constant (m^3/s^2)
7.2921151467e-5 AS omega_e -- Earth's rotation rate (rad/s)
),
step1_prelim AS (
SELECT
(sqrt_a * sqrt_a) AS a,
(current_t - toe) AS tk,
SQRT(c.mu / POWER(sqrt_a * sqrt_a, 3)) AS n_0,
c.omega_e
FROM constants c
),
step2_mean_anomaly AS (
SELECT *, (m_0 + (n_0 + delta_n) * tk) AS mk
FROM step1_prelim
),
-- The Recursive CTE to solve Kepler's Equation
kepler_iter(iteration, ek, mk, e_val) AS (
SELECT 0, mk, mk, e FROM step2_mean_anomaly
UNION ALL
SELECT iteration + 1, mk + e_val * SIN(ek), mk, e_val
FROM kepler_iter
WHERE iteration < 7
),
kepler_solved AS (
SELECT ek FROM kepler_iter ORDER BY iteration DESC LIMIT 1
),
step3_true_anomaly AS (
SELECT
s2.*, ks.ek,
ATAN2(SQRT(1 - e * e) * SIN(ks.ek), COS(ks.ek) - e) AS nu_k
FROM step2_mean_anomaly s2 CROSS JOIN kepler_solved ks
),
step4_perturbations AS (
SELECT *,
(nu_k + omega) AS phi_k,
(cus * SIN(2 * (nu_k + omega)) + cuc * COS(2 * (nu_k + omega))) AS delta_uk,
(crs * SIN(2 * (nu_k + omega)) + crc * COS(2 * (nu_k + omega))) AS delta_rk,
(cis * SIN(2 * (nu_k + omega)) + cic * COS(2 * (nu_k + omega))) AS delta_ik
FROM step3_true_anomaly
),
step5_corrected AS (
SELECT *,
(phi_k + delta_uk) AS uk,
(a * (1 - e * COS(ek)) + delta_rk) AS rk,
(i_0 + delta_ik + i_dot * tk) AS ik,
(omega_0 + (omega_dot - omega_e) * tk - omega_e * toe) AS omega_k
FROM step4_perturbations
),
step6_ecef AS (
SELECT
((rk * COS(uk)) * COS(omega_k) - (rk * SIN(uk)) * COS(ik) * SIN(omega_k)) AS x_ecef,
((rk * COS(uk)) * SIN(omega_k) + (rk * SIN(uk)) * COS(ik) * COS(omega_k)) AS y_ecef,
((rk * SIN(uk)) * SIN(ik)) AS z_ecef
FROM step5_corrected
)
-- Return a 3D WGS84 PointZ (Longitude, Latitude, Altitude)
SELECT
ST_Transform(ST_SetSRID(ST_MakePoint(x_ecef, y_ecef, z_ecef), 4978), 4326)
FROM step6_ecef
$$ LANGUAGE sql IMMUTABLE;
Once we get the Earth-Centered, Earth-Fixed (X_ecef, Y_ecef, Z_ecef) coordinates, converting those back to a usable Latitude/Longitude usually requires another nightmare mathematical process called Bowring's algorithm. But because we are in PostGIS, we just define the raw 3D point as EPSG:4978 (the spatial standard for ECEF space) and ask PostGIS to ST_Transform it to EPSG:4326 (WGS84). PostGIS silently handles the geodetic calculus.
When we pass a satellite's raw ephemeris data into our new function, it hands us back a perfect 3D spatial geometry:
SELECT ST_AsText(
st_gps_ephemeris_to_wgs84(
5153.654321, 0.0054321, 1.54321, 1.01234, 0.95876, 0.51234, 1.123e-9, -1.234e-8, 1.123e-10,
1.2e-6, 1.3e-6, 1.4e-6, 1.5e-6, 1.6e-6, 1.7e-6, 259200.0, 262800.0
)
) AS satellite_wgs84;
-- Output: POINT Z (-78.123 38.123 20200000)
2D Trilateration
Assuming you hold a perfectly synchronized atomic clock, your receiver notes the exact Time of Reception (T_{rx}). We calculate the distance d to satellite i:
In a 3D coordinate system, the intersection of these distances is represented by a system of spheres:
To solve this purely on a 2D surface using PostGIS, we cast satellite coordinates to the geography type and draw distance buffers (ST_Buffer). We then find the geometric center (ST_Centroid) of their intersection (ST_Intersection). For simplicity in these early steps, we'll feed raw Latitude/Longitude directly into the CTE:
WITH constants AS (
SELECT
299792458.0 AS c, -- Speed of light (m/s)
1715284872.000000000 AS clock_time -- Perfect receiver time
),
l1_nav_messages AS (
SELECT 0 AS id, 38.123 AS lat, -78.123 AS lng, 1715284871.932000000 AS emission_time, 5.0 AS error_margin UNION ALL
SELECT 1 AS id, 39.456 AS lat, -77.456 AS lng, 1715284871.921000000 AS emission_time, 5.0 AS error_margin UNION ALL
SELECT 2 AS id, 37.789 AS lat, -79.789 AS lng, 1715284871.945000000 AS emission_time, 5.0 AS error_margin
)
SELECT
ST_AsText(
ST_Centroid(
ST_Intersection(
ST_Intersection(
ST_Buffer(
ST_MakePoint((SELECT lng FROM l1_nav_messages WHERE id = 0), (SELECT lat FROM l1_nav_messages WHERE id = 0))::geography,
((clock_time - (SELECT emission_time FROM l1_nav_messages WHERE id = 0)) * c) + (SELECT error_margin FROM l1_nav_messages WHERE id = 0)
)::geometry,
ST_Buffer(
ST_MakePoint((SELECT lng FROM l1_nav_messages WHERE id = 1), (SELECT lat FROM l1_nav_messages WHERE id = 1))::geography,
((clock_time - (SELECT emission_time FROM l1_nav_messages WHERE id = 1)) * c) + (SELECT error_margin FROM l1_nav_messages WHERE id = 1)
)::geometry
),
ST_Buffer(
ST_MakePoint((SELECT lng FROM l1_nav_messages WHERE id = 2), (SELECT lat FROM l1_nav_messages WHERE id = 2))::geography,
((clock_time - (SELECT emission_time FROM l1_nav_messages WHERE id = 2)) * c) + (SELECT error_margin FROM l1_nav_messages WHERE id = 2)
)::geometry
)
)
) AS estimated_position
FROM constants;
Taking It to the Third Dimension
The 2D solution assumes we are at sea level. In reality, 3D distances form intersecting spheres, not flat circles. Because PostGIS lacks a native ST_3DSphereIntersection function, we must use a 3D Voxel Grid Search.
We generate a vertical line of thousands of points (voxels) at our rough 2D location, stretching into the atmosphere. We then use a spatial cost function (ST_3DDistance) to find the point that minimizes the error between our guessed distances and the actual time-of-flight distances.
WITH constants AS (
SELECT 299792458.0 AS c, 1715284872.000000000 AS clock_time
),
l1_nav_messages AS (
SELECT 0 AS id, 38.123 AS lat, -78.123 AS lng, 20200000 AS alt, 1715284871.932000000 AS emission_time UNION ALL
SELECT 1 AS id, 39.456 AS lat, -77.456 AS lng, 20200000 AS alt, 1715284871.921000000 AS emission_time UNION ALL
SELECT 2 AS id, 37.789 AS lat, -79.789 AS lng, 20200000 AS alt, 1715284871.945000000 AS emission_time
),
target_distances AS (
SELECT
id,
ST_MakePoint(lng, lat, alt) AS geom_3d,
((constants.clock_time - emission_time) * constants.c) AS true_distance
FROM l1_nav_messages, constants
),
rough_2d_fix AS (
SELECT ST_SetSRID(ST_MakePoint(-78.503, 38.033), 4326) AS geom
),
altitude_guesses AS (
-- Voxel grid from -100m to 10,000m at 1m intervals
SELECT ST_MakePoint(ST_X(geom), ST_Y(geom), generate_series(-100, 10000, 1)) AS geom_z
FROM rough_2d_fix
)
SELECT
ST_AsText(geom_z) AS estimated_3d_position,
ST_Z(geom_z) AS calculated_altitude_meters,
(
ABS(ST_3DDistance(geom_z, (SELECT geom_3d FROM target_distances WHERE id = 0)) - (SELECT true_distance FROM target_distances WHERE id = 0)) +
ABS(ST_3DDistance(geom_z, (SELECT geom_3d FROM target_distances WHERE id = 1)) - (SELECT true_distance FROM target_distances WHERE id = 1)) +
ABS(ST_3DDistance(geom_z, (SELECT geom_3d FROM target_distances WHERE id = 2)) - (SELECT true_distance FROM target_distances WHERE id = 2))
) AS total_error
FROM altitude_guesses ag
ORDER BY total_error ASC LIMIT 1;
What if you don't have an atomic clock?
A standard GPS receiver uses a cheap quartz oscillator. At the speed of light, a 1-millisecond clock drift introduces 300 kilometers of error. We must solve for a fourth variable: Receiver Clock Bias (t_{bias}). This requires n=4 satellites.
We upgrade to a 4D Grid Search, executing a Cartesian product (CROSS JOIN) between altitude guesses and nanosecond clock bias guesses.
WITH constants AS (SELECT 299792458.0 AS c),
l1_nav_messages AS (
SELECT 0 AS id, 38.123 AS lat, -78.123 AS lng, 20200000 AS alt, 0.068 AS time_diff UNION ALL
SELECT 1 AS id, 39.456 AS lat, -77.456 AS lng, 20200000 AS alt, 0.079 AS time_diff UNION ALL
SELECT 2 AS id, 37.789 AS lat, -79.789 AS lng, 20200000 AS alt, 0.055 AS time_diff UNION ALL
SELECT 3 AS id, 36.123 AS lat, -80.123 AS lng, 20200000 AS alt, 0.062 AS time_diff
),
target_sats AS (
SELECT id, ST_MakePoint(lng, lat, alt) AS geom_3d, time_diff FROM l1_nav_messages
),
altitude_guesses AS (
SELECT ST_MakePoint(-78.503, 38.033, generate_series(-100, 10000, 10)) AS geom_z
),
clock_bias_guesses AS (
SELECT generate_series(-5000, 5000, 10) AS t_bias_ns
)
SELECT
ST_AsText(ag.geom_z) AS estimated_3d_position,
cbg.t_bias_ns AS calculated_clock_bias_ns,
(
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=0)) - (constants.c * ((SELECT time_diff FROM target_sats WHERE id=0) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=1)) - (constants.c * ((SELECT time_diff FROM target_sats WHERE id=1) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=2)) - (constants.c * ((SELECT time_diff FROM target_sats WHERE id=2) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=3)) - (constants.c * ((SELECT time_diff FROM target_sats WHERE id=3) - (cbg.t_bias_ns / 1e9))))
) AS total_error
FROM altitude_guesses ag
CROSS JOIN clock_bias_guesses cbg, constants
ORDER BY total_error ASC LIMIT 1;
Einstein and Spacetime Curvature
Our math is now perfectly aligned, but our physical location would drift by 11.4 kilometers every day.
- Special Relativity: Satellite orbital velocity (~14,000 km/h) slows their clocks down by 7 \mu s/day.
- General Relativity: Weaker gravity at 20,200 km altitude speeds their clocks up by 45 \mu s/day.
Combined, satellite clocks run fast by a net 38 \mu s/day. The DoD solves the bulk of this in hardware, deliberately tuning satellite clocks down to 10.22999999543 MHz prior to launch so they speed up to exactly 10.23 MHz in orbit.
However, because orbits are slightly elliptical, we must dynamically adjust the time of transmission (T_{tx}) in software using the relativistic drift (\Delta t_r) broadcasted by the satellite:
For the grand finale, we are bringing it all together. We will use our custom st_gps_ephemeris_to_wgs84 UDF to parse raw orbital vectors into 3D positions dynamically, correct the time of flight using relativity, and brute-force the remaining 4 dimensions.
WITH constants AS (SELECT 299792458.0 AS c),
-- Raw L1 Data intercepted by our antenna
l1_nav_messages AS (
SELECT
0 AS id, 0.068 AS time_diff, 0.000000012 AS t_relativity,
5153.654 AS sqrt_a, 0.005 AS e, 1.543 AS m_0, 1.012 AS omega,
0.958 AS i_0, 0.512 AS omega_0, 1.12e-9 AS delta_n, -1.23e-8 AS omega_dot,
1.12e-10 AS i_dot, 1.2e-6 AS cuc, 1.3e-6 AS cus, 1.4e-6 AS crc, 1.5e-6 AS crs,
1.6e-6 AS cic, 1.7e-6 AS cis, 259200.0 AS toe, 262800.0 AS current_t
UNION ALL
SELECT 1, 0.079, 0.000000015, 5153.111, 0.004, 1.1, 1.2, 0.9, 0.5, 1e-9, -1e-8, 1e-10, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 259200.0, 262800.0
UNION ALL
SELECT 2, 0.055, 0.000000009, 5153.222, 0.003, 1.2, 1.3, 0.8, 0.6, 1e-9, -1e-8, 1e-10, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 259200.0, 262800.0
UNION ALL
SELECT 3, 0.062, 0.000000011, 5153.333, 0.002, 1.3, 1.4, 0.7, 0.7, 1e-9, -1e-8, 1e-10, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 259200.0, 262800.0
),
-- Map the raw parameters through our custom UDF
target_sats AS (
SELECT
id, time_diff, t_relativity,
st_gps_ephemeris_to_wgs84(
sqrt_a, e, m_0, omega, i_0, omega_0, delta_n, omega_dot, i_dot,
cuc, cus, crc, crs, cic, cis, toe, current_t
) AS geom_3d
FROM l1_nav_messages
),
altitude_guesses AS (
SELECT ST_MakePoint(-78.503, 38.033, generate_series(-100, 10000, 10)) AS geom_z
),
clock_bias_guesses AS (
SELECT generate_series(-5000, 5000, 10) AS t_bias_ns
)
SELECT
ST_AsText(ag.geom_z) AS estimated_3d_position,
cbg.t_bias_ns AS calculated_clock_bias_ns,
(
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=0)) - (constants.c * (((SELECT time_diff FROM target_sats WHERE id=0) - (SELECT t_relativity FROM target_sats WHERE id=0)) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=1)) - (constants.c * (((SELECT time_diff FROM target_sats WHERE id=1) - (SELECT t_relativity FROM target_sats WHERE id=1)) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=2)) - (constants.c * (((SELECT time_diff FROM target_sats WHERE id=2) - (SELECT t_relativity FROM target_sats WHERE id=2)) - (cbg.t_bias_ns / 1e9)))) +
ABS(ST_3DDistance(ag.geom_z, (SELECT geom_3d FROM target_sats WHERE id=3)) - (constants.c * (((SELECT time_diff FROM target_sats WHERE id=3) - (SELECT t_relativity FROM target_sats WHERE id=3)) - (cbg.t_bias_ns / 1e9))))
) AS total_error
FROM altitude_guesses ag
CROSS JOIN clock_bias_guesses cbg, constants
ORDER BY total_error ASC LIMIT 1;
How Real Receivers Actually Do It
Real navigation chips don't brute-force grids. They use continuous, iterative numerical methods:
1. Gauss-Newton (Least Squares) Iteration: The receiver constructs a Jacobian matrix (J) of partial derivatives, calculating the error (\Delta \rho) between its current guess and actual measurements. It updates its position iteratively:
2. Extended Kalman Filter (EKF): Instead of calculating a static point, an EKF maintains a state vector of position, time bias, and momentum:
It continuously predicts its future state and updates it based on incoming satellite noise.
Because we ORDER BY total_error ASC LIMIT 1, Postgres has to compute four PostGIS floating-point calculations for all 1.01 million rows, run out of work_mem, spill to the physical hard drive, sort it all on disk, and hand back one row.
It is the antithesis of how a relational database is meant to be used, and the absolute worst way to build a navigation engine. But as an exercise in software engineering? It's pure magic.
Happy Sunday and think twice before you go camping.