{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell065",
   "metadata": {},
   "source": [
    "# Join transmitters to catalog records\n",
    "\n",
    "You want to know what an amateur satellite transmits on, and where it is. No\n",
    "single database holds both, and that is normal: orbital elements and radio\n",
    "transmitters are maintained by different communities for different reasons.\n",
    "\n",
    "So this notebook does the join, which is the actual skill.\n",
    "\n",
    "**Be clear about what this is not.** OrbitalWiki has no public transmitter\n",
    "endpoint. The radio pages at <https://www.orbitalwiki.com/radio> are built from\n",
    "SatNOGS DB, so rather than pretend an API exists, this notebook goes to that\n",
    "same upstream project directly and joins it to OrbitalWiki on the NORAD catalog\n",
    "number. That is exactly what OrbitalWiki's own importer does.\n",
    "\n",
    "**What you need:** `pip install requests pandas matplotlib`.\n",
    "\n",
    "**What it costs:** 6 OrbitalWiki requests, plus 6 to SatNOGS DB (separate quota).\n",
    "\n",
    "**Receive only.** Nothing here is transmitting instruction. Transmitting needs a\n",
    "licence, current frequency information from the operator, and local regulatory\n",
    "compliance. Listening needs none of that."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell066",
   "metadata": {},
   "outputs": [],
   "source": [
    "import requests\n",
    "\n",
    "BASE = \"https://www.orbitalwiki.com/api/v1\"\n",
    "\n",
    "# Anonymous callers need no account. The trade is a hard cap of 50 requests per\n",
    "# day shared by everyone on your network, which a classroom of 30 will exhaust\n",
    "# in one lesson. A free key raises that to 1,000/day: sign up, then create one\n",
    "# in the Keys section of https://dashboard.orbitalwiki.com. Paste it here and\n",
    "# every request below is counted against your key instead of the shared pool.\n",
    "API_KEY = None\n",
    "\n",
    "session = requests.Session()\n",
    "session.headers[\"Accept\"] = \"application/json\"\n",
    "# Identify yourself. Not required, but it is what lets an operator tell a\n",
    "# classroom apart from a scraper when something goes wrong.\n",
    "session.headers[\"User-Agent\"] = \"orbitalwiki-notebook (education)\"\n",
    "if API_KEY:\n",
    "    session.headers[\"Authorization\"] = f\"Bearer {API_KEY}\"\n",
    "\n",
    "last_response = None\n",
    "\n",
    "\n",
    "def get(path, **params):\n",
    "    \"\"\"GET one OrbitalWiki endpoint. Returns parsed JSON, raises on failure.\"\"\"\n",
    "    global last_response\n",
    "    last_response = session.get(f\"{BASE}{path}\", params=params, timeout=30)\n",
    "    if last_response.status_code == 429:\n",
    "        raise RuntimeError(\n",
    "            \"Rate limited. Anonymous callers share 50 requests per day per \"\n",
    "            \"network. Reset (unix time): \"\n",
    "            + str(last_response.headers.get(\"X-RateLimit-Reset\"))\n",
    "        )\n",
    "    last_response.raise_for_status()\n",
    "    return last_response.json()\n",
    "\n",
    "\n",
    "def budget():\n",
    "    \"\"\"Requests left in the current window, straight from the response headers.\"\"\"\n",
    "    if last_response is None:\n",
    "        return \"no request made yet\"\n",
    "    return \"{} of {} left\".format(\n",
    "        last_response.headers.get(\"X-RateLimit-Remaining\", \"?\"),\n",
    "        last_response.headers.get(\"X-RateLimit-Limit\", \"?\"),\n",
    "    )"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell067",
   "metadata": {},
   "source": [
    "## 1. The second source\n",
    "\n",
    "SatNOGS DB is an open, crowd-curated database of satellite transmitters run by\n",
    "the Libre Space Foundation. Its transmitter list is public and filterable by\n",
    "NORAD catalog number.\n",
    "\n",
    "Crowd-curated is a real caveat, not a disclaimer to skim: a frequency in there\n",
    "is community-reported, and it can be out of date if a spacecraft was\n",
    "reconfigured. OrbitalWiki records these as medium confidence for that reason."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell068",
   "metadata": {},
   "outputs": [],
   "source": [
    "SATNOGS = \"https://db.satnogs.org/api/transmitters/\"\n",
    "\n",
    "\n",
    "def transmitters(norad):\n",
    "    \"\"\"Every transmitter SatNOGS DB holds for one NORAD catalog number.\"\"\"\n",
    "    response = requests.get(\n",
    "        SATNOGS,\n",
    "        params={\"format\": \"json\", \"satellite__norad_cat_id\": norad},\n",
    "        headers={\"User-Agent\": \"orbitalwiki-notebook (education)\"},\n",
    "        timeout=30,\n",
    "    )\n",
    "    response.raise_for_status()\n",
    "    return response.json()\n",
    "\n",
    "\n",
    "sample = transmitters(27607)  # SO-50, a much-loved FM repeater\n",
    "print(len(sample), \"transmitters\")\n",
    "sample[0]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell069",
   "metadata": {},
   "source": [
    "The fields that matter for us: `downlink_low` and `uplink_low` in **hertz**,\n",
    "`mode` (FM, AFSK, BPSK...), `baud`, `status` (`active` / `inactive`), `service`,\n",
    "and `updated`, which is when a human last touched the record.\n",
    "\n",
    "`alive` and `status` are not the same thing, and neither means \"transmitting\n",
    "right now\". They mean \"believed to be a working transmitter\". A satellite can be\n",
    "alive, active, and silent because it is in eclipse, has a duty cycle, or is\n",
    "simply not over you."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell070",
   "metadata": {},
   "source": [
    "## 2. Bands, from frequencies\n",
    "\n",
    "Amateur radio talks in bands, and the databases talk in hertz. The mapping below\n",
    "is the one OrbitalWiki uses, reproduced here so you can see the thresholds\n",
    "rather than trusting a label."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell071",
   "metadata": {},
   "outputs": [],
   "source": [
    "def band_of(hz):\n",
    "    \"\"\"Band name from a frequency in hertz. None when outside the modelled range.\"\"\"\n",
    "    if hz is None or hz <= 0:\n",
    "        return None\n",
    "    mhz = hz / 1e6\n",
    "    if mhz < 30:\n",
    "        return \"HF\"\n",
    "    if mhz < 300:\n",
    "        return \"VHF\"\n",
    "    if mhz < 1000:\n",
    "        return \"UHF\"\n",
    "    if mhz < 2000:\n",
    "        return \"L\"\n",
    "    if mhz < 4000:\n",
    "        return \"S\"\n",
    "    if mhz < 8000:\n",
    "        return \"C\"\n",
    "    if mhz < 12000:\n",
    "        return \"X\"\n",
    "    return None\n",
    "\n",
    "\n",
    "for hz in (145_825_000, 436_795_000, 2_401_500_000, None):\n",
    "    print(f\"{str(hz):>12} -> {band_of(hz)}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell072",
   "metadata": {},
   "source": [
    "## 3. The join\n",
    "\n",
    "Six well-known amateur spacecraft. For each: the catalog record from\n",
    "OrbitalWiki, the transmitters from SatNOGS, joined on NORAD catalog number."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell073",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "TARGETS = [25544, 43017, 27607, 39444, 40967, 44909]\n",
    "\n",
    "rows = []\n",
    "for norad in TARGETS:\n",
    "    record = get(f\"/satellites/{norad}\")[\"data\"]\n",
    "    for transmitter in transmitters(norad):\n",
    "        downlink = transmitter.get(\"downlink_low\")\n",
    "        rows.append({\n",
    "            \"norad_cat_id\": norad,\n",
    "            \"name\": record[\"name\"],\n",
    "            \"orbit_class\": record[\"orbit_class\"],\n",
    "            \"inclination\": record[\"inclination\"],\n",
    "            \"description\": transmitter.get(\"description\"),\n",
    "            \"mode\": transmitter.get(\"mode\"),\n",
    "            \"downlink_mhz\": round(downlink / 1e6, 4) if downlink else None,\n",
    "            \"band\": band_of(downlink),\n",
    "            \"status\": transmitter.get(\"status\"),\n",
    "            \"service\": transmitter.get(\"service\"),\n",
    "            \"updated\": (transmitter.get(\"updated\") or \"\")[:10],\n",
    "        })\n",
    "\n",
    "joined = pd.DataFrame(rows)\n",
    "print(len(joined), \"transmitter rows across\", joined[\"norad_cat_id\"].nunique(), \"spacecraft\")\n",
    "print(\"budget:\", budget())\n",
    "\n",
    "joined.head(12)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell074",
   "metadata": {},
   "source": [
    "The join key is the NORAD catalog number, and it is the right key precisely\n",
    "because neither project owns it: it is assigned by the US Space Force and both\n",
    "databases inherit it. Joining on *name* would fail immediately, because\n",
    "OrbitalWiki calls one of these `SAUDISAT 1C (SO-50)` and the amateur community\n",
    "calls it `SO-50`.\n",
    "\n",
    "One warning that costs people real time: a NORAD number identifies a catalog\n",
    "entry, not a spacecraft forever. Numbers have been reused and reassigned between\n",
    "catalogs. If a join produces something absurd, suspect the key before you\n",
    "suspect the data."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell075",
   "metadata": {},
   "source": [
    "## 4. What can you actually hear?\n",
    "\n",
    "Filter to active transmitters, and look at where they live."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell076",
   "metadata": {},
   "outputs": [],
   "source": [
    "active = joined[joined[\"status\"] == \"active\"]\n",
    "\n",
    "print(\"active transmitter rows:\", len(active), \"of\", len(joined))\n",
    "print()\n",
    "print(active[\"band\"].value_counts().to_string())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell077",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "\n",
    "by_band = active[\"band\"].value_counts()\n",
    "\n",
    "figure, axes = plt.subplots(figsize=(7, 3.8))\n",
    "axes.bar(by_band.index, by_band.values, color=\"#3b6ea5\")\n",
    "axes.set_ylabel(\"active transmitters\")\n",
    "axes.set_title(f\"Active transmitters by band, {len(TARGETS)} amateur spacecraft\")\n",
    "axes.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "figure.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell078",
   "metadata": {},
   "source": [
    "VHF and UHF dominate, and that is not an accident of sampling. Those bands need\n",
    "the least equipment: a handheld radio and a hand-held Yagi will hear an FM\n",
    "repeater pass. It is why amateur satellite work is one of the cheapest ways into\n",
    "real space data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell079",
   "metadata": {},
   "outputs": [],
   "source": [
    "# How stale is the radio information? A frequency last confirmed years ago is\n",
    "# still probably right, but \"probably\" is the operative word.\n",
    "freshness = (\n",
    "    active.groupby(\"name\")[\"updated\"]\n",
    "    .max()\n",
    "    .sort_values()\n",
    "    .to_frame(\"last_updated_in_satnogs\")\n",
    ")\n",
    "freshness"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell080",
   "metadata": {},
   "source": [
    "## 5. Handing off to a pass prediction\n",
    "\n",
    "Knowing the frequency is half of it. The other half is when the spacecraft is\n",
    "above your horizon, which needs current orbital elements."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell081",
   "metadata": {},
   "outputs": [],
   "source": [
    "tle = get(\"/satellites/27607/tle\")[\"data\"]\n",
    "\n",
    "print(tle[\"name\"])\n",
    "print(tle[\"line1\"])\n",
    "print(tle[\"line2\"])\n",
    "print()\n",
    "print(\"element epoch:\", tle[\"epoch\"])\n",
    "print(\"stale?       \", tle[\"stale_warning\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell082",
   "metadata": {},
   "source": [
    "Feed those two lines to any SGP4 implementation, or use the pass predictor at\n",
    "<https://www.orbitalwiki.com/radio/passes>, which does it for you.\n",
    "\n",
    "Two limits, stated plainly:\n",
    "\n",
    "- The TLE here is **reconstructed** from stored OMM mean elements, and the\n",
    "  element-set number is a placeholder. It propagates correctly; it is not a\n",
    "  byte-identical copy of an official file.\n",
    "- Legacy TLE cannot encode six-digit catalog numbers. Newer objects return a 422\n",
    "  on this endpoint and you use `/omm` instead. That is a real format limitation,\n",
    "  not a bug.\n",
    "\n",
    "And the honest bit about predictions: a predicted pass tells you the geometry\n",
    "worked out. It does not tell you the transmitter was on, your horizon was clear,\n",
    "or your receiver was good enough. Log what you actually heard, including\n",
    "nothing."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell083",
   "metadata": {},
   "source": [
    "## 6. Two licences, both of them yours to honour\n",
    "\n",
    "You just combined two datasets. Redistributing the result binds you to both.\n",
    "\n",
    "- OrbitalWiki database: **ODbL-1.0**, <https://www.orbitalwiki.com/license>\n",
    "- SatNOGS DB: **CC BY-SA 4.0**, <https://db.satnogs.org>\n",
    "\n",
    "CC BY-SA is a share-alike licence, which is the one people forget. If you\n",
    "publish the joined table, that obligation travels with it.\n",
    "\n",
    "> Transmitter data from SatNOGS DB contributors (CC BY-SA 4.0), joined to\n",
    "> OrbitalWiki catalog records (ODbL-1.0) on NORAD catalog number, retrieved\n",
    "> YYYY-MM-DD."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell084",
   "metadata": {},
   "source": [
    "## What to do next\n",
    "\n",
    "- <https://www.orbitalwiki.com/radio/satellites> is the same data as a browsable\n",
    "  catalog with band, mode and service filters.\n",
    "- <https://www.orbitalwiki.com/radio/receive> walks through equipment for\n",
    "  receiving, at the level of \"what do I actually buy first\".\n",
    "- The lesson *Plan a receive-only satellite session* turns this into a\n",
    "  classroom activity."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  },
  "orbitalwiki": {
   "title": "Join transmitters to catalog records",
   "license": "Notebook code CC0. Data ODbL-1.0 from OrbitalWiki; see the licence cell.",
   "source": "https://www.orbitalwiki.com/education/notebooks"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
