Powered by AppSignal & Oban Pro

ISS Tracker

examples/iss_tracker.livemd

ISS Tracker

Mix.install([
  {:orbis, github: "neilberkman/orbis"},
  {:kino, "~> 0.14"},
  {:kino_maplibre, "~> 0.1"}
])

Fetch the ISS

{:ok, [iss]} = Orbis.CelesTrak.fetch_tle(25544)
IO.puts("Catalog: #{iss.catalog_number}")
IO.puts("Epoch: #{iss.epoch}")
IO.puts("Period: #{Float.round(1440 / iss.mean_motion, 1)} minutes")

Where Is It Right Now?

now = DateTime.utc_now()
{:ok, geo} = Orbis.geodetic(iss, now)

Kino.Markdown.new("""
### ISS Position at #{Calendar.strftime(now, "%H:%M:%S UTC")}

| | |
|---|---|
| **Latitude** | #{Float.round(geo.latitude, 4)}° |
| **Longitude** | #{Float.round(geo.longitude, 4)}° |
| **Altitude** | #{Float.round(geo.altitude_km, 1)} km |
""")

Plot the Ground Track

Propagate the ISS over one full orbit (~93 minutes) and plot the ground track:

# Propagate every 30 seconds for one orbit
points =
  for i <- 0..186 do
    dt = DateTime.add(now, i * 30, :second)
    {:ok, geo} = Orbis.geodetic(iss, dt)
    %{lat: geo.latitude, lon: geo.longitude, alt: geo.altitude_km, time: dt}
  end

# Current position
current = hd(points)

# Build GeoJSON for the track
track_coords = Enum.map(points, fn p -> [p.lon, p.lat] end)

MapLibre.new(
  style: :street,
  center: {current.lon, current.lat},
  zoom: 2
)
|> MapLibre.add_source("track",
  type: :geojson,
  data: %{
    type: "Feature",
    geometry: %{type: "LineString", coordinates: track_coords}
  }
)
|> MapLibre.add_layer(
  id: "track-line",
  type: :line,
  source: "track",
  paint: %{"line-color" => "#ff4444", "line-width" => 2}
)
|> MapLibre.add_source("iss-now",
  type: :geojson,
  data: %{
    type: "Feature",
    geometry: %{type: "Point", coordinates: [current.lon, current.lat]},
    properties: %{title: "ISS"}
  }
)
|> MapLibre.add_layer(
  id: "iss-marker",
  type: :circle,
  source: "iss-now",
  paint: %{
    "circle-radius" => 8,
    "circle-color" => "#ff0000",
    "circle-stroke-width" => 2,
    "circle-stroke-color" => "#ffffff"
  }
)

Look Angles from Your Location

# Change these to your location!
my_station = %{latitude: 40.7128, longitude: -74.006, altitude_m: 10.0}

{:ok, look} = Orbis.look_angle(iss, now, my_station)

status =
  if look.elevation > 0 do
    "🟢 **ABOVE HORIZON** — go outside and look!"
  else
    "🔴 Below horizon (#{Float.round(look.elevation, 1)}°)"
  end

Kino.Markdown.new("""
### ISS from New York City

| | |
|---|---|
| **Azimuth** | #{Float.round(look.azimuth, 1)}° |
| **Elevation** | #{Float.round(look.elevation, 1)}° |
| **Range** | #{Float.round(look.range_km, 0)} km |
| **Status** | #{status} |
""")

Next Passes

tomorrow = DateTime.add(now, 86400, :second)

passes = Orbis.Passes.predict(iss, my_station, now, tomorrow, min_elevation: 10.0)

rows =
  Enum.map(passes, fn pass ->
    duration = DateTime.diff(pass.set, pass.rise)

    %{
      rise: Calendar.strftime(pass.rise, "%H:%M:%S"),
      set: Calendar.strftime(pass.set, "%H:%M:%S"),
      max_el: "#{Float.round(pass.max_elevation, 1)}°",
      duration: "#{duration}s"
    }
  end)

Kino.DataTable.new(rows, name: "ISS Passes (next 24h, >10° elevation)")

RF Link Budget

What signal would you get from a 437 MHz amateur radio contact?

if look.elevation > 0 do
  fspl = Orbis.RF.fspl(look.range_km, 437.0)
  eirp = Orbis.RF.eirp(30.0, 10.0)  # 1W + 10 dBi Yagi

  margin = Orbis.RF.link_margin(%{
    eirp_dbw: eirp,
    fspl_db: fspl,
    receiver_gt_dbk: -20.0,
    other_losses_db: 3.0,
    required_cn0_dbhz: 30.0
  })

  Kino.Markdown.new("""
  ### UHF Link Budget (437 MHz)

  | | |
  |---|---|
  | **EIRP** | #{Float.round(eirp, 1)} dBW |
  | **Path Loss** | #{Float.round(fspl, 1)} dB |
  | **Link Margin** | #{Float.round(margin, 1)} dB |
  | **Link** | #{if margin > 0, do: "✅ Closes", else: "❌ Does not close"} |
  """)
else
  Kino.Markdown.new("*ISS is below the horizon — link budget not applicable.*")
end

Real-Time Tracking

Start live tracking with 2-second updates:

frame = Kino.Frame.new()

{:ok, tracker} = Orbis.Tracker.start_link(iss, interval_ms: 2000)
Orbis.Tracker.subscribe(tracker)

Kino.listen(tracker, fn {:orbis_tracker, _pid, state} ->
  geo = state.geodetic

  content =
    Kino.Markdown.new("""
    **#{Calendar.strftime(state.time, "%H:%M:%S UTC")}** —
    #{Float.round(abs(geo.latitude), 2)}°#{if geo.latitude >= 0, do: "N", else: "S"},
    #{Float.round(abs(geo.longitude), 2)}°#{if geo.longitude >= 0, do: "E", else: "W"},
    #{Float.round(geo.altitude_km, 1)} km
    """)

  Kino.Frame.render(frame, content)
end)

frame

To stop tracking:

Orbis.Tracker.stop(tracker)