Thoughts

Meet Theo: I Just Needed a Theodolite

Skywarden gained support for detailed horizon profiles - but creating one required either expensive surveying hardware or apps that cost €20–30 for a task most astrophotographers do once or twice. So I built Theo: a free PWA that uses your phone's sensors and camera to capture azimuth/altitude pairs around the full 360° and export them as a .hrz file. This is the story of why it exists, how the sensor math works, and where it fits into the Skywarden workflow.

Meet Theo: I Just Needed a Theodolite
When I added proper horizon profile support to Skywarden, I thought the hard part was done. And it was - on the software side. The .hrz file format is simple, the interpolation logic was fun to write, and seeing Skywarden mask visibility windows against a real horizon felt genuinely satisfying.

The hard part turned out to be something else entirely: actually getting that horizon profile in the first place.

The theodolite problem

A proper horizon profile isn't just "there's a hill to the southeast." It's a series of azimuth/altitude pairs that describe the exact shape of your local horizon at every compass bearing. 360 degrees, sampled densely enough to be useful. To capture that accurately, you'd traditionally use a theodolite - a precision optical instrument for measuring horizontal and vertical angles. They're used in surveying, construction, geodesy. They are not cheap. They are not something most astrophotographers own.

There are a handful of apps that do this on a phone. I tried them. Most are either overkill (built for actual surveyors), not maintained, or just clunky to use for this specific task. The ones that do work well tend to cost €20–30. And look - having built Theo, I now genuinely appreciate how much work goes into getting the sensor math right. That price isn't unreasonable for the effort involved. But most astrophotographers will create maybe two or three horizon profiles in their life - one for their backyard, maybe one for a favourite dark sky site. Paying €20 for that feels disproportionate. I didn't need sub-arcsecond precision. I needed something I could point at the treeline behind my house, use for free, and get a decent profile out of in ten minutes.

So I built Theo.

What Theo actually does

The concept is simple: point your phone at the top of an obstruction, tap to record a point. Repeat around the full 360°. Export a .hrz file.

Under the hood it's doing more work than that sentence implies.

Getting orientation right

The browser's DeviceOrientation API hands you three Euler angles: alpha (compass heading), beta (front-to-back tilt), gamma (left-to-right tilt). For casual use they're fine. For measuring altitude angles at steep inclinations they fall apart. Gimbal lock is the culprit - Euler angles have a singularity near 90°, so if you tilt your phone up toward a steep rooftop, the azimuth reading starts drifting or flipping unpredictably.

The fix is to stop working in Euler angles entirely and go back to the rotation matrix. The W3C DeviceOrientation spec actually defines a full 3×3 rotation matrix R = Rz(−α)·Rx(β)·Ry(γ). From that matrix you can derive a stable gravity vector and a stable "where the camera is pointing" vector regardless of tilt angle. Heading comes from projecting the camera direction onto the horizontal plane. Altitude comes from the vertical component: asin(−cos(β)·cos(γ)). No singularities, no flipping.

iOS adds its own wrinkle. Apple's webkitCompassHeading property gives you a north-relative heading, but its relationship to the rotation matrix components drifts depending on how much you've tilted the device. At low tilt angles (below ~30°), I calibrate the north offset from webkitCompassHeading. At steeper angles, the rotation matrix takes over with the frozen offset. It took a few evenings and a lot of frustrated phone-pointing at my ceiling to get that transition right.

The AR overlay

Once the orientation math was solid, I wanted a way to visualize the profile as it's being built - both so you can see the shape emerging and so you can spot gaps in coverage before you stop measuring.

On Android, Theo uses WebXR immersive-AR to anchor the captured horizon points into the live camera feed. Each recorded point is projected back into screen space based on the current device orientation and rendered as a dot; adjacent points close enough in azimuth are connected by line segments, giving you a live outline of the horizon as you scan around.

iOS doesn't properly support WebXR immersive-AR yet (it's been "coming soon" for years at this point), so on Apple devices Theo falls back to a sensor-based projection: same math, but composited manually over a plain camera feed using the Canvas API rather than the WebXR compositor. Technically a completely different code path - and unfortunately it shows. Without depth data from the WebXR session, the overlay has no spatial anchor. It's purely orientation-driven, which means any sensor jitter translates directly into wobbly, drifting points on screen. It works well enough to be useful, but it's nowhere near as stable as the Android experience. Something to be aware of if you're on iOS.

There's also a circular minimap in the corner - a polar coordinate view of the full 360°, outer ring at 0° altitude, center at zenith, north at the top. As you capture points they appear on the minimap, and a dashed radial line tracks your current pointing direction. It sounds like a small detail but in practice it's the most useful part: you can immediately see which azimuths you haven't covered yet.

The .hrz format

The export format is dead simple: one line per sample, azimuth altitude in degrees, space-separated. Stellarium defined it, SkySafari supports it, and it was a natural fit for Skywarden's horizon interpolation. Theo accepts negative altitudes too - for locations where the mathematical horizon is actually above you (a valley, surrounded by hills) the profile dips below 0°, and that's valid data worth keeping.

The Skywarden connection

Custom horizon setting in Skywarden

Once you have a .hrz file, you load it into Skywarden. From that point on, every visibility calculation is aware of your actual horizon - not just "avoid objects below 20°" but "avoid this specific object between azimuth 110° and 160° because there's a building there, even though it only reaches 8° altitude."

Altitude chart in Skywarden

The newest addition in Skywarden is an altitude chart per object, showing the full elevation curve over the night alongside your horizon profile. You can see exactly when an object clears your obstructions and for how long. That's the payoff. That's why Theo exists.

Was it overkill?

Probably, for my own use case. I could have just eyeballed the compass grid and called it good enough. But "good enough" felt like leaving the feature half-finished. Skywarden already had the infrastructure to use a proper horizon profile - it just needed a frictionless way to create one.

Theo is that. Lightweight, focused, does one thing. You can use it with any app that accepts .hrz files - Stellarium, SkySafari, and now Skywarden. It took a few evenings to build and a few more to get the sensor math right, and I've used it more than I expected.


Theo is a free PWA. Skywarden is its natural habitat.

← Previous

No Ads. No Paywall. Just a Request.

Next →

Why I Switched Back to Claude