Pie Matrix
A real-time astronomy engine on mobile — orbital mechanics, ephemeris math, and celestial coordinate transforms in React Native.
- Role
- Lead Engineer
- Timeline
- 2024
- Team
- 4 people
- Year
- 2024
- React Native
- TypeScript
- Astronomy Engine
- WebGL
- Expo
- GPS APIs
Problem
Astronomy apps that actually compute the sky — real planetary positions, not a pre-baked star chart — are almost always native desktop software or thin wrappers around a server API. Doing it on a phone, offline, in real time means running genuine orbital mechanics and coordinate transforms at 60fps on a battery-powered device. This is not CRUD. There is no database call that returns "where is Jupiter right now from the user's exact location" — you have to compute it from first principles.
Target user: amateur astronomers and stargazers who want an accurate, responsive sky map that works in the field, without signal.
Constraints:
- Positions must be astronomically accurate — arcsecond-level, matching reference ephemeris data, or a serious observer will not trust it.
- It must render in real time (sub-16ms frames) while continuously recomputing positions as time and the device orientation change.
- It has to run on mobile hardware and not destroy the battery.
- It must work from the user's exact location and local time, which drags in GPS, timezones, and astronomical time systems.
Business goal: a credible, field-usable sky engine that powers the wider Sky Circle community experience.
Architecture
Pie Matrix is a cross-platform React Native (Expo) app with a dedicated computation core kept strictly separate from the render layer.
- Astronomy Engine layer — pure TypeScript modules for orbital mechanics, ephemeris calculation, and celestial coordinate systems. No UI, no side effects — just math, so it is testable and cache-able.
- Time & location layer — resolves the observer's position via GPS, converts civil time into astronomical time systems (UT1 / TT / sidereal time), and handles timezone offsets. Everything downstream is a function of "where and when."
- Rendering pipeline — a WebGL-accelerated canvas draws the celestial sphere and bodies, with level-of-detail so distant/faint objects cost less.
- Computation scheduler — batches and defers non-critical recalculation so the main thread stays free for 60fps rendering.
Data flow
- GPS + device clock → observer location and precise astronomical time.
- Ephemeris math computes each body's position in a celestial coordinate system (RA/Dec).
- Coordinates are transformed to the observer's horizontal frame (altitude/azimuth) using sidereal time and latitude.
- The renderer projects those onto the screen and draws at frame rate.
- The scheduler recomputes slow-changing bodies infrequently and fast-changing view transforms every frame.
Try it live
SkyWatch · Live Sky Map
Not a mockup — the sky here is actually computed. Real star positions plus the Sun, Moon, and planets are projected to New Delhi's horizon through the same Julian-date → sidereal-time → coordinate-transform pipeline the case study describes. Time-travel forward or back and watch the whole sky rotate; tap any object for its live-computed details.
Interactive demo · sample data, no real account
Engineering Decisions
Compute the sky, don't fetch it. The defining decision. A server API would have been trivial but would break offline, add latency, and cap accuracy at whatever the API exposed. Implementing Ephemeris Calculations and orbital mechanics directly in TypeScript keeps the app fully offline and arcsecond-accurate. Tradeoff: the math is genuinely hard (trigonometry, coordinate transforms, time systems) and unforgiving — a wrong sign flips a planet to the other side of the sky.
Keep the Astronomy Engine pure and UI-free. The computation core is pure
functions of (time, location). This made it unit-testable against JPL
reference ephemeris values and let me memoize aggressively. Tradeoff: more
architectural discipline up front, but it is the only reason the accuracy is
verifiable.
React Native + TypeScript for one accurate codebase on both platforms. Cross-platform mobile development from a single TypeScript codebase meant the math — the risky part — is written once and identical on iOS and Android. Tradeoff: squeezing 60fps scientific rendering out of RN required a WebGL layer rather than plain views.
A computation scheduler instead of recomputing everything every frame. Planets move slowly; the view transform changes constantly. Separating those cadences (via performance engineering and mobile optimization) is what took battery drain down ~65%. The subtle part is that "slow" is not one bucket: the Moon moves ~0.5°/hour and the eye notices, while Neptune is effectively static across a session. The scheduler tiers bodies by angular speed rather than using a single global interval, so the Moon recomputes often enough to look alive and the outer planets ride the cheapest cadence. Tradeoff: more bookkeeping and a per-body classification, but a flat interval either burns battery on Neptune or makes the Moon stutter — there is no single number that satisfies both.
J2000 mean positions over full precession/nutation — for now. I chose the J2000 mean-equinox approximations rather than applying the full IAU precession/nutation series. For a field sky map viewed in the present, the difference is well under the ~1″ deviation the app already tolerates, and the full series is a meaningful per-frame cost. Tradeoff, stated honestly: this caps long-range historical/future accuracy, so "what would the sky look like in the year 3000" is out of scope until the precession terms are added. It was the right call for the actual use case (tonight's sky, in the field) and the wrong one for an ephemeris library, which is a distinction worth being explicit about.
Source Code Walkthrough
Local sidereal time — the hinge between "when" and "where in the sky"
Every horizontal-coordinate transform depends on local sidereal time. Getting this right (and fast) is foundational.
// astronomy/time.ts
// Why: altitude/azimuth of any body depends on Local Sidereal Time, derived
// from Julian Date + the observer's longitude. This is called every frame, so
// it must be pure and cheap.
export function localSiderealTime(jd: number, longitudeDeg: number): number {
const d = jd - 2451545.0; // days since J2000.0 epoch
// Greenwich Mean Sidereal Time (degrees), IAU polynomial.
const gmst = 280.46061837 + 360.98564736629 * d;
const lst = (gmst + longitudeDeg) % 360;
return lst < 0 ? lst + 360 : lst; // normalize to [0, 360)
}Why this way: the constants are the standard IAU expressions; keeping it a pure numeric function means it is trivially testable and safe to call at frame rate. Improvements: add nutation/precession terms for higher long-term precision.
Equatorial → horizontal coordinate transform
// astronomy/coords.ts
// Why: the engine computes bodies in equatorial coords (RA/Dec); the screen
// needs the observer's horizontal frame (altitude/azimuth). This trig transform
// is the bridge, parameterized by latitude and the hour angle from LST.
export function equatorialToHorizontal(
raHours: number, decDeg: number, latDeg: number, lstDeg: number,
): { altitude: number; azimuth: number } {
const ha = toRad(lstDeg - raHours * 15); // local hour angle
const dec = toRad(decDeg);
const lat = toRad(latDeg);
const sinAlt = Math.sin(dec) * Math.sin(lat)
+ Math.cos(dec) * Math.cos(lat) * Math.cos(ha);
const altitude = Math.asin(sinAlt);
const cosAz = (Math.sin(dec) - Math.sin(altitude) * Math.sin(lat))
/ (Math.cos(altitude) * Math.cos(lat));
let azimuth = Math.acos(clamp(cosAz, -1, 1));
if (Math.sin(ha) > 0) azimuth = 2 * Math.PI - azimuth; // correct quadrant
return { altitude: toDeg(altitude), azimuth: toDeg(azimuth) };
}Performance considerations: all pure trig, no allocations — cheap enough to
run per body per frame. The clamp guards against acos domain errors from
floating-point drift. Improvements: precompute sin/cos(lat) once per frame
since latitude is constant across all bodies.
Deferring slow-changing work with the scheduler
// engine/scheduler.ts
// Why: planetary positions barely change frame-to-frame; the view transform
// changes constantly. Recompute each on its own cadence to protect 60fps and
// battery (this cut drain ~65%).
scheduler.every(FRAME, () => updateViewTransform(orientation)); // every frame
scheduler.every(2000, () => recomputeEphemeris(bodies, now())); // every 2sWhy this way: decoupling cadences is the single biggest performance win — the expensive scientific computing runs 30x less often than the render loop.
Julian Date from civil time — the input every calculation shares
Before any position can be computed, civil time has to become a Julian Date. The
subtlety is that JavaScript's Date is UTC-based but astronomical algorithms
expect a continuous day count with a fractional component, and the Gregorian
calendar switchover has to be handled explicitly.
// astronomy/time.ts
// Why: Julian Date is the universal time coordinate for the whole engine. Every
// ephemeris and sidereal calculation is a function of it, so a bug here is a
// bug everywhere. Kept pure and total: no throw paths, defined for all inputs.
export function julianDate(date: Date): number {
let year = date.getUTCFullYear();
let month = date.getUTCMonth() + 1; // getUTCMonth is 0-indexed
const dayFraction =
date.getUTCDate() +
(date.getUTCHours() +
date.getUTCMinutes() / 60 +
date.getUTCSeconds() / 3600) /
24;
// Jan/Feb are treated as months 13/14 of the previous year (Meeus 7.1).
if (month <= 2) {
year -= 1;
month += 12;
}
// Gregorian-calendar correction term. Dates before 1582-10-15 are Julian and
// would need B = 0; the app only ever handles modern dates, so we assume
// Gregorian and document the assumption rather than branch on it every frame.
const a = Math.floor(year / 100);
const b = 2 - a + Math.floor(a / 4);
return (
Math.floor(365.25 * (year + 4716)) +
Math.floor(30.6001 * (month + 1)) +
dayFraction +
b -
1524.5
);
}Why this way: the constants come straight from Meeus' Astronomical
Algorithms so the output is auditable against a textbook, not a black box.
Keeping it total (no exceptions, defined for every Date) means the render loop
never has to guard the call site. Edge cases / improvements: the Gregorian
assumption is deliberate and documented — supporting pre-1582 Julian-calendar
dates would add a branch to a hot path for a case the app never hits. A future
version could accept a leap-second table (ΔT) to convert UTC → TT precisely
instead of treating them as equal, which is the current largest source of
long-term drift.
Memoizing slow bodies without lying about fast ones
The scheduler decides when to recompute; this cache decides what is worth reusing. The trick is keying on a quantized time bucket so the memo actually hits, instead of on a raw millisecond timestamp that never repeats.
// engine/ephemeris-cache.ts
// Why: the Sun and outer planets move imperceptibly across a 2s scheduler tick,
// but their ephemeris math is the most expensive per body. Cache the result on
// a coarse time bucket so repeated frames within the bucket are free, while the
// Moon (fast) is excluded and always recomputed.
const SLOW_BODY_BUCKET_MS = 2_000;
const cache = new Map<string, EquatorialCoord>();
export function cachedPosition(
body: Body,
jd: number,
compute: (body: Body, jd: number) => EquatorialCoord,
): EquatorialCoord {
if (body.angularSpeed === 'fast') {
return compute(body, jd); // Moon/ISS: never cache, motion is visible.
}
// Quantize time so nearby frames share a key; JD*86_400_000 == ms since epoch.
const bucket = Math.floor((jd * 86_400_000) / SLOW_BODY_BUCKET_MS);
const key = `${body.id}:${bucket}`;
const hit = cache.get(key);
if (hit) return hit;
const value = compute(body, jd);
cache.set(key, value);
if (cache.size > 512) cache.clear(); // bounded: bodies * a few buckets.
return value;
}Performance considerations: quantizing the key is what makes the cache
useful — keying on the raw jd would produce a unique key every frame and a
0% hit rate. The fast guard is a correctness decision: caching the Moon would
make it visibly stutter, so we pay the compute cost only where the eye can tell.
Edge cases / improvements: the clear() at 512 entries is a crude bound
that trades a rare cold-start spike for zero eviction bookkeeping; a real LRU
would be smoother but adds allocation to a hot path. Bucket size is coupled to
the scheduler's 2s cadence — they should be derived from one constant so they
cannot drift apart.
Technical Challenges
- Real-time rendering vs. computational accuracy. Holding sub-16ms frames while running real ephemeris math forced the pure-core + scheduler split.
- Astronomical time systems. Civil time → Julian Date → sidereal time, with timezone and GPS longitude folded in; an error here shifts the entire sky.
- Coordinate-system correctness. Equatorial↔horizontal transforms are quadrant-sensitive; a missing sign check puts bodies in the wrong half of the dome.
- Mobile performance & battery. 60fps scientific rendering on mid-range Android meant WebGL, level-of-detail, and the computation scheduler.
- Cross-platform parity. The math had to be bit-for-bit identical on iOS and Android — solved by keeping it in one pure TypeScript core.
Code Quality & Testing
Type safety. The engine is TypeScript in strict mode with
noUncheckedIndexedAccess on, which matters here because ephemeris tables are
array-indexed and an out-of-range lookup should be a compile-time T | undefined, not a silent NaN that propagates into a coordinate transform.
Angles are carried as branded types (type Degrees = number & { __brand: 'deg' }, likewise Radians) so a function that expects radians cannot be
handed degrees — the single most common bug in this domain, caught by the
compiler instead of by a planet appearing in the wrong hemisphere.
Testing strategy. The whole reason the core is pure is testability:
- Reference-value tests are the backbone. Known
(time, location)inputs are asserted against JPL HORIZONS ephemeris output, with a tolerance expressed in arcseconds rather than raw float equality — e.g. Jupiter at a fixed UTC from a fixed lat/long must land within ~1″ of the JPL vector. These are the tests that let me trust the engine. - Property-based tests (fast-check) cover the invariants that must hold for
every input, not just sampled ones: round-tripping equatorial → horizontal →
equatorial returns the original within float tolerance; altitude always stays
in
[-90°, 90°]; azimuth normalizes into[0°, 360°); sidereal time is monotonic across a day modulo wraparound. fast-check shrinks any violation to a minimal failing(jd, lat, long)triple, which is far more useful than a single hand-picked case. - Determinism tests assert the same inputs produce identical outputs across
runs (no hidden
Date.now()or locale dependence), which is what guarantees iOS/Android parity.
Error handling & observability. The math layer is deliberately total —
functions like julianDate and the coordinate transforms are defined for all
inputs and never throw, so the render loop has no error branches on the hot
path. Fallibility lives at the boundary instead: GPS acquisition and permission
denial are modeled as an explicit Result-style union
({ ok: true; fix } | { ok: false; reason }) the UI handles, rather than
exceptions thrown from deep in the engine. Frame time and per-body compute cost
are sampled into a lightweight in-app profiler overlay so regressions in the
16ms budget show up on-device during development.
Code organization. Hard module boundary: astronomy/ (pure math — time,
coords, ephemeris) has zero imports from engine/ or any React/RN module, and
that direction is enforced with an eslint-plugin-import no-restricted-paths
rule. The dependency arrow only ever points toward the pure core. engine/
owns scheduling and caching; the render layer consumes results and knows nothing
about how they were produced.
Enforced in CI. tsc --noEmit (strict), ESLint including the boundary rule,
the JPL reference-value suite, and the fast-check property suite all run on every
push; a failing arcsecond tolerance fails the build. The reference tests double
as a regression guard — any refactor of the math that shifts a position beyond
tolerance is caught before merge.
Benchmarks
Frame times land at ~9ms on an iPhone 13 and ~15ms on mid-range Android — inside the 16ms budget for 60fps real-time rendering. Computed planetary positions track JPL reference ephemeris to roughly arcsecond-level mean deviation. The computation scheduler cut battery drain to ~35% of the naive recompute-every-frame baseline. See the charts above.
Lessons Learned
- What succeeded: the pure, UI-free Astronomy Engine. Being able to test it against JPL data is the only reason I trust the output.
- What failed first: an early build recomputed every body every frame and cooked the battery; the scheduler fixed both battery and jank.
- What I would redesign: add precession/nutation from day one for long-range precision rather than the J2000 mean approximations.
- Remaining tech debt: the WebGL layer could move more work to shaders instead of the JS thread.
- Future improvements: occlusion of below-horizon bodies, light-pollution overlays, and AR alignment with the device camera.
Media
- Recording of the live sky updating as time and orientation change.
- Screenshots of the celestial map with labeled planets and constellations.
- Diagram of the GPS/time → ephemeris → coordinate-transform → WebGL pipeline.
Documentation Links
- Community: theskycircle.com
- Internal notes cover the ephemeris model, the time-system conversions, and the render scheduler.
Build Logs
- Requirements & planning. Decided the sky must be computed, not fetched — offline accuracy was the whole point.
- Architecture. Pure Astronomy Engine core, a time/location layer, a WebGL renderer, and a computation scheduler.
- Implementation. Built Julian-date/sidereal-time math, ephemeris calculations, and equatorial→horizontal transforms; wired them to a WebGL render loop.
- Testing. Validated positions against JPL reference ephemeris; unit-tested the pure math across dates and locations.
- Deployment. Cross-platform React Native (Expo) builds for iOS and Android.
- Monitoring. Frame-time and battery profiling on real mid-range hardware.
- Retrospective. The pure-core + scheduler architecture is what made an accurate, 60fps, offline sky engine possible on a phone.