{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell045",
   "metadata": {},
   "source": [
    "# Count the orbits: a census you can defend\n",
    "\n",
    "\"How many satellites are in low Earth orbit?\" is a question with a number\n",
    "attached, and almost every number you will read in an article comes without the\n",
    "filter that produced it.\n",
    "\n",
    "This notebook produces the number *and* the filter, checks it adds up, and\n",
    "draws it. Then it shows you the sampling mistake that would have made the chart\n",
    "wrong.\n",
    "\n",
    "**What you need:** `pip install requests pandas matplotlib`.\n",
    "\n",
    "**What it costs:** about 15 requests."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell046",
   "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": "cell047",
   "metadata": {},
   "source": [
    "## 1. Counting without downloading\n",
    "\n",
    "Every listing endpoint reports `meta.total`: matching rows across all pages, not\n",
    "just the page you received. Ask for one row per orbit class and read the total.\n",
    "\n",
    "Seven requests, instead of the 165 it would take to page the whole catalog at\n",
    "100 rows a time. It is also the polite thing to do to a public API you are not\n",
    "paying for."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell048",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "# The full enum the API accepts. Taken from the OpenAPI spec at\n",
    "# /api/v1/openapi.json, not guessed: an invalid value returns 400, not zero.\n",
    "ORBIT_CLASSES = [\"LEO\", \"SSO\", \"VLEO\", \"MEO\", \"GEO\", \"HEO\", \"UNKNOWN\"]\n",
    "\n",
    "census = {}\n",
    "for orbit in ORBIT_CLASSES:\n",
    "    census[orbit] = get(\"/satellites\", orbit_class=orbit, limit=1)[\"meta\"][\"total\"]\n",
    "\n",
    "counts = pd.Series(census, name=\"objects\").sort_values(ascending=False)\n",
    "print(counts.to_string())\n",
    "print()\n",
    "print(\"budget:\", budget())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell049",
   "metadata": {},
   "source": [
    "## 2. Make it add up before you believe it\n",
    "\n",
    "A census that does not reconcile against an independently computed total is a\n",
    "guess with a table around it. `/stats` counts the same table a different way, so\n",
    "the two should agree exactly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell050",
   "metadata": {},
   "outputs": [],
   "source": [
    "total_objects = get(\"/stats\")[\"data\"][\"total_objects\"]\n",
    "summed = int(counts.sum())\n",
    "\n",
    "print(\"sum of orbit classes:\", summed)\n",
    "print(\"total from /stats:   \", total_objects)\n",
    "print(\"difference:          \", summed - total_objects)\n",
    "\n",
    "assert summed == total_objects, (\n",
    "    \"The classes no longer partition the catalog. Either a new orbit class was \"\n",
    "    \"added to the API, or an object is classified into none of them. Find out \"\n",
    "    \"which before using any number from this notebook.\"\n",
    ")\n",
    "print(\"\\nExhaustive and non-overlapping. The census is safe to quote.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell051",
   "metadata": {},
   "source": [
    "That assertion is the point of the cell. If OrbitalWiki adds an eighth orbit\n",
    "class next year, this notebook fails loudly instead of quietly under-counting\n",
    "by however many objects landed in the class you did not ask for.\n",
    "\n",
    "Write the check that breaks when your assumption breaks."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell052",
   "metadata": {},
   "source": [
    "## 3. The chart"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell053",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "\n",
    "figure, axes = plt.subplots(figsize=(8, 4.2))\n",
    "bars = axes.bar(counts.index, counts.values, color=\"#3b6ea5\")\n",
    "\n",
    "axes.set_ylabel(\"objects in catalog\")\n",
    "axes.set_title(f\"OrbitalWiki catalog by orbit class (n = {total_objects:,})\")\n",
    "axes.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "\n",
    "for bar, value in zip(bars, counts.values):\n",
    "    axes.text(\n",
    "        bar.get_x() + bar.get_width() / 2,\n",
    "        value,\n",
    "        f\"{value:,}\",\n",
    "        ha=\"center\", va=\"bottom\", fontsize=9,\n",
    "    )\n",
    "\n",
    "# A linear axis puts LEO at the ceiling and flattens everything else into the\n",
    "# baseline. Log makes the small classes readable without misrepresenting the\n",
    "# ratio, as long as the axis is labelled as log.\n",
    "axes.set_yscale(\"log\")\n",
    "axes.set_ylabel(\"objects in catalog (log scale)\")\n",
    "\n",
    "figure.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell054",
   "metadata": {},
   "source": [
    "Two thirds of the catalog is one constellation. Before you write \"LEO is\n",
    "crowded\" as a conclusion about spaceflight in general, find out how much of that\n",
    "bar is a single operator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell055",
   "metadata": {},
   "outputs": [],
   "source": [
    "operators = get(\"/operators\", limit=5)[\"data\"]\n",
    "\n",
    "pd.DataFrame(operators)[[\"operator\", \"total\", \"active\", \"countries\"]]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell056",
   "metadata": {},
   "source": [
    "## 4. The sampling mistake\n",
    "\n",
    "Now the part that would have quietly ruined the analysis.\n",
    "\n",
    "It is tempting to grab one page of 100 objects per class and treat their\n",
    "altitudes as the class. Do it, and then look at what you actually sampled."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell057",
   "metadata": {},
   "outputs": [],
   "source": [
    "def sample_page(orbit, limit=100):\n",
    "    \"\"\"One page of a class. NOT a random sample: see the cell below.\"\"\"\n",
    "    page = get(\"/satellites\", orbit_class=orbit, limit=limit)\n",
    "    frame = pd.DataFrame(page[\"data\"])\n",
    "    frame[\"orbit_class\"] = orbit\n",
    "    return frame\n",
    "\n",
    "\n",
    "samples = pd.concat([sample_page(o) for o in [\"LEO\", \"SSO\", \"MEO\", \"GEO\"]])\n",
    "print(\"budget:\", budget())\n",
    "\n",
    "samples.groupby(\"orbit_class\")[[\"apogee_km\", \"perigee_km\"]].describe().round(0)[\n",
    "    [(\"apogee_km\", \"mean\"), (\"apogee_km\", \"min\"), (\"apogee_km\", \"max\")]\n",
    "]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell058",
   "metadata": {},
   "outputs": [],
   "source": [
    "# What did that page actually contain?\n",
    "samples.groupby(\"orbit_class\")[\"norad_cat_id\"].agg([\"min\", \"max\", \"count\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell059",
   "metadata": {},
   "source": [
    "Look at the NORAD id ranges. The listing endpoint orders by catalog number\n",
    "ascending, so page one of `LEO` is the **oldest hundred LEO objects in the\n",
    "catalog**, launched decades before the constellation that dominates the class\n",
    "today.\n",
    "\n",
    "Those hundred rows are a real, correct answer to a question nobody asked. Their\n",
    "mean altitude is not the mean altitude of LEO, and no amount of statistics\n",
    "applied afterwards fixes a biased draw.\n",
    "\n",
    "There is no `sort=random` on this API, and there should not be one. The honest\n",
    "options are: page the whole class and compute on all of it, use the bulk dataset\n",
    "download at <https://www.orbitalwiki.com/datasets>, or state plainly in your\n",
    "caption that you charted the oldest hundred."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell060",
   "metadata": {},
   "outputs": [],
   "source": [
    "figure, axes = plt.subplots(figsize=(8, 4.2))\n",
    "\n",
    "for orbit, group in samples.groupby(\"orbit_class\"):\n",
    "    axes.scatter(\n",
    "        group[\"perigee_km\"], group[\"apogee_km\"],\n",
    "        s=14, alpha=0.7, label=orbit,\n",
    "    )\n",
    "\n",
    "axes.set_xscale(\"log\")\n",
    "axes.set_yscale(\"log\")\n",
    "axes.set_xlabel(\"perigee (km, log)\")\n",
    "axes.set_ylabel(\"apogee (km, log)\")\n",
    "axes.set_title(\"First 100 catalogued objects per class, NOT a random sample\")\n",
    "axes.legend(frameon=False)\n",
    "axes.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "\n",
    "figure.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell061",
   "metadata": {},
   "source": [
    "Points on the diagonal are near-circular orbits: apogee equals perigee. Points\n",
    "far above it are elliptical, and that is what `HEO` means in practice.\n",
    "\n",
    "The title carries the caveat. If you export this chart, the caveat goes with it,\n",
    "because the chart will outlive the notebook and someone will quote it without\n",
    "the code."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell062",
   "metadata": {},
   "source": [
    "## 5. What this census excludes\n",
    "\n",
    "`/stats` reported the object-class breakdown in the first notebook: essentially\n",
    "all payloads, three debris objects, two rocket bodies.\n",
    "\n",
    "That is because this catalog is built from CelesTrak's *active* group, a curated\n",
    "list of working spacecraft. So every number above is a count of **tracked\n",
    "active payloads**, and it is not a count of objects in orbit, which is a much\n",
    "larger number dominated by debris.\n",
    "\n",
    "Say which one you counted. Most published satellite counts do not, and that is\n",
    "why no two of them agree."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell063",
   "metadata": {},
   "source": [
    "## What to do next\n",
    "\n",
    "- **Join transmitters to catalog records** brings in a second, independent project.\n",
    "- The bulk download at <https://www.orbitalwiki.com/datasets> is one request for\n",
    "  the whole catalog, which is the right tool once you need every row."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell064",
   "metadata": {},
   "source": [
    "Every OrbitalWiki number in this notebook is a **live lookup**, not a frozen\n",
    "release. Two people running this a week apart will get different counts, and\n",
    "both are right. So cite the access date, never just the URL:\n",
    "\n",
    "> OrbitalWiki, `/api/v1/satellites?orbit_class=LEO&limit=1`, retrieved YYYY-MM-DD, https://www.orbitalwiki.com\n",
    "\n",
    "Licence: the OrbitalWiki database is ODbL-1.0. The upstream sources keep their\n",
    "own terms, and the API tells you which ones applied to each response in the\n",
    "`X-OrbitalWiki-Sources` header and the `meta.sources` field. Attribute them too."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  },
  "orbitalwiki": {
   "title": "Count the orbits",
   "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
}
