{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell001",
   "metadata": {},
   "source": [
    "# Your first OrbitalWiki API request\n",
    "\n",
    "You will make four real requests to a live public satellite catalog, read the\n",
    "answers, and understand the envelope every response comes wrapped in.\n",
    "\n",
    "No account, no key, no signup. Just `requests`.\n",
    "\n",
    "**What you need:** Python 3.9+ and `pip install requests pandas`.\n",
    "\n",
    "**What it costs:** 4 requests out of the 50/day anonymous allowance."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell002",
   "metadata": {},
   "source": [
    "## 1. A client that tells you what it spent\n",
    "\n",
    "Almost every API tutorial hands you a bare `requests.get`. That hides the two\n",
    "things you will actually trip over: the rate limit, and the difference between\n",
    "\"no results\" and \"you were blocked\".\n",
    "\n",
    "The helper below raises loudly on a 429 instead of returning an empty list that\n",
    "looks like a legitimate zero-result answer."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell003",
   "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",
    "\n",
    "    Treat the number as an upper bound, not a count. Cacheable endpoints such\n",
    "    as /stats are served from a CDN, and a cached hit never reaches the rate\n",
    "    limiter, so the counters it carries are the ones captured when the cache\n",
    "    entry was filled, up to one TTL (10 minutes for /stats) ago. Size a script\n",
    "    by handling the 429, not by pre-counting against this.\n",
    "    \"\"\"\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": "cell004",
   "metadata": {},
   "source": [
    "## 2. How big is the catalog?\n",
    "\n",
    "`/stats` is the cheapest call on the API: one request, no parameters, and it\n",
    "answers the question people actually arrive with."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell005",
   "metadata": {},
   "outputs": [],
   "source": [
    "stats = get(\"/stats\")[\"data\"]\n",
    "\n",
    "print(\"objects tracked:  \", stats[\"total_objects\"])\n",
    "print(\"active payloads:  \", stats[\"active_payloads\"])\n",
    "print(\"with current OMM: \", stats[\"with_omm\"])\n",
    "print(\"last source fetch:\", stats[\"latest_source_fetched_at\"])\n",
    "print()\n",
    "print(\"by object class:\", stats[\"by_object_class\"])\n",
    "print(\"budget:\", budget())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell006",
   "metadata": {},
   "source": [
    "Two things worth stopping on.\n",
    "\n",
    "`catalog_updated_at` is `None`, and the response says why in\n",
    "`timestamp_note`: there is no single moment the whole catalog updated, because\n",
    "different sources refresh on different schedules. An API that invented one\n",
    "number there would be lying to you politely. `latest_source_fetched_at` is the\n",
    "honest version, the newest fetch across all sources.\n",
    "\n",
    "And look at `by_object_class`. Almost everything is `PAYLOAD`. This catalog is\n",
    "built from CelesTrak's *active* group, which is a curated list of working\n",
    "spacecraft, so it is not a debris catalog and you should not use it to make\n",
    "claims about debris. Knowing what a dataset excludes is worth more than knowing\n",
    "what it contains."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell007",
   "metadata": {},
   "source": [
    "## 3. One satellite, in full\n",
    "\n",
    "Every response has the same three-part shape:\n",
    "\n",
    "| key | what it holds |\n",
    "| --- | --- |\n",
    "| `object` | what kind of thing came back (`satellite`, `list`, `stats`) |\n",
    "| `data` | the actual payload |\n",
    "| `meta` | licence, attribution, sources, and paging |\n",
    "\n",
    "Ask for the International Space Station, NORAD catalog number 25544."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell008",
   "metadata": {},
   "outputs": [],
   "source": [
    "iss = get(\"/satellites/25544\")\n",
    "\n",
    "record = iss[\"data\"]\n",
    "print(record[\"name\"], \"/\", record[\"cospar_id\"])\n",
    "print(\"orbit class:  \", record[\"orbit_class\"])\n",
    "print(\"inclination:  \", record[\"inclination\"], \"deg\")\n",
    "print(\"period:       \", record[\"period_minutes\"], \"min\")\n",
    "print(\"apogee/perigee:\", record[\"apogee_km\"], \"/\", record[\"perigee_km\"], \"km\")\n",
    "print(\"element epoch:\", record[\"epoch\"])\n",
    "print()\n",
    "print(\"sources used:\", iss[\"meta\"][\"sources\"])\n",
    "print(\"licence:     \", iss[\"meta\"][\"license\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell009",
   "metadata": {},
   "source": [
    "`epoch` is the single most misread field in orbital data. It is the moment the\n",
    "orbital elements describe, *not* the moment you asked. Elements age: propagate\n",
    "a two-week-old element set and your predicted position can be wrong by\n",
    "kilometres. Whenever you use this record for anything, carry the epoch with it.\n",
    "\n",
    "The record also carries a `claims` list, which is where every value's source\n",
    "lives. That is a whole notebook of its own: see *Trace a catalog value to its\n",
    "source*."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell010",
   "metadata": {},
   "source": [
    "## 4. Lists, filters, and the count you did not have to download\n",
    "\n",
    "Listing endpoints return `meta.total`, the number of rows matching your filter\n",
    "across *all* pages, not just the page you got. So if you only want the count,\n",
    "ask for one row and read the total. One request instead of hundreds."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell011",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "geo = get(\"/satellites\", orbit_class=\"GEO\", limit=5)\n",
    "\n",
    "print(\"GEO objects in the catalog:\", geo[\"meta\"][\"total\"])\n",
    "print(\"rows returned on this page:\", len(geo[\"data\"]))\n",
    "print(\"next page:\", geo[\"meta\"][\"next\"])\n",
    "print()\n",
    "\n",
    "pd.DataFrame(geo[\"data\"])[\n",
    "    [\"norad_cat_id\", \"name\", \"operator\", \"country_code\", \"period_minutes\", \"apogee_km\"]\n",
    "]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell012",
   "metadata": {},
   "source": [
    "Check the periods in that table. Geostationary objects sit near 1,436 minutes,\n",
    "one sidereal day, which is what makes them appear to hang still over a fixed\n",
    "point. If a row in a `GEO` filter has a period far from that, it is a\n",
    "classification question worth chasing, not a rounding error.\n",
    "\n",
    "Valid `orbit_class` values are `LEO`, `MEO`, `GEO`, `HEO`, `SSO`, `VLEO` and\n",
    "`UNKNOWN`. Other filters: `country` (ISO alpha-2), `status`, `object_class`,\n",
    "`active`, and `q` for free text. Anything else returns a 400 that names the\n",
    "offending parameter, rather than silently ignoring it."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell013",
   "metadata": {},
   "source": [
    "## 5. Search, and why the API tells you *why* something matched\n",
    "\n",
    "Free-text search on a catalog is where naive APIs get embarrassing. Ask for\n",
    "\"hubble\" and you should get the Hubble Space Telescope, whose catalog name is\n",
    "`HST`, not the word \"hubble\"."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell014",
   "metadata": {},
   "outputs": [],
   "source": [
    "hits = get(\"/search\", q=\"hubble\", limit=5)\n",
    "\n",
    "for row in hits[\"data\"]:\n",
    "    print(\n",
    "        f'{row[\"norad_cat_id\"]:>6}  {row[\"name\"]:<22}'\n",
    "        f'matched on {row[\"matched_on\"]} -> \"{row[\"matched_value\"]}\"'\n",
    "    )\n",
    "\n",
    "print()\n",
    "print(\"budget:\", budget())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell015",
   "metadata": {},
   "source": [
    "`matched_on` is the useful part. `alias` means the hit came from a sourced\n",
    "alternate designation, not from the catalog name. `fuzzy` only ever appears\n",
    "when nothing exact matched, so a result set full of `fuzzy` is your signal that\n",
    "the query missed and you should not trust the top hit.\n",
    "\n",
    "An API that returned a ranked list without saying why each row is in it forces\n",
    "you to guess. This one does not make you guess."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell016",
   "metadata": {},
   "source": [
    "## 6. Where your requests went\n",
    "\n",
    "The rate-limit headers are on every response. Read them before you write a loop.\n",
    "\n",
    "One caveat that matters more than it sounds. Responses a CDN is allowed to\n",
    "cache carry the counters from the moment the cache entry was filled, not from\n",
    "your request, because a cached hit never reaches the rate limiter at all. So\n",
    "`X-RateLimit-Remaining` can read higher than what you actually have left, by\n",
    "up to one cache lifetime of usage. Plan with it, but handle the 429 rather\n",
    "than assuming the number is exact."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell017",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"limit:    \", last_response.headers.get(\"X-RateLimit-Limit\"))\n",
    "print(\"remaining:\", last_response.headers.get(\"X-RateLimit-Remaining\"))\n",
    "print(\"resets at:\", last_response.headers.get(\"X-RateLimit-Reset\"), \"(unix time)\")\n",
    "print(\"sources:  \", last_response.headers.get(\"X-OrbitalWiki-Sources\"))\n",
    "print(\"licence:  \", last_response.headers.get(\"X-OrbitalWiki-License\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell018",
   "metadata": {},
   "source": [
    "Anonymous: 50 requests per day, **per network**. That is per school, per office,\n",
    "per cafe, not per person. It is deliberately small: enough to try the API,\n",
    "not enough to build on.\n",
    "\n",
    "A free key gives you 1,000/day. Sign up at\n",
    "<https://www.orbitalwiki.com>, create a key in the Keys section of\n",
    "<https://dashboard.orbitalwiki.com>, then set `API_KEY` in the setup cell\n",
    "above and re-run. Nothing else in this notebook changes.\n",
    "\n",
    "Never commit a key to a repository or paste one into a notebook you share."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell019",
   "metadata": {},
   "source": [
    "## What to do next\n",
    "\n",
    "- **Trace a catalog value to its source** takes the `claims` list apart.\n",
    "- **Count the orbits** turns the `meta.total` trick into a full census with a chart.\n",
    "- The full endpoint list is at <https://www.orbitalwiki.com/api/v1/openapi.json>,\n",
    "  and you can try requests without writing code at\n",
    "  <https://www.orbitalwiki.com/playground>."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell020",
   "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/stats`, 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": "Your first OrbitalWiki API request",
   "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
}
