{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell021",
   "metadata": {},
   "source": [
    "# Trace a catalog value to its source\n",
    "\n",
    "Most satellite datasets hand you a value and expect you to take it. This one\n",
    "attaches a *claim* to each field: who said it, how confident they are, and\n",
    "which record in their catalog it came from.\n",
    "\n",
    "This notebook takes that structure apart, then does the thing that actually\n",
    "matters for research: works out which fields on a record have **no** source\n",
    "behind them at all.\n",
    "\n",
    "**What you need:** `pip install requests pandas`.\n",
    "\n",
    "**What it costs:** about 7 requests."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell022",
   "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": "cell023",
   "metadata": {},
   "source": [
    "## 1. One record, and the claims attached to it\n",
    "\n",
    "`/satellites/{norad_cat_id}` returns the record plus a `claims` array. Each\n",
    "claim is one field, one source, one confidence level."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell024",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "iss = get(\"/satellites/25544\")\n",
    "claims = iss[\"data\"][\"claims\"]\n",
    "\n",
    "print(iss[\"data\"][\"name\"], \"has\", len(claims), \"sourced claims\")\n",
    "print()\n",
    "claims[0]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell025",
   "metadata": {},
   "source": [
    "The shape is deliberately verbose. A claim carries the value in a *typed* slot,\n",
    "`value_text`, `value_numeric`, `value_date` or `value_bool`, with the other\n",
    "three null. That looks clumsy next to a single `value` column, and it is the\n",
    "reason a launch date can never be silently compared as a string, or a numeric\n",
    "altitude sorted alphabetically.\n",
    "\n",
    "Flatten it into one column only when you are ready to lose that."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell026",
   "metadata": {},
   "outputs": [],
   "source": [
    "def flatten(claim):\n",
    "    \"\"\"Collapse the four typed value slots into one, keeping the provenance.\"\"\"\n",
    "    value = next(\n",
    "        (claim[key] for key in\n",
    "         (\"value_text\", \"value_numeric\", \"value_date\", \"value_bool\")\n",
    "         if claim[key] is not None),\n",
    "        None,\n",
    "    )\n",
    "    source = claim.get(\"source\") or {}\n",
    "    return {\n",
    "        \"field\": claim[\"field\"],\n",
    "        \"value\": value,\n",
    "        \"confidence\": claim[\"confidence\"],\n",
    "        \"source\": source.get(\"slug\"),\n",
    "        \"source_name\": source.get(\"name\"),\n",
    "        \"license\": source.get(\"license\"),\n",
    "        \"source_record\": claim[\"source_record\"],\n",
    "    }\n",
    "\n",
    "\n",
    "frame = pd.DataFrame([flatten(claim) for claim in claims])\n",
    "frame"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell027",
   "metadata": {},
   "source": [
    "`source_record` is the identifier *in the upstream catalog*, not here. `S25544`\n",
    "is GCAT's own designation. That is what makes a claim checkable: you can go to\n",
    "GCAT, look up `S25544`, and see whether this row reproduces it faithfully.\n",
    "\n",
    "A provenance system that only said \"GCAT\" without the record id would be\n",
    "unfalsifiable, which is another way of saying useless."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell028",
   "metadata": {},
   "source": [
    "## 2. What is *not* claimed\n",
    "\n",
    "Here is the part that changes how you use the data. Compare the fields on the\n",
    "record against the fields that have a claim."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell029",
   "metadata": {},
   "outputs": [],
   "source": [
    "record = iss[\"data\"]\n",
    "\n",
    "# Fields that are structure or derived output, not assertions about the object.\n",
    "NOT_CLAIMS = {\n",
    "    \"claims\", \"id\", \"stable_id\", \"created_at\", \"updated_at\",\n",
    "    \"has_tle\", \"has_omm\",\n",
    "}\n",
    "\n",
    "present = {\n",
    "    key for key, value in record.items()\n",
    "    if key not in NOT_CLAIMS and value is not None\n",
    "}\n",
    "sourced = set(frame[\"field\"])\n",
    "\n",
    "print(\"fields with a source: \", sorted(sourced))\n",
    "print()\n",
    "print(\"fields with NO source:\", sorted(present - sourced))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell030",
   "metadata": {},
   "source": [
    "Far more fields are unsourced than sourced, and they fall into three groups.\n",
    "\n",
    "**The orbital elements** (`mean_motion`, `inclination`, `bstar`, `apogee_km`,\n",
    "`epoch`...) arrive from the CelesTrak OMM feed as one coherent element set,\n",
    "refreshed on a schedule. Splitting one element set into fourteen identical\n",
    "per-field claims would add rows and no information, so they are attributed at\n",
    "the *response* level instead: `meta.sources` and the `X-OrbitalWiki-Sources`\n",
    "header.\n",
    "\n",
    "**The identifiers** (`norad_cat_id`, `cospar_id`, `name`) are the join keys.\n",
    "They are what a claim is attached *to*, so a claim about them would be\n",
    "circular.\n",
    "\n",
    "**`data_quality_score`** is OrbitalWiki's own computed field, not an assertion\n",
    "by anyone upstream. Treat a derived score as an opinion of the pipeline, and go\n",
    "look at how it is computed before you put it in a paper.\n",
    "\n",
    "The research point holds across all three: **\"has a value\" and \"has a citable\n",
    "source\" are different questions**, and you have to ask both. If you need a\n",
    "per-field citation for inclination, the response-level attribution is what you\n",
    "cite, and you say so."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell031",
   "metadata": {},
   "source": [
    "## 3. Confidence is not a score you can average\n",
    "\n",
    "Compare several records and the confidence levels start to look like a number.\n",
    "They are not. `HIGH` from a careful catalog and `HIGH` from a crowd-edited one\n",
    "mean different things."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell032",
   "metadata": {},
   "outputs": [],
   "source": [
    "SAMPLE = {\n",
    "    25544: \"ISS (ZARYA)\",\n",
    "    20580: \"Hubble\",\n",
    "    39084: \"Landsat 8\",\n",
    "    43013: \"NOAA 20\",\n",
    "    27607: \"SAUDISAT 1C (SO-50)\",\n",
    "}\n",
    "\n",
    "rows = []\n",
    "for norad in SAMPLE:\n",
    "    for claim in get(f\"/satellites/{norad}\")[\"data\"][\"claims\"]:\n",
    "        item = flatten(claim)\n",
    "        item[\"norad_cat_id\"] = norad\n",
    "        rows.append(item)\n",
    "\n",
    "everything = pd.DataFrame(rows)\n",
    "print(\"budget:\", budget())\n",
    "\n",
    "pd.crosstab(everything[\"source\"], everything[\"confidence\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell033",
   "metadata": {},
   "source": [
    "Read that table as coverage, not quality. It says which source supplies which\n",
    "fields, and how often each source hedges. A source that never emits anything\n",
    "below `HIGH` is not necessarily more accurate; it may simply not model\n",
    "uncertainty at all."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell034",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Which fields does each source actually supply?\n",
    "pd.crosstab(everything[\"field\"], everything[\"source\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell035",
   "metadata": {},
   "source": [
    "## 4. Where two sources disagree\n",
    "\n",
    "The interesting case is one field, one object, two sources, two answers. Go\n",
    "looking for it rather than assuming it away."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell036",
   "metadata": {},
   "outputs": [],
   "source": [
    "disagreements = (\n",
    "    everything\n",
    "    .groupby([\"norad_cat_id\", \"field\"])[\"value\"]\n",
    "    .nunique()\n",
    "    .reset_index(name=\"distinct_values\")\n",
    "    .query(\"distinct_values > 1\")\n",
    ")\n",
    "\n",
    "keys = set(zip(disagreements[\"norad_cat_id\"], disagreements[\"field\"]))\n",
    "contested = everything[\n",
    "    everything.apply(lambda row: (row[\"norad_cat_id\"], row[\"field\"]) in keys, axis=1)\n",
    "]\n",
    "\n",
    "if contested.empty:\n",
    "    print(\"No field in this sample has two sources giving different values.\")\n",
    "    print(\"That is a result about this sample, not a promise about the catalog.\")\n",
    "\n",
    "print(len(keys), \"contested field/object pairs\\n\")\n",
    "contested.sort_values([\"norad_cat_id\", \"field\"])[\n",
    "    [\"norad_cat_id\", \"field\", \"value\", \"source\", \"confidence\"]\n",
    "]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell037",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Look at one in detail rather than skimming the table.\n",
    "contested[\n",
    "    (contested[\"norad_cat_id\"] == 25544) & (contested[\"field\"] == \"operator\")\n",
    "][[\"value\", \"source\", \"source_name\", \"confidence\", \"source_record\"]]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell038",
   "metadata": {},
   "source": [
    "Neither source is wrong. They answered different questions.\n",
    "\n",
    "`operator` for 25544 comes back as \"NASA Johnson Space Flight Center\" from GCAT\n",
    "and \"National Aeronautics and Space Administration\" from Wikidata. Same\n",
    "organisation, different granularity. A string comparison calls that a conflict;\n",
    "a human calls it agreement. If your pipeline dedupes operators by exact string,\n",
    "this is where it quietly splits one operator into two.\n",
    "\n",
    "`launch_mass_kg` is the sharper lesson. Run the cell and look at the two\n",
    "numbers: they differ by more than an order of magnitude. That is not an error\n",
    "either. Catalog entry 25544 is *Zarya*, the first module, and GCAT reports that\n",
    "module's launch mass. Wikidata is describing the assembled International Space\n",
    "Station as it exists now. The field name is identical and the referent is not.\n",
    "\n",
    "The correct research move is to **report the disagreement**, with both values\n",
    "and both sources, and say what would settle it. Picking the one that reads\n",
    "better and not mentioning the other is how a difference of definition between\n",
    "two catalogs turns into false certainty three citations downstream.\n",
    "\n",
    "Note what confidence does and does not do here: GCAT's claims come in `HIGH`\n",
    "and Wikidata's `MEDIUM`, so in this sample confidence does track how tightly\n",
    "the source scopes its own claim. That is a property of these two sources, not a\n",
    "rule. Confidence is each source's opinion of its own claim. It is not a referee."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell039",
   "metadata": {},
   "source": [
    "## 5. The licences you inherit\n",
    "\n",
    "Different sources, different terms. If you redistribute, you are bound by all of\n",
    "them at once."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell040",
   "metadata": {},
   "outputs": [],
   "source": [
    "sources = get(\"/sources\")\n",
    "\n",
    "pd.DataFrame(sources[\"data\"])[[\"slug\", \"name\", \"license\", \"last_fetched_at\"]]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell041",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"catalog state:\", sources[\"meta\"].get(\"source_catalog_state\"))\n",
    "print(\"warning:      \", sources[\"meta\"].get(\"warning\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell042",
   "metadata": {},
   "source": [
    "`last_fetched_at` is how you tell a live source from a stale one. A `null`\n",
    "there means no successful fetch has ever been recorded for that source, and\n",
    "that covers two different cases: a static fallback row standing in for live\n",
    "storage the API could not read, and a source that is registered in the\n",
    "catalogue but has never been ingested. Only the first raises `meta.warning`.\n",
    "So a `null` timestamp sitting next to `source_catalog_state: \"live\"` and\n",
    "`warning: null` is normal, and noticing it is your job, not the API's.\n",
    "Read the timestamps yourself before you describe any of this data as\n",
    "\"current\"."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell043",
   "metadata": {},
   "source": [
    "## What to do next\n",
    "\n",
    "- **Count the orbits** builds a census and a chart from the same API.\n",
    "- **Join transmitters to catalog records** does provenance across *two*\n",
    "  independent projects.\n",
    "- The full source inventory, in prose, is at\n",
    "  <https://www.orbitalwiki.com/sources> and the methodology at\n",
    "  <https://www.orbitalwiki.com/methodology>."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell044",
   "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/25544`, 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": "Trace a catalog value to its source",
   "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
}
