[
  {
    "path": ".gitignore",
    "content": "# Mac\n.DS_Store\n\n# Project\ndata\nrtl_sdr_gps_sampler.py\nsamples-*\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Debug\",\n            \"type\": \"debugpy\",\n            \"request\": \"launch\",\n            \"module\": \"gpsreceiver\"\n        }\n    ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"[python]\": {\n        \"editor.codeActionsOnSave\": {\n            \"source.organizeImports\": \"explicit\"\n        },\n        \"editor.defaultFormatter\": \"ms-python.black-formatter\",\n        \"editor.formatOnSave\": true\n    },\n    \"[typescript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n        \"editor.formatOnSave\": true\n    },\n    \"[typescriptreact]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n        \"editor.formatOnSave\": true\n    },\n    \"isort.args\": [\"--profile\", \"black\"]\n}"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Chris Doble\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "This repository contains my software-defined GPS receiver project.\n\n<p align=\"center\">\n  <img src=\"./presentation/1 introduction/images/10 dashboard.png\" width=\"500\"/>\n</p>\n\nI also made a video series about the process that you can watch [on YouTube](https://www.youtube.com/playlist?list=PLmlXFuUXRl5BnKM9PM_tT9uIzlwUxGzLb).\n\n<p align=\"center\">\n  <a href=\"https://www.youtube.com/playlist?list=PLmlXFuUXRl5BnKM9PM_tT9uIzlwUxGzLb\">\n    <img src=\"./thumbnail.png\" width=\"500\"/>\n  </a>\n</p>\n\n# Features\n\n- Uses the legacy coarse/acquisition (C/A) code to produce clock bias and location estimates.\n- Produces estimates in as little as ~24 s from cold start (depending on environmental factors).\n- Location estimates tend to be within a few hundred metres of the true location.\n- Runs from pre-recorded sample files or a connected RTL-SDR.\n- Has an accompanying web-based dashboard to show location estimates and satellite information.\n- Written in Python with no runtime dependencies other than aiohttp (for the dashboard), NumPy, Pydantic (for data serialisation), and pyrtlsdr.\n\n# Receiver\n\nThe `gpsreceiver` directory contains a Python package that processes samples of GPS signals from a file or SDR dongle to estimate a clock bias and location. It logs information to `stdout`, e.g. when satellites are acquired or a solution is computed, but doesn't provide a graphical interface — for that you'll need to run the dashboard (see below).\n\n> [!NOTE]\n> All commands in this section should be run from the `gpsreceiver` directory.\n\n## Setup\n\n### Hardware\n\nIf you'd like to record your own samples or run the receiver in real-time from an [RTL-SDR](https://www.rtl-sdr.com/about-rtl-sdr/), you'll need [a GPS antenna](https://www.sparkfun.com/products/14986) and (optionally) [a ground plate](https://www.sparkfun.com/products/17519). You'll get the best results in large, open areas with a clear view of the sky in all directions, e.g. a park.\n\n### Software\n\n```bash\n# Make sure you're running Python >=3.12\npython -m venv .env\nsource .env/bin/activate\npip install -r requirements.txt\n```\n\n## Running\n\n### From a file\n\nThe file must contain a series of I/Q samples recorded at a rate matching `SAMPLES_PER_MILLISECOND` in `config.py` (the default rate is 2.046 MHz). The samples' I and Q components must be represented by 32-bit floats and be interleaved, i.e.\n\n```\n[32-bit float][32-bit float][32-bit float][32-bit float]...\n      ^             ^             ^             ^\n  Sample 0 I    Sample 0 Q    Sample 1 I    Sample 1 Q\n```\n\nYou can pass the file to the GPS receiver by running the following from within the `gpsreceiver` directory\n\n```bash\npython -m gpsreceiver -f $FILE_PATH -t $START_TIMESTAMP\n```\n\nwhere `$FILE_PATH` is the path to the file and `$START_TIMESTAMP` is the Unix time when the samples began being recorded.\n\nPhillip Tennen made such a file available as part of his [Gypsum](https://github.com/codyd51/gypsum) project. It contains ~13 minutes of samples recorded from [St Ives in the UK](https://maps.app.goo.gl/jbhZ1QGLcfHn7PJA9). To use it:\n\n1. Download `nov_3_time_18_48_st_ives.zip` from [here](https://github.com/codyd51/gypsum/releases/tag/1.0)\n2. Unzip it.\n3. Optionally, set `ALL_SATELLITE_IDS` in `config.py` to be `set([25, 28, 31, 32])`. These are the only satellites available in the recording so there's no need to search for any others.\n4. Run `python -m gpsreceiver -f nov_3_time_18_48_st_ives -t 1699037280`.\n\nIf you'd like to record your own file:\n\n1. Connect your antenna to your RTL-SDR and your RTL-SDR to your computer.\n2. Install [GNU Radio](https://www.gnuradio.org/).\n3. Open the GNU Radio Companion (GNURC) by running `gnuradio-companion`.\n4. Open `rtl_srd_gps_sampler.grc` in GNURC.\n5. Click play in GNURC. A window will open.\n6. Record data for as long as you'd like.\n7. Close the window that opened in step 5.\n8. There will be a new file called `samples-TIMESTAMP`.\n9. Run `python -m gpsreceiver -f samples-TIMESTAMP -t TIMESTAMP`.\n\n### From an RTL-SDR\n\n```bash\npython -m gpsreceiver --rtl-sdr\n```\n\n## Development\n\n```bash\n# Autoformat\nmake format\n\n# Type check\nmake type_check\n```\n\n# Dashboard\n\nThe dashboard takes information from the receiver's HTTP server and renders it in a web-based interface.\n\n> [!NOTE]\n> All commands in this section should be run from the `dashboard` directory unless otherwise noted.\n\n> [!NOTE]\n> The receiver only exposes an HTTP server when running from a file.\n>\n> Doing so when running from real-time RTL-SDR data causes the receiver to miss data and lose lock on satellites.\n\n## Setup\n\n```bash\npnpm install\n\n# A Google Maps API key is required to show location estimates on a map. Replace\n# the ellipses (...) with your API key. See here[1] for more instructions.\n#\n# 1: https://developers.google.com/maps/documentation/javascript/cloud-setup\necho \"VITE_GOOGLE_MAPS_API_KEY=...\" >> .env.local\n\n# If you know the receiver's actual location and want to show it on the map to\n# compare it with the estimated location, set this environment variable. Replace\n# LAT and LNG with the receiver's actual latitude and longitude.\necho \"VITE_ACTUAL_LOCATION=LAT,LNG\" >> .env.local\n```\n\n## Running\n\n```bash\npnpm start\n```\n\nNote that the GPS receiver must be running in order for data to be available to the dashboard.\n\n## Development\n\n```bash\n# Autoformat\npnpm format\n\n# Generate dashboard/src/http_types.ts from gpsreceiver/gpsreceiver/http_types.py.\n#\n# Run from the root of the repository.\n./bin/generate_dashboard_types.sh\n\n# Lint\npnpm lint\n\n# Type check\npnpm type_check\n```"
  },
  {
    "path": "bin/generate_dashboard_types.sh",
    "content": "#!/bin/bash\nset -eu\n\nDIR=\"$(cd $(dirname \"$0\") && pwd)\"\n\nmain() {\n    if [[ \"$@\" == \"--help\" ]]; then\n        echo \"Generates dashboard/src/http_types.ts from gpsreceiver/gpsreceiver/http_types.py\"\n        exit\n    fi\n    \n    # Create a temporary file to store the JSON schema.\n    local schema_path\n    schema_path=\"$(mktemp)\"\n    trap 'rm -f \"'\"${schema_path}\"'\"' EXIT\n\n    # Generate a JSON schema from the Python types.\n    #\n    # We want to suppress the titles of fields so json-schema-to-typescript\n    # doesn't generate a bunch of random types that are just used for fields.\n    cd \"${DIR}/../gpsreceiver\"\n    source .env/bin/activate\n    python - <<EOF > \"${schema_path}\"\nfrom gpsreceiver.http_types import HttpData\nfrom json import dumps\nfrom pydantic.json_schema import GenerateJsonSchema\n\nclass TitleSuppressingGenerateJsonSchema(GenerateJsonSchema):\n    def field_title_should_be_set(self, schema):\n        return False\n\nprint(dumps(HttpData.model_json_schema(schema_generator=TitleSuppressingGenerateJsonSchema)))\nEOF\n\n    # Generate the TypeScript types from the JSON schema.\n    cd \"${DIR}/../dashboard\"\n    node - <<EOF > src/http_types.ts\nimport { compileFromFile } from \"json-schema-to-typescript\";\n\ncompileFromFile(\n    \"${schema_path}\",\n    {\n        additionalProperties: false,\n        bannerComment: \"/**\\n * This file was automatically generated. Don't edit it by hand. Instead, change\\n * gpsreceiver/gpsreceiver/http_types.py and run bin/generate_dashboard_types.sh.\\n */\"\n    }\n).then(console.log)\nEOF\n}\n\nmain \"$@\""
  },
  {
    "path": "dashboard/.gitignore",
    "content": "*.local\nnode_modules\n"
  },
  {
    "path": "dashboard/.prettierrc.json",
    "content": "{\n    \"importOrder\": [\"^\\\\.\"],\n    \"importOrderCaseInsensitive\": true,\n    \"importOrderSeparation\": true,\n    \"importOrderSpecifiers\": true,\n    \"plugins\": [\"@trivago/prettier-plugin-sort-imports\"]\n}"
  },
  {
    "path": "dashboard/eslint.config.js",
    "content": "import js from \"@eslint/js\";\nimport reactHooks from \"eslint-plugin-react-hooks\";\nimport reactRefresh from \"eslint-plugin-react-refresh\";\nimport globals from \"globals\";\nimport tseslint from \"typescript-eslint\";\n\nexport default tseslint.config({\n  extends: [js.configs.recommended, ...tseslint.configs.recommended],\n  files: [\"**/*.{ts,tsx}\"],\n  languageOptions: {\n    ecmaVersion: 2020,\n    globals: globals.browser,\n  },\n  plugins: {\n    \"react-hooks\": reactHooks,\n    \"react-refresh\": reactRefresh,\n  },\n  rules: {\n    ...reactHooks.configs.recommended.rules,\n    \"react-refresh/only-export-components\": [\n      \"warn\",\n      { allowConstantExport: true },\n    ],\n  },\n});\n"
  },
  {
    "path": "dashboard/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>GPS Receiver</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "dashboard/package.json",
    "content": "{\n  \"dependencies\": {\n    \"@vis.gl/react-google-maps\": \"^1.5.0\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"recharts\": \"^2.15.0\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.17.0\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^5.2.1\",\n    \"@types/google.maps\": \"^3.58.1\",\n    \"@types/react\": \"^18.3.18\",\n    \"@types/react-dom\": \"^18.3.5\",\n    \"@vitejs/plugin-react\": \"^4.3.4\",\n    \"eslint\": \"^9.17.0\",\n    \"eslint-plugin-react-hooks\": \"^5.0.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.16\",\n    \"globals\": \"^15.14.0\",\n    \"json-schema-to-typescript\": \"^15.0.4\",\n    \"prettier\": \"^3.4.2\",\n    \"typescript\": \"~5.6.2\",\n    \"typescript-eslint\": \"^8.18.2\",\n    \"vite\": \"^6.0.5\"\n  },\n  \"name\": \"dashboard\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"format\": \"prettier src --write\",\n    \"lint\": \"eslint .\",\n    \"start\": \"vite --open --port 8081\",\n    \"type_check\": \"tsc -b\"\n  }\n}\n"
  },
  {
    "path": "dashboard/src/Dashboard.css",
    "content": "body {\n    font-family: sans-serif;\n    margin: 0;\n}\n\n.message {\n    left: 50%;\n    margin: 0;\n    position: absolute;\n    top: 50%;\n    transform: translate(-50%, -50%);\n}\n\n.map-container {\n    height: 400px;\n}\n\n.tracked-satellites-container {\n    display: grid;\n    grid-gap: 10px;\n    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));\n    padding: 10px;\n}\n"
  },
  {
    "path": "dashboard/src/Dashboard.tsx",
    "content": "import {\n  AdvancedMarker,\n  Map,\n  Pin,\n  RenderingType,\n  useMap,\n  useMapsLibrary,\n} from \"@vis.gl/react-google-maps\";\nimport { useEffect, useState } from \"react\";\n\nimport \"./Dashboard.css\";\nimport { HttpData } from \"./http_types\";\nimport TrackedSatelliteInformation from \"./TrackedSatelliteInformation\";\n\nexport default function Dashboard() {\n  const actualLocation = getActualLocation();\n\n  // Periodically fetch data from the server.\n  //\n  // undefined means we haven't received a response from the receiver yet, null\n  // means we've received a response but it contained no data (the receiver is\n  // probably running from a file and hasn't finished acquisition yet).\n  const [data, setData] = useState<HttpData | null | undefined>();\n  useEffect(() => {\n    const intervalId = setInterval(async () => {\n      try {\n        const response = await fetch(\"http://localhost:8080/\");\n        setData(await response.json());\n      } catch {\n        setData(undefined);\n      }\n    }, 2000);\n    return () => clearInterval(intervalId);\n  }, []);\n\n  // Update the map's viewport to contain the location estimate.\n  const core = useMapsLibrary(\"core\");\n  const map = useMap();\n  useEffect(() => {\n    if (core !== null && data != null && map !== null) {\n      const bounds = new core.LatLngBounds();\n      if (actualLocation !== null) {\n        bounds.extend(actualLocation);\n      }\n      if (data.latest_solution !== null) {\n        const {\n          position: { latitude: lat, longitude: lng },\n        } = data.latest_solution;\n        bounds.extend({ lat, lng });\n\n        // Extend the bounds a little so we can see the area around the location\n        const buffer = 0.001;\n        bounds.extend({ lat: lat - buffer, lng: lng - buffer });\n        bounds.extend({ lat: lat + buffer, lng: lng + buffer });\n      }\n      map.fitBounds(bounds, 0);\n    }\n  }, [actualLocation, core, data, map]);\n\n  if (data === undefined) {\n    return <p className=\"message\">Waiting for the server to start.</p>;\n  }\n\n  if (data === null) {\n    return <p className=\"message\">Waiting for the server to collect data.</p>;\n  }\n\n  return (\n    <>\n      {/* Map */}\n      <div className=\"map-container\">\n        <Map\n          clickableIcons={false}\n          defaultCenter={{ lat: 0, lng: 0 }}\n          defaultZoom={0}\n          disableDefaultUI\n          gestureHandling=\"none\"\n          mapId=\"DEMO_MAP_ID\"\n          renderingType={RenderingType.VECTOR}\n        >\n          {actualLocation && (\n            <AdvancedMarker key=\"actual\" position={actualLocation}>\n              <Pin\n                background=\"#4285F4\"\n                borderColor=\"#174EA6\"\n                glyphColor=\"#174EA6\"\n              />\n            </AdvancedMarker>\n          )}\n          {data.latest_solution && (\n            <AdvancedMarker\n              key=\"estimated\"\n              position={{\n                lat: data.latest_solution.position.latitude,\n                lng: data.latest_solution.position.longitude,\n              }}\n            />\n          )}\n        </Map>\n      </div>\n\n      {/* Tracked satellites */}\n      {data.tracked_satellites.length === 0 ? (\n        <p>No satellites have been acquired yet.</p>\n      ) : (\n        <div className=\"tracked-satellites-container\">\n          {data.tracked_satellites\n            .toSorted(({ satellite_id: a }, { satellite_id: b }) => a - b)\n            .map((trackedSatellite) => (\n              <TrackedSatelliteInformation\n                key={trackedSatellite.satellite_id}\n                trackedSatellite={trackedSatellite}\n              />\n            ))}\n        </div>\n      )}\n    </>\n  );\n}\n\nfunction getActualLocation(): google.maps.LatLngLiteral | null {\n  const s = import.meta.env.VITE_ACTUAL_LOCATION;\n  if (s === undefined) {\n    return null;\n  }\n\n  const ss = s.split(\",\");\n  if (ss.length !== 2) {\n    throw Error(`Invalid actual location: ${s}`);\n  }\n\n  const ns = ss.map(parseFloat);\n  if (ns.some(isNaN)) {\n    throw Error(`Invalid actual location: ${s}`);\n  }\n\n  return { lat: ns[0], lng: ns[1] };\n}\n"
  },
  {
    "path": "dashboard/src/TrackedSatelliteInformation.css",
    "content": ".tracked-satellite-container {\n    background: #fafafa;\n    border-radius: 10px;\n    padding: 10px;\n}\n\n.tracked-satellite-container h1 {\n    font-size: 1.5em;\n    margin: 0 0 10px 0;\n}\n\n.tracked-satellite-container ol {\n    list-style: none;\n    margin: 0 0 10px 0;\n    padding: 0;\n}\n\n.tracked-satellite-container dl {\n    display: grid;\n    grid-template-columns: 90px 1fr;\n    margin: 0 0 10px 0;\n}\n\n.tracked-satellite-container dt {\n    margin-right: 5px;\n}\n\n.tracked-satellite-container dd {\n    margin: 0;\n}\n\n.tracked-satellite-container .line-charts-container {\n    display: flex;\n    margin: 0 0 10px 0;\n}\n\n.line-chart-container {\n    display: flex;\n    flex-basis: 50%;\n    flex-direction: column;\n}\n\n.chart-title {\n    font-size: 0.8em;\n    margin: 0 0 5px 0;\n    text-align: center;\n}\n"
  },
  {
    "path": "dashboard/src/TrackedSatelliteInformation.tsx",
    "content": "import { useCallback, useMemo } from \"react\";\nimport {\n  Dot,\n  DotProps,\n  Line,\n  LineChart,\n  ResponsiveContainer,\n  Scatter,\n  ScatterChart,\n  XAxis,\n  XAxisProps,\n  YAxis,\n  YAxisProps,\n} from \"recharts\";\n\nimport { TrackedSatellite } from \"./http_types\";\nimport \"./TrackedSatelliteInformation.css\";\n\nexport default function TrackedSatelliteInformation({\n  trackedSatellite: {\n    bit_boundary_found,\n    bit_phase,\n    carrier_frequency_shifts,\n    correlations,\n    duration,\n    prn_code_phase_shifts,\n    required_subframes_received,\n    satellite_id,\n    subframe_count,\n  },\n}: {\n  trackedSatellite: TrackedSatellite;\n}) {\n  return (\n    <div className=\"tracked-satellite-container\">\n      <h1>#{satellite_id}</h1>\n      <ol>\n        <li>{toEmoji(bit_boundary_found)} Bit boundary</li>\n        <li>{toEmoji(bit_phase !== null)} Bit phase</li>\n        <li>{toEmoji(required_subframes_received)} Required subframes</li>\n      </ol>\n      <dl>\n        <dt>Duration:</dt>\n        <dd>{toHoursMinutesSeconds(duration)}</dd>\n        <dt>Subframes:</dt>\n        <dd>{subframe_count}</dd>\n      </dl>\n      <div className=\"line-charts-container\">\n        <LineChart_\n          data={carrier_frequency_shifts}\n          title=\"Carrier frequency shift\"\n        />\n        <LineChart_ data={prn_code_phase_shifts} title=\"PRN code phase shift\" />\n      </div>\n      <CorrelationChart data={correlations} />\n    </div>\n  );\n}\n\n/** Converts a boolean value to an appropriate emoji. */\nfunction toEmoji(value: boolean): string {\n  return value ? \"✅\" : \"❌\";\n}\n\n/**\n * Converts a duration in seconds to a string.\n *\n *     toHoursMinutesSeconds(6020) === \"1h 40m 20s\"\n */\nfunction toHoursMinutesSeconds(duration: number): string {\n  const hours = Math.floor(duration / 3600);\n  const minutes = Math.floor((duration % 3600) / 60);\n  const seconds = Math.floor(duration % 60);\n  return [\n    hours ? `${hours}h` : \"\",\n    minutes ? `${minutes}m` : \"\",\n    seconds ? `${seconds}s` : \"\",\n  ]\n    .filter(Boolean)\n    .join(\" \");\n}\n\nfunction LineChart_({ data, title }: { data: number[]; title: string }) {\n  const identity = useCallback((x: unknown) => x, []);\n  const xTickFormatter = useCallback(\n    (n: number) => (n === 0 ? \"-1s\" : \"0s\"),\n    [],\n  );\n  const yTickFormatter = useCallback((n: number) => `${Math.floor(n)}`, []);\n\n  return (\n    <div className=\"line-chart-container\">\n      <p className=\"chart-title\">{title}</p>\n      <ResponsiveContainer height={100} width=\"100%\">\n        <LineChart data={data}>\n          <Line animationDuration={0} dataKey={identity} dot={false} />\n          <XAxis height={15} tickFormatter={xTickFormatter} ticks={[0, 999]} />\n          <YAxis\n            domain={[\"dataMin\", \"dataMax\"]}\n            tickFormatter={yTickFormatter}\n            type=\"number\"\n            width={40}\n          />\n        </LineChart>\n      </ResponsiveContainer>\n    </div>\n  );\n}\n\nfunction CorrelationChart({ data }: { data: number[][] }) {\n  // For some reason domain={[\"dataMin\", \"dataMax\"]} doesn't seem to work for\n  // scatter plots. Calculate each axes' domain manually.\n  const xDomain = useMemo<NonNullable<XAxisProps[\"domain\"]>>(() => {\n    const xs = data.map(([x]) => x);\n    return [Math.floor(Math.min(...xs)), Math.ceil(Math.max(...xs))];\n  }, [data]);\n  const yDomain = useMemo<NonNullable<YAxisProps[\"domain\"]>>(() => {\n    const ys = data.map(([y]) => y);\n    return [Math.floor(Math.min(...ys)), Math.ceil(Math.max(...ys))];\n  }, [data]);\n\n  return (\n    <div className=\"correlation-chart-container\">\n      <p className=\"chart-title\">Correlations</p>\n      <ResponsiveContainer height={200} width=\"100%\">\n        <ScatterChart>\n          <Scatter\n            animationDuration={0}\n            data={data}\n            shape={<CorrelationDot />}\n          />\n          <XAxis dataKey={0} domain={xDomain} height={15} type=\"number\" />\n          <YAxis dataKey={1} domain={yDomain} type=\"number\" width={20} />\n        </ScatterChart>\n      </ResponsiveContainer>\n    </div>\n  );\n}\n\nfunction CorrelationDot({ cx, cy }: DotProps) {\n  return <Dot cx={cx} cy={cy} fill=\"#3182bd80\" r={2} />;\n}\n"
  },
  {
    "path": "dashboard/src/http_types.ts",
    "content": "/**\n * This file was automatically generated. Don't edit it by hand. Instead, change\n * gpsreceiver/gpsreceiver/http_types.py and run bin/generate_dashboard_types.sh.\n */\n\n/**\n * Data sent to the HTTP server subprocess to be served to clients.\n */\nexport interface HttpData {\n  latest_solution: GeodeticSolution | null;\n  tracked_satellites: TrackedSatellite[];\n  untracked_satellites: UntrackedSatellite[];\n}\n/**\n * A computed solution with the position in geodetic coordinates.\n */\nexport interface GeodeticSolution {\n  clock_bias: number;\n  position: GeodeticCoordinates;\n}\n/**\n * A location expressed in geodetic coordinates.\n */\nexport interface GeodeticCoordinates {\n  height: number;\n  latitude: number;\n  longitude: number;\n}\n/**\n * Data regarding a tracked satellite.\n */\nexport interface TrackedSatellite {\n  bit_boundary_found: boolean;\n  bit_phase: (-1 | 1) | null;\n  carrier_frequency_shifts: number[];\n  correlations: number[][];\n  duration: number;\n  prn_code_phase_shifts: number[];\n  required_subframes_received: boolean;\n  satellite_id: number;\n  subframe_count: number;\n}\n/**\n * Data regarding an untracked satellite.\n */\nexport interface UntrackedSatellite {\n  next_acquisition_at: string;\n  satellite_id: number;\n}\n\n"
  },
  {
    "path": "dashboard/src/main.tsx",
    "content": "import { APIProvider } from \"@vis.gl/react-google-maps\";\nimport { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\n\nimport Dashboard from \"./Dashboard\";\n\ncreateRoot(document.getElementById(\"root\")!).render(\n  <StrictMode>\n    <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}>\n      <Dashboard />\n    </APIProvider>\n  </StrictMode>,\n);\n"
  },
  {
    "path": "dashboard/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "dashboard/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"target\": \"ES2020\",\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"useDefineForClassFields\": true,\n  },\n  \"extends\": \"./tsconfig.common.json\",\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "dashboard/tsconfig.common.json",
    "content": "{\n    \"compilerOptions\": {\n        \"allowImportingTsExtensions\": true,\n        \"isolatedModules\": true,\n        \"module\": \"ESNext\",\n        \"moduleDetection\": \"force\",\n        \"moduleResolution\": \"bundler\",\n        \"noEmit\": true,\n        \"noFallthroughCasesInSwitch\": true,\n        \"noUncheckedSideEffectImports\": true,\n        \"noUnusedLocals\": true,\n        \"noUnusedParameters\": true,\n        \"skipLibCheck\": true,\n        \"strict\": true,\n    }\n}"
  },
  {
    "path": "dashboard/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "dashboard/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"lib\": [\"ES2023\"],\n  },\n  \"extends\": \"./tsconfig.common.json\",\n  \"include\": [\"vite.config.ts\"],\n}\n"
  },
  {
    "path": "dashboard/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  plugins: [react()],\n});\n"
  },
  {
    "path": "gpsreceiver/.gitignore",
    "content": "__pycache__\n.env\n.mypy_cache"
  },
  {
    "path": "gpsreceiver/gpsreceiver/__init__.py",
    "content": ""
  },
  {
    "path": "gpsreceiver/gpsreceiver/__main__.py",
    "content": "import logging\nfrom argparse import ArgumentParser\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom .acquirer import MainProcessAcquirer, SubprocessAcquirer\nfrom .antenna import FileAntenna, RtlSdrAntenna\nfrom .receiver import Receiver\n\nlogging.basicConfig(\n    format=\"%(asctime)s [%(levelname)s] %(message)s\", level=logging.INFO\n)\n\nargument_parser = ArgumentParser()\nargument_parser.add_argument(\"-f\", \"--file\", help=\"the path to the input file to use\")\nargument_parser.add_argument(\n    \"-t\", \"--time\", help=\"the start time of the input file, in Unix time\"\n)\nargument_parser.add_argument(\n    \"--rtl-sdr\", action=\"store_true\", help=\"run in real time from an RTL-SDR\"\n)\nargument_parser.add_argument(\n    \"-g\", \"--gain\", type=int, default=20, help=\"front-end gain for RTL-SDR (default 20)\"\n)\n\nargs = argument_parser.parse_args()\n\ntry:\n    if args.file and args.time:\n        FileAntenna(\n            Path(args.file),\n            Receiver(MainProcessAcquirer(), run_http_server=True),\n            datetime.fromtimestamp(float(args.time), tz=timezone.utc),\n        ).start()\n    elif args.rtl_sdr:\n        RtlSdrAntenna(\n            Receiver(SubprocessAcquirer(), run_http_server=False), gain=args.gain\n        ).start()\n    else:\n        argument_parser.print_help()\nexcept KeyboardInterrupt:\n    pass\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/acquirer.py",
    "content": "import time\nfrom abc import ABC, abstractmethod\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom datetime import MINYEAR, datetime, timedelta, timezone\nfrom multiprocessing import Pipe, Process\nfrom multiprocessing.connection import Connection\nfrom typing import cast\n\nimport numpy as np\n\nfrom .config import (\n    ACQUISITION_INTERVAL,\n    ACQUISITION_STRENGTH_THRESHOLD,\n    ALL_SATELLITE_IDS,\n    MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION,\n    SAMPLES_PER_MILLISECOND,\n)\nfrom .constants import SAMPLE_TIMES, SAMPLES_PER_SECOND\nfrom .http_types import UntrackedSatellite\nfrom .prn_codes import COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID\nfrom .types import OneMsOfSamples, SatelliteId, UtcTimestamp\nfrom .utils import InvariantError, invariant\n\n\n@dataclass(kw_only=True)\nclass Acquisition:\n    \"\"\"The parameters resulting from acquisition of a GPS satellite signal.\n\n    Note that the frequency/phase shift parameters are the observed shifts, i.e.\n    we must negate them on a received signal to perform carrier wipeoff.\n    \"\"\"\n\n    # An estimate of the carrier signal's frequency shift in Hz.\n    carrier_frequency_shift: float\n\n    # An estimate of the carrier signal's phase shift in radians.\n    #\n    # This can be inaccurate if we encountered a navigation bit change during\n    # acquisition, but the Costas loop in the tracking phase should fix it.\n    carrier_phase_shift: float\n\n    # An estimate of the C/A PRN code's phase shift in half-chips.\n    #\n    # This will be in the range [0, 2045].\n    prn_code_phase_shift: int\n\n    # The ID of the GPS satellite whose signal was acquired.\n    satellite_id: SatelliteId\n\n    # The strength of the acquisition.\n    #\n    # This is the peak-to-mean ratio of the signal correlations for this\n    # particular Doppler shift and all possible C/A PRN code phase shifts.\n    strength: float\n\n    # When the acquisition occurred.\n    timestamp: UtcTimestamp\n\n\nclass Acquirer(ABC):\n    \"\"\"Detects GPS satellite signals and determines their parameters.\n\n    This is abstract so subclasses can decide how to schedule computations, e.g.\n    whether they should block the main process or occur in a subprocess.\n    \"\"\"\n\n    def __init__(self) -> None:\n        # When to next attempt acquisition for each satellite, in receiver time.\n        #\n        # Set to the minimum value so we perform acquisition on startup.\n        self._next_acquisition_at_by_satellite_id: dict[SatelliteId, UtcTimestamp] = {\n            i: datetime(MINYEAR, 1, 1, tzinfo=timezone.utc) for i in ALL_SATELLITE_IDS\n        }\n\n        # The most recently received samples.\n        self._samples = deque[OneMsOfSamples](\n            maxlen=MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION\n        )\n\n        # The most recently received set of tracked satellite IDs.\n        self._tracked_satellite_ids: set[SatelliteId] = set()\n\n    def handle_1ms_of_samples(\n        self, samples: OneMsOfSamples, tracked_satellite_ids: set[SatelliteId]\n    ) -> Acquisition | None:\n        \"\"\"Handles 1 ms of samples.\n\n        Returns an ``Acquisition`` if a satellite's signal has been acquired.\n        \"\"\"\n\n        self._samples.append(samples)\n        self._tracked_satellite_ids = tracked_satellite_ids\n\n        # Check if we have enough samples to perform acquisition.\n        if len(self._samples) < MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION:\n            return None\n\n        acquisition = self._get_acquisition()\n        if acquisition is not None:\n            self._next_acquisition_at_by_satellite_id[acquisition.satellite_id] = (\n                samples.end_timestamp + ACQUISITION_INTERVAL\n            )\n\n            if acquisition.strength >= ACQUISITION_STRENGTH_THRESHOLD:\n                return acquisition\n\n        return None\n\n    @property\n    def untracked_satellites(self) -> list[UntrackedSatellite]:\n        return [\n            UntrackedSatellite(\n                next_acquisition_at=next_acquisition_at,\n                satellite_id=satellite_id,\n            )\n            for satellite_id, next_acquisition_at in self._next_acquisition_at_by_satellite_id.items()\n            if satellite_id not in self._tracked_satellite_ids\n        ]\n\n    @abstractmethod\n    def _get_acquisition(self) -> Acquisition | None:\n        \"\"\"Returns an ``Acquisition``, if one is ready.\"\"\"\n\n        pass\n\n    def _get_next_acquisition_target(self) -> SatelliteId | None:\n        \"\"\"Determines which satellite we should attempt to acquire next.\"\"\"\n\n        now = self._samples[-1].end_timestamp\n        untracked_satellite_ids = ALL_SATELLITE_IDS - self._tracked_satellite_ids\n        candidates = [\n            (si, t)\n            for si, t in self._next_acquisition_at_by_satellite_id.items()\n            if si in untracked_satellite_ids and t <= now\n        ]\n        candidates.sort(key=lambda c: c[1])\n\n        if len(candidates) > 0:\n            return candidates[0][0]\n\n        return None\n\n\nclass MainProcessAcquirer(Acquirer):\n    \"\"\"An ``Acquirer`` that performs computations in the main process.\n\n    To be used when sampling a recorded signal, otherwise the receiver churns\n    through all of the recorded samples before acquisition is complete.\n    \"\"\"\n\n    def _get_acquisition(self) -> Acquisition | None:\n        satellite_id = self._get_next_acquisition_target()\n        if satellite_id is not None:\n            return _acquire_satellite(list(self._samples), satellite_id)\n\n        return None\n\n\nclass SubprocessAcquirer(Acquirer):\n    \"\"\"An ``Acquirer`` that performs computations in a subprocess.\n\n    To be used when sampling a real-time signal, otherwise there will be periods\n    where we don't sample because the computations block the main process.\n    \"\"\"\n\n    def __init__(self) -> None:\n        super().__init__()\n\n        # The connections through which the processes communicate.\n        self._connection, connection = Pipe()\n\n        # The subprocess.\n        #\n        # Marked as a daemon so it's killed alongside the main process.\n        self._subprocess = Process(\n            args=(connection,),\n            daemon=True,\n            target=_run_subprocess,\n        )\n        self._subprocess.start()\n\n        # Whether we're waiting for the subprocess to return an ``Acquisition``.\n        self._waiting = False\n\n    def _get_acquisition(self) -> Acquisition | None:\n        invariant(self._subprocess.is_alive(), \"Acquisition subprocess has terminated\")\n\n        if self._waiting:\n            if self._connection.poll():\n                result = self._connection.recv()\n                invariant(\n                    isinstance(result, Acquisition),\n                    f\"Invalid value received from acquisition subprocess: {result}\",\n                )\n                self._waiting = False\n                return result\n        else:\n            satellite_id = self._get_next_acquisition_target()\n            if satellite_id is not None:\n                self._connection.send((list(self._samples), satellite_id))\n                self._waiting = True\n\n        return None\n\n\ndef _run_subprocess(connection: Connection) -> None:\n    while True:\n        # If we haven't received any arguments from the main process, sleep.\n        if not connection.poll():\n            time.sleep(0.001)\n            continue\n\n        args = connection.recv()\n        invariant(\n            isinstance(args, tuple) and len(args) == 2,\n            f\"Invalid arguments sent to acquisition subprocess: {args}\",\n        )\n\n        samples, satellite_id = args\n        invariant(\n            isinstance(samples, list)\n            and all([isinstance(s, OneMsOfSamples) for s in samples]),\n            f\"Invalid samples sent to acquisition subprocess: {samples}\",\n        )\n        invariant(\n            isinstance(satellite_id, int),\n            f\"Invalid satellite ID send to acquisition subprocess: {satellite_id}\",\n        )\n\n        connection.send(_acquire_satellite(samples, satellite_id))\n\n\ndef _acquire_satellite(\n    samples: list[OneMsOfSamples], satellite_id: SatelliteId\n) -> Acquisition:\n    \"\"\"Attempts to acquire the signal of a particular GPS satellite.\"\"\"\n\n    # The following attempts to acquire the satellite's signal at a fixed\n    # number of frequency shifts in a range around a central value. The\n    # central value is updated to be the frequency shift of the strongest\n    # candidate, the range is reduced, and the process is repeated until\n    # we're searching a continuous range. This means we start by searching a\n    # large range and gradually narrow down on the most promising regions.\n    #\n    # The initial central value is 0 and the initial frequency shift range\n    # is ±7.680 kHz. This range was chosen to accommodate all reasonable\n    # receiver and satellite motion, receiver oscillation variance, etc. On\n    # each iteration the range is split into 31 equally-spaced values\n    # (including endpoints) and is reduced by a factor of two. This means on\n    # the first iteration the step size is 512 Hz, the second it's 256 Hz,\n    # etc., until the tenth iteration where it's 1 Hz and we're searching a\n    # continuous range. At that point we've found the strongest candidate.\n    best_acquisition: Acquisition | None = None\n    centre_frequency_shift: float = 0\n    half_frequency_shift_range: float = 7_680\n\n    while half_frequency_shift_range >= 15:\n        new_acquisition = _acquire_satellite_at_frequency_shifts(\n            np.linspace(\n                centre_frequency_shift - half_frequency_shift_range,\n                centre_frequency_shift + half_frequency_shift_range,\n                31,\n            ),\n            samples,\n            satellite_id,\n        )\n\n        if (\n            best_acquisition is None\n            or new_acquisition.strength > best_acquisition.strength\n        ):\n            best_acquisition = new_acquisition\n\n        centre_frequency_shift = best_acquisition.carrier_frequency_shift\n        half_frequency_shift_range /= 2\n\n    if best_acquisition is None:\n        raise InvariantError(\"Missing acquisition result\")\n\n    return best_acquisition\n\n\ndef _acquire_satellite_at_frequency_shifts(\n    frequency_shifts: np.ndarray,\n    samples: list[OneMsOfSamples],\n    satellite_id: SatelliteId,\n) -> Acquisition:\n    \"\"\"Attempts to acquire the signal of a particular GPS satellite at\n    particular frequency shifts.\n\n    Returns the best acquisition result regardless of whether its strength\n    exceeds the acquisition strength threshold.\n    \"\"\"\n\n    # For each frequency shift we perform both coherent and non-coherent\n    # integration for every 1 ms period of samples and add the results. This\n    # strengthens weak signals as if the 1 ms period were extended, but\n    # minimises the issue of navigation bit changes affecting the magnitude\n    # of the correlation. We then find the frequency shift and PRN code\n    # phase that give the greatest non-coherent sum - this is the strongest\n    # signal. The argument of the corresponding coherent sum is an estimate\n    # of the phase of the carrier wave. Finally, the peak-to-mean ratio of\n    # all correlations for the strongest frequency shift gives the strength.\n\n    prn_code = COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID[satellite_id]\n    prn_code_fft_conj = np.conj(np.fft.fft(prn_code))\n\n    coherent_sums = np.zeros((len(frequency_shifts), len(prn_code)), dtype=complex)\n    magnitude_sums = np.zeros((len(frequency_shifts), len(prn_code)))\n\n    for i, f in enumerate(frequency_shifts):\n        for j, samples_i in enumerate(samples):\n            # Perform carrier wipeoff.\n            shifted_samples = samples_i.samples * np.exp(\n                -2j * np.pi * f * (SAMPLE_TIMES + j * 0.001)\n            )\n\n            correlation = np.fft.ifft(np.fft.fft(shifted_samples) * prn_code_fft_conj)\n\n            coherent_sums[i] += correlation\n            magnitude_sums[i] += np.abs(correlation)\n\n    frequency_shift_index, prn_code_phase = np.unravel_index(\n        np.argmax(magnitude_sums), magnitude_sums.shape\n    )\n\n    peak_correlation = magnitude_sums[frequency_shift_index, prn_code_phase]\n    mean_correlation = np.mean(\n        magnitude_sums[\n            frequency_shift_index,\n            magnitude_sums[frequency_shift_index] != peak_correlation,\n        ]\n    )\n\n    return Acquisition(\n        carrier_frequency_shift=frequency_shifts[frequency_shift_index],\n        carrier_phase_shift=np.angle(\n            coherent_sums[frequency_shift_index, prn_code_phase]\n        ),\n        prn_code_phase_shift=int(prn_code_phase),\n        satellite_id=satellite_id,\n        strength=peak_correlation / mean_correlation,\n        timestamp=samples[-1].end_timestamp,\n    )\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/antenna.py",
    "content": "import signal\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nimport numpy as np\nfrom rtlsdr import RtlSdr\n\nfrom .config import SAMPLES_PER_MILLISECOND\nfrom .constants import L1_FREQUENCY, SAMPLES_PER_SECOND, SECONDS_PER_SAMPLE\nfrom .receiver import Receiver\nfrom .types import OneMsOfSamples, Samples, UtcTimestamp\n\n\nclass Antenna(ABC):\n    \"\"\"An antenna that samples signals as I/Q data.\n\n    All antennas sample at a rate of ``gpsreceiver.config.SAMPLE_RATE``.\n    \"\"\"\n\n    def __init__(self, receiver: Receiver) -> None:\n        self._receiver = receiver\n\n    @abstractmethod\n    def start(self) -> None:\n        \"\"\"Start sampling and passing the samples to the ``Receiver``.\n\n        This method blocks.\n        \"\"\"\n\n        pass\n\n\nclass FileAntenna(Antenna):\n    \"\"\"An antenna backed by a file containing I/Q data.\n\n    It's assumed that the file contains a list of 32-bit floating point numbers\n    with each pair representing a single complex value (an I/Q sample).\n    \"\"\"\n\n    def __init__(\n        self, path: Path, receiver: Receiver, start_timestamp: UtcTimestamp\n    ) -> None:\n        super().__init__(receiver)\n\n        self._dtype = np.dtype(np.float32)\n        self._file_size_in_samples = path.stat().st_size // self._dtype.itemsize // 2\n        self._offset_in_samples: int = 0\n        self._path = path\n        self._start_timestamp = start_timestamp\n\n    def start(self) -> None:\n        while True:\n            self._receiver.handle_1ms_of_samples(self._sample_1ms())\n\n    def _sample_1ms(self) -> OneMsOfSamples:\n        if (\n            self._offset_in_samples + SAMPLES_PER_MILLISECOND\n            >= self._file_size_in_samples\n        ):\n            raise EOFError(\"No more samples\")\n\n        data = np.fromfile(\n            self._path,\n            count=SAMPLES_PER_MILLISECOND * 2,\n            dtype=self._dtype,\n            offset=self._offset_in_samples * 2 * self._dtype.itemsize,\n        )\n\n        old_offset_in_samples = self._offset_in_samples\n        self._offset_in_samples += SAMPLES_PER_MILLISECOND\n\n        return OneMsOfSamples(\n            end_timestamp=self._start_timestamp\n            + timedelta(seconds=self._offset_in_samples / SAMPLES_PER_SECOND),\n            samples=data[0::2] + (1j * data[1::2]),\n            start_timestamp=self._start_timestamp\n            + timedelta(seconds=old_offset_in_samples / SAMPLES_PER_SECOND),\n        )\n\n\nclass RtlSdrAntenna(Antenna):\n    \"\"\"An antenna backed by an RTL-SDR receiver.\n\n    It's assumed that a single RTL-SDR is connected to the computer.\n    \"\"\"\n\n    def __init__(self, receiver: Receiver, gain: int) -> None:\n        super().__init__(receiver)\n\n        # pyrtlsdr requires that you receive a multiple of 512 samples at a\n        # time. The number of samples we take per millisecond may not be a\n        # multiple of 512, in which case there will be some \"leftover\" samples.\n        #\n        # This attribute is used to store the leftover samples which are\n        # prepended to the next chunk of samples and forwarded to the receiver.\n        self._samples: Samples | None = None\n        self._gain: int = gain\n\n    def start(self) -> None:\n        rtl_sdr = RtlSdr()\n        rtl_sdr.set_bandwidth(SAMPLES_PER_SECOND)\n        rtl_sdr.set_bias_tee(True)\n        rtl_sdr.set_center_freq(L1_FREQUENCY)\n        rtl_sdr.set_gain(self._gain)\n        rtl_sdr.set_sample_rate(SAMPLES_PER_SECOND)\n\n        signal.signal(signal.SIGINT, lambda signal, frame: rtl_sdr.cancel_read_async())\n\n        # The sample count must be a multiple of 512.\n        rtl_sdr.read_samples_async(self._on_samples, 2048)\n\n    def _on_samples(self, samples: np.ndarray, _: RtlSdr) -> None:\n        # Concatenate the leftover samples (if any) and the new samples.\n        now = datetime.now(timezone.utc)\n        samples_ = Samples(\n            end_timestamp=now,\n            samples=samples,\n            start_timestamp=now - timedelta(seconds=len(samples) * SECONDS_PER_SAMPLE),\n        )\n        self._samples = samples_ if self._samples is None else self._samples + samples_\n\n        # While we have enough samples, forward them to the receiver.\n        while len(self._samples.samples) > SAMPLES_PER_MILLISECOND:\n            self._receiver.handle_1ms_of_samples(\n                self._samples[0:SAMPLES_PER_MILLISECOND]\n            )\n            self._samples = self._samples[SAMPLES_PER_MILLISECOND:]\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/config.py",
    "content": "\"\"\"This module contains values that, at least in theory, could be changed to\nalter the receiver's behaviour. In practice, parts of the receiver may have been\nwritten in such a way that they won't work with other values, e.g.\n``SAMPLES_PER_MILLISECOND``. That's not to say it would never be possible to\nchange them, just that it hasn't been tested and may not work at the moment.\n\nValues that are derived from these (e.g. ``SAMPLES_PER_SECOND`` is derived from\n``SAMPLES_PER_MILLISECOND``) should be defined in ``constants.py`` instead.\n\"\"\"\n\nfrom datetime import timedelta\n\nimport numpy as np\n\n# Sampling\n\n# A GPS satellite's navigation message (50 bps) is XORed with its C/A PRN code\n# (1.023 Mbps) and the result is BPSK modulated onto a carrier wave whose\n# frequency is 1575.42 MHz. BPSK modulation results in a main lobe with a\n# bandwidth equal to twice the data rate - in this case that's 2.046 MHz. That\n# means we can capture the majority of the signal power by sampling between\n# 1573.374 MHz and 1577.466 MHz.\n#\n# The Shannon-Nyquist sampling theorem says that, to avoid aliasing, the\n# sampling rate must be at least double the highest frequency. In this case that\n# would be 3154.932 MHz which is prohibitively high. Instead we can take\n# advantage of aliasing and undersample the signal to effectively shift its\n# central frequency to 0 Hz. If we sample at 2.046 MHz there will be an alias\n# with a central frequency at 0 Hz, effectively removing the carrier frequency.\n#\n# This sampling rate has the added benefit that the number of samples in 1 ms of\n# data is equal to twice the number of chips in a C/A PRN code (1023). So, when\n# we're trying to correlate 1 ms of a received signal with a local replica of a\n# C/A PRN code we can simply repeat each code chip twice to get a signal of the\n# same length. This avoids needing to e.g. pad the local replica with zeroes.\nSAMPLES_PER_MILLISECOND: int = 2046\n\n# Acquisition\n\n# The interval between acquisition attempts.\n#\n# This value was chosen experimentally to balance the frequency of attempting\n# acquisition and the computational cost of doing so.\nACQUISITION_INTERVAL: timedelta = timedelta(seconds=10)\n\n# An acquisition result must have a strength above this threshold in order to be\n# considered successful. Its strength is measured as the peak-to-mean ratio of\n# the correlation between the received signal and the local C/A PRN code for a\n# particular Doppler shift and all possible C/A PRN code phase shifts.\n#\n# This value was chosen experimentally.\nACQUISITION_STRENGTH_THRESHOLD: float = 3\n\n# The IDs of all GPS satellites that we may track.\n#\n# ID 1 isn't included because it's not currently in use[1].\n#\n# 1: https://en.wikipedia.org/wiki/List_of_GPS_satellites#PRN_status_by_satellite_block\nALL_SATELLITE_IDS: set[int] = set(range(2, 33))\n\n# During acquisition we perform both coherent and non-coherent integration over\n# multiple 1 ms periods of samples and add the results. This strengthens weak\n# signals as would happen if we simply extended the 1 ms period, but minimises\n# the issue of navigation bit changes affecting correlation magnitude.\n#\n# This constant controls how many ms of data to use. Increasing it increases the\n# correlation strength, but also makes it more likely we'll see a navigation bit\n# change which can negatively affect the carrier wave phase estimate. That's not\n# too big of an issue as the tracking loop will eventually find the right value.\nMS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION: int = 10\n\n# Tracking\n\n# The gain to use in the PRN code phase shift tracking loop.\n#\n# This determines how much noise affects the loop and how quickly it can respond\n# to changes in the phase shift. I don't have a deep understanding of this value\n# but this is what Claude suggests and is what Gypsum uses[1].\n#\n# 1: https://github.com/codyd51/gypsum/blob/b9a5b4ec98557cf107f589dbffa0ad522851c14c/gypsum/tracker.py#L298\nPRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN = 0.002\n\n# The gains to use in the carrier frequency/phase shift tracking loop.\n#\n# These constants are also called beta and alpha in some implementations of\n# Costas loops. However, unlike other Costas loops I've seen, these values are\n# multiplied by the tracker update interval (currently 0.001s) when used so they\n# are quite a bit larger than other loops' constants. This has the benefit that\n# they don't need to be updated if the tracker update interval changes.\n#\n# I tried to find definitions of these values in terms of the loop's bandwidth\n# and damping factor but there didn't seem to be consensus. My DSP theory isn't\n# strong enough to derive them myself so the values were found experiementally.\nCARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN = 20\nCARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN = 500\n\n# Navigation data demodulation\n\n# How many bits worth of pseudosymbols must ``PseudosymbolIntegrator`` collect\n# before it can determine the boundaries between navigation bits.\n#\n# Before ``PseudosymbolIntegrator`` can group pseudosymbols into bits it needs\n# to know where one bit ends and the next begins. To do this it collects many\n# pseudosymbols into an array, then it finds the offset into that array that\n# best splits the pseudosymbols into like groups of 20. Now they can be grouped\n# into bits. This constant determines how many \"bits worth\" of pseudosymbols\n# (i.e. multiples of 20) must be collected before this process can occur.\nBITS_REQUIRED_TO_DETECT_BOUNDARIES = 20\n\n# How many preambles ``PseudobitIntegrator`` must detect in order to determine\n# the boundaries between subframes and the overall bit phase.\nPREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE = 3\n\n# HTTP server payload\n\n# The interval at which data is sent to the HTTP server subprocess, in ms.\n#\n# The data can be around 1 MB in size, so we don't want to send it too often\n# (otherwise the inter-process queue could become full or its feeder thread\n# could take up too much CPU time and affect the receiver). On the other hand\n# we don't want it to be too infrequent or the dashboard will become stale.\n#\n# 2 s was chosen arbitrarily.\nHTTP_UPDATE_INTERVAL_MS = 2000\n\n# The number of values to store in each tracking history buffer.\n#\n# This includes carrier frequency shifts, carrier phase shifts, correlations,\n# and PRN code phase shifts. Divide by 1000 to get the number of seconds.\nTRACKING_HISTORY_SIZE = 1000\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/constants.py",
    "content": "\"\"\"This module contains commonly used values whose definitions shouldn't change,\neither because they're defined in the GPS spec (e.g. ``BITS_PER_SUBFRAME``) or\nbecause they're derived from other values (e.g. ``SAMPLES_PER_SECOND``).\"\"\"\n\nimport numpy as np\n\nfrom .config import SAMPLES_PER_MILLISECOND\n\n# Sampling\n\nL1_FREQUENCY = 1575.42e6\n\nSAMPLES_PER_SECOND = SAMPLES_PER_MILLISECOND * 1000\n\n# The time of each sample within a 1 ms sampling period (in seconds).\nSAMPLE_TIMES = np.arange(SAMPLES_PER_MILLISECOND) / SAMPLES_PER_SECOND\n\nSECONDS_PER_SAMPLE = 1 / SAMPLES_PER_SECOND\n\n# Navigation data demodulation\n\n# The number of bits contained within a subframe of the navigation message.\n# Defined in section 20.3.2 of IS-GPS-200.\nBITS_PER_SUBFRAME = 300\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/http_types.py",
    "content": "\"\"\"This module contains types that are used to send data to the HTTP server\n    subprocess and are then served by that subprocess to clients.\"\"\"\n\nfrom typing import Annotated\n\nfrom pydantic import BaseModel, Field, WithJsonSchema, field_serializer\n\nfrom .types import BitPhase, SatelliteId, UtcTimestamp\n\n\nclass GeodeticCoordinates(BaseModel):\n    \"\"\"A location expressed in geodetic coordinates.\"\"\"\n\n    height: float  # meters\n    latitude: float  # degrees\n    longitude: float  # degrees\n\n\nclass GeodeticSolution(BaseModel):\n    \"\"\"A computed solution with the position in geodetic coordinates.\"\"\"\n\n    # See EcefSolution.clock_bias.\n    clock_bias: float\n\n    # An estimate of the receiver's position, in geodetic coordinates.\n    position: GeodeticCoordinates\n\n\n# Pydantic serialises complex values as strings by default but it's more\n# convenient if they're two-tuples. This type tells Pydantic to treat them as\n# such when generating a JSON schema (as done in generate_dashboard_types.sh).\nComplex = Annotated[\n    complex, WithJsonSchema({\"items\": {\"type\": \"number\"}, \"type\": \"array\"})\n]\n\n\nclass TrackedSatellite(BaseModel):\n    \"\"\"Data regarding a tracked satellite.\"\"\"\n\n    # Whether the boundary between different bits' pseudosymbols has been found.\n    bit_boundary_found: bool\n\n    # The signal's bit phase.\n    #\n    # ``None`` means we haven't determined it yet.\n    bit_phase: BitPhase | None\n\n    # The most recent carrier frequency shift values.\n    #\n    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.\n    carrier_frequency_shifts: list[float]\n\n    # The most recent correlations of 1 ms of received signal and the prompt\n    # local replica. These are used to plot a constellation diagram.\n    #\n    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.\n    correlations: list[Complex]\n\n    # The duration for which the satellite has been tracked, in seconds.\n    duration: float\n\n    # The most recent PRN code phase shift values.\n    #\n    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.\n    prn_code_phase_shifts: list[float]\n\n    # Whether the subframes required to use this satellite in solution\n    # calculations (1, 2, and 3) have been received yet.\n    required_subframes_received: bool\n\n    # The satellite's ID.\n    satellite_id: SatelliteId\n\n    # The number of subframes that have been decoded from this satellite.\n    subframe_count: int\n\n    # Pydantic serialises complex values as strings by default but it's more\n    # convenient if they're two-tuples. This field serialiser does that.\n    @field_serializer(\"correlations\")\n    def serialize_correlations(self, correlations: list[complex]) -> list[list[float]]:\n        return [[correlation.real, correlation.imag] for correlation in correlations]\n\n\nclass UntrackedSatellite(BaseModel):\n    \"\"\"Data regarding an untracked satellite.\"\"\"\n\n    # The time after which the receiver will next try to acquire this satellite.\n    next_acquisition_at: UtcTimestamp\n\n    # The satellite's ID.\n    satellite_id: SatelliteId\n\n\nclass HttpData(BaseModel):\n    \"\"\"Data sent to the HTTP server subprocess to be served to clients.\"\"\"\n\n    # The most recently calculated solution (if any).\n    latest_solution: GeodeticSolution | None\n\n    # Satellites that are currently being tracked by the receiver.\n    tracked_satellites: list[TrackedSatellite]\n\n    # Satellites that aren't currently tracked by the receiver.\n    untracked_satellites: list[UntrackedSatellite]\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pipeline.py",
    "content": "from .acquirer import Acquisition\nfrom .http_types import TrackedSatellite\nfrom .pseudobit_integrator import PseudobitIntegrator\nfrom .pseudosymbol_integrator import PseudosymbolIntegrator\nfrom .subframe_decoder import SubframeDecoder\nfrom .tracker import Tracker\nfrom .types import OneMsOfSamples, UtcTimestamp\nfrom .world import World\n\n\nclass Pipeline:\n    \"\"\"Processes antenna samples to update the world model of a satellite.\n\n    The pipeline is initialised with acquisition parameters and subsequent\n    samples pass through: a ``Tracker``, a ``PseudosymbolIntegrator``, a\n    ``PseudobitIntegrator``, a ``SubframeDecoder``, and finally to a ``World``.\n    \"\"\"\n\n    def __init__(self, acquisition: Acquisition, world: World) -> None:\n        self._acquired_at = acquisition.timestamp\n        self._satellite_id = acquisition.satellite_id\n        self._subframe_decoder = SubframeDecoder(self._satellite_id, world)\n        self._pseudobit_integrator = PseudobitIntegrator(\n            acquisition.satellite_id, self._subframe_decoder\n        )\n        self._pseudosymbol_integrator = PseudosymbolIntegrator(\n            self._pseudobit_integrator, acquisition.satellite_id\n        )\n        self._tracker = Tracker(acquisition, self._pseudosymbol_integrator, world)\n        self._world = world\n\n    def get_tracked_satellite(self, time: UtcTimestamp) -> TrackedSatellite:\n        return TrackedSatellite(\n            bit_boundary_found=self._pseudosymbol_integrator.bit_boundary_found,\n            bit_phase=self._pseudobit_integrator.bit_phase,\n            carrier_frequency_shifts=self._tracker.carrier_frequency_shifts,\n            correlations=self._tracker.correlations,\n            duration=(time - self._acquired_at).total_seconds(),\n            prn_code_phase_shifts=self._tracker.prn_code_phase_shifts,\n            required_subframes_received=self._world.has_required_subframes(\n                self._satellite_id\n            ),\n            satellite_id=self._satellite_id,\n            subframe_count=self._subframe_decoder.count,\n        )\n\n    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:\n        self._tracker.handle_1ms_of_samples(samples)\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/prn_codes.py",
    "content": "\"\"\"This module generates the GPS satellites' C/A PRN codes.\n\nThe PRN codes are generated by XORing the output of two linear-feedback shift\nregisters (LFSRs). Some documents say the second LFSR is delayed by a certain\nnumber of chips while others say the output of the second LFSR is the XOR of\ndifferent stages per satellite. It turns out these approaches are equivalent.\nHere we use the latter approach because it requires generating fewer outputs.\n\"\"\"\n\nimport json\nimport math\nfrom typing import Iterator\n\nimport numpy as np\n\nfrom .config import SAMPLES_PER_MILLISECOND\nfrom .types import SatelliteId\nfrom .utils import invariant\n\n\ndef _lfsr(outputs: list[int], taps: list[int]) -> Iterator[int]:\n    \"\"\"Generates the output of a 10-stage linear-feedback shift register.\n\n    ``outputs`` contains the (one-based) indices of the bits that are used to\n    calculate the LFSR's output on each iteration, e.g. if ``outputs = [1, 2]``\n    the output would be ``bits[0] ^ bits[1]``.\n\n    Similarly, ``taps`` contains the (one-based) indices of the bits that are\n    used to calculate the LFSR's leftmost bit on each iteration.\n\n    The LFSR is seeded with ones.\n\n    One-based indices are used to better match the GPS spec.\n    \"\"\"\n    bits = [1 for _ in range(10)]\n\n    while True:\n        output = sum([bits[i - 1] for i in outputs]) % 2\n        yield output\n\n        feedback = sum(bits[i - 1] for i in taps) % 2\n\n        for i in range(9, 0, -1):\n            bits[i] = bits[i - 1]\n\n        bits[0] = feedback\n\n\n# The (one-based) output indices used to generate each satellite's C/A PRN code,\n# indexed by satellite ID.\n#\n# Taken from Table 3-Ia in the GPS spec[1].\n#\n# 1: https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf\n_prn_code_outputs: dict[SatelliteId, list[int]] = {\n    1: [2, 6],\n    2: [3, 7],\n    3: [4, 8],\n    4: [5, 9],\n    5: [1, 9],\n    6: [2, 10],\n    7: [1, 8],\n    8: [2, 9],\n    9: [3, 10],\n    10: [2, 3],\n    11: [3, 4],\n    12: [5, 6],\n    13: [6, 7],\n    14: [7, 8],\n    15: [8, 9],\n    16: [9, 10],\n    17: [1, 4],\n    18: [2, 5],\n    19: [3, 6],\n    20: [4, 7],\n    21: [5, 8],\n    22: [6, 9],\n    23: [1, 3],\n    24: [4, 6],\n    25: [5, 7],\n    26: [6, 8],\n    27: [7, 9],\n    28: [8, 10],\n    29: [1, 6],\n    30: [2, 7],\n    31: [3, 8],\n    32: [4, 9],\n}\n\n# The C/A PRN codes of all GPS satellites, indexed by satellite ID.\nPRN_CODES_BY_SATELLITE_ID: dict[SatelliteId, np.ndarray] = {}\n\nfor satellite_id, outputs in _prn_code_outputs.items():\n    g1 = _lfsr([10], [3, 10])\n    g2 = _lfsr(outputs, [2, 3, 6, 8, 9, 10])\n    prn_code = np.empty(1023, np.float32)\n    for i in range(1023):\n        prn_code[i] = next(g1) ^ next(g2)\n    PRN_CODES_BY_SATELLITE_ID[satellite_id] = prn_code\n\n# The same C/A PRN codes as above, but upsampled so the number of chips in each\n# is equal to the number of samples present in 1 ms of a received signal.\n#\n# This requires that SAMPLES_PER_MILLISECOND is an integer multiple of the\n# length of a C/A PRN code (1023). Raises an exception if that's not the case.\ninvariant(\n    SAMPLES_PER_MILLISECOND % len(PRN_CODES_BY_SATELLITE_ID[1]) == 0,\n    \"SAMPLES_PER_MILLISECOND isn't an integer multiple of the number of chips in a C/A PRN code (1023)\",\n)\n_repeat_count = SAMPLES_PER_MILLISECOND // len(PRN_CODES_BY_SATELLITE_ID[1])\nUPSAMPLED_PRN_CODES_BY_SATELLITE_ID = {\n    satellite_id: np.repeat(prn_code, int(_repeat_count))\n    for satellite_id, prn_code in PRN_CODES_BY_SATELLITE_ID.items()\n}\n\n# The same upsampled C/A PRN codes as above, but with 0 mapped to 1 and 1 to -1.\n#\n# As the data transmitted by a GPS satellite is modulated onto the carrier wave\n# via BPSK, 0s and 1s result in signals that are 180 degrees out of phase. Thus,\n# when we attempt to correlate a received signal with a local replica of a C/A\n# PRN code we want the code chips to also be 180 degrees out of phase. Whether\n# 0 is mapped to 1 and 1 to -1 or vice versa is arbitrary, but this mapping has\n# the benefit that the XOR operation becomes equivalent to multiplication.\n#\n# This is also called polar non-return-to-zero encoding.\nCOMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID = {\n    satellite_id: np.array([-1 if b == 1 else 1 for b in prn_code])\n    for satellite_id, prn_code in UPSAMPLED_PRN_CODES_BY_SATELLITE_ID.items()\n}\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pseudobit_integrator.py",
    "content": "import logging\n\nimport numpy as np\n\nfrom .config import PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE\nfrom .constants import BITS_PER_SUBFRAME\nfrom .subframe_decoder import SubframeDecoder\nfrom .types import Bit, BitPhase, Pseudobit, SatelliteId\nfrom .utils import invariant\n\n# How many pseudobits we must collect before we may attempt to determine the\n# boundaries between subframes and the overall bit phase.\n#\n# We'll likely start collecting them part way through a subframe. This means\n# that even after we've collected ``PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE``\n# subframes' worth of pseudobits, the number of preambles we find will likely be one\n# fewer than that. Add one to the constant to avoid this issue.\n_PSEUDOBITS_REQUIRED_TO_DETERMINE_BIT_PHASE = (\n    PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE + 1\n) * BITS_PER_SUBFRAME\n\n# The fixed TLM word preamble and its inverse.\n#\n# These are used to determine the boundaries between subframes and the overall\n# bit phase. They're defined as ``Pseudobit``s rather than ``Bit``s so they\n# can be matched against the collected array of ``Pseudobit``s.\n_TLM_PREAMBLE: list[Pseudobit] = [1, -1, -1, -1, 1, -1, 1, 1]\n_INVERSE_TLM_PREAMBLE: list[Pseudobit] = [-1, 1, 1, 1, -1, 1, -1, -1]\n\nlogger = logging.getLogger(__name__)\n\n\nclass PseudobitIntegrator:\n    \"\"\"Integrates ``Pseudobits`` into subframes.\n\n    Each subframe is 300 bits long and starts with a telemetry word which in\n    turn starts with a preamble that's the same for every subframe. We can use\n    this to find the boundaries between subframes and the overall bit phase.\n\n    This class takes ``Pseudobit``s from a ``PseudosymbolIntegrator``,\n    determines which groups of 300 bits should be considered a subframe, and\n    forwards the results to a ``SubframeDecoder``.\n    \"\"\"\n\n    def __init__(\n        self, satellite_id: SatelliteId, subframe_decoder: SubframeDecoder\n    ) -> None:\n        # The overall bit phase.\n        #\n        # ``None`` means we haven't determined it yet.\n        self._bit_phase: BitPhase | None = None\n\n        self._pseudobits: list[Pseudobit] = []\n        self._satellite_id = satellite_id\n        self._subframe_decoder = subframe_decoder\n\n    @property\n    def bit_phase(self) -> BitPhase | None:\n        return self._bit_phase\n\n    def handle_pseudobit(self, pseudobit: Pseudobit) -> None:\n        self._pseudobits.append(pseudobit)\n\n        # Determine the bit phase.\n        if (\n            len(self._pseudobits) >= _PSEUDOBITS_REQUIRED_TO_DETERMINE_BIT_PHASE\n            and self._bit_phase is None\n        ):\n            self._determine_bit_phase()\n\n        # Group bits into subframes as long as we have enough data.\n        while (\n            len(self._pseudobits) >= BITS_PER_SUBFRAME and self._bit_phase is not None\n        ):\n            pseudobits = self._pseudobits[:BITS_PER_SUBFRAME]\n            del self._pseudobits[:BITS_PER_SUBFRAME]\n\n            bits = [self._resolve_bit(ub) for ub in pseudobits]\n            self._subframe_decoder.handle_bits(bits)\n\n    def _determine_bit_phase(self) -> None:\n        invariant(self._bit_phase is None, \"The bit phase has already been determined\")\n\n        for offset in range(BITS_PER_SUBFRAME):\n            pseudobits = self._pseudobits[offset:]\n            determined = False\n\n            # If each subframe in ``pseudobits`` starts with the TLM preamble\n            # (or its inverse) then we've found the boundaries between subframes\n            # and can determine the overall bit phase.\n            #\n            # If ``offset`` is non-zero then there's a partial subframe at the\n            # start of ``self._pseudobits`` which can be discarded.\n            if self._all_subframes_start_with_preamble(_TLM_PREAMBLE, pseudobits):\n                determined = True\n                self._bit_phase = 1\n            elif self._all_subframes_start_with_preamble(\n                _INVERSE_TLM_PREAMBLE, pseudobits\n            ):\n                determined = True\n                self._bit_phase = -1\n\n            if determined:\n                del self._pseudobits[:offset]\n                logger.info(\n                    f\"[{self._satellite_id}] Determined bit phase: {self._bit_phase}\"\n                )\n                return\n\n        raise UnknownBitPhaseError()\n\n    def _all_subframes_start_with_preamble(\n        self, preamble: list[Pseudobit], pseudobits: list[Pseudobit]\n    ) -> bool:\n        \"\"\"Determines if all subframes in ``pseudobits`` start with ``preamble``.\n\n        Assumes the first subframe starts at index 0, the second at 300, etc.\n        If the length of ``pseudobits`` isn't an integer multiple of\n        ``BITS_PER_SUBFRAME`` the leftover bits at the end are ignored.\n        \"\"\"\n        invariant(\n            len(preamble) <= len(pseudobits),\n            \"The preamble must be equal or shorter in length than the pseudobits\",\n        )\n        invariant(\n            len(pseudobits) >= BITS_PER_SUBFRAME,\n            \"Not enough pseudobits for a subframe\",\n        )\n\n        for i in range(0, len(pseudobits) - (BITS_PER_SUBFRAME - 1), BITS_PER_SUBFRAME):\n            if not np.array_equal(preamble, pseudobits[i : i + len(preamble)]):\n                return False\n\n        return True\n\n    def _resolve_bit(self, pseudobit: Pseudobit) -> Bit:\n        invariant(\n            self._bit_phase is not None,\n            \"A bit can't be resolved until the bit phase is determined\",\n        )\n\n        if self._bit_phase == -1:\n            return 1 if pseudobit == -1 else 0\n        else:\n            return 0 if pseudobit == -1 else 1\n\n\nclass UnknownBitPhaseError(Exception):\n    \"\"\"Indicates that we weren't able to determine a satellite's bit phase.\n\n    This suggests we're not tracking the satellite correctly.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pseudosymbol_integrator.py",
    "content": "import logging\nfrom collections import Counter\n\nimport numpy as np\n\nfrom .config import BITS_REQUIRED_TO_DETECT_BOUNDARIES\nfrom .pseudobit_integrator import PseudobitIntegrator\nfrom .types import Pseudobit, Pseudosymbol, SatelliteId\nfrom .utils import invariant\n\n_PSEUDOSYMBOLS_PER_BIT = 20\n\n# How many pseudosymbols we must collect before we may attempt to determine\n# the boundaries between navigation bits.\n_PSEUDOSYMBOLS_REQUIRED_TO_DETECT_BOUNDARIES = (\n    BITS_REQUIRED_TO_DETECT_BOUNDARIES * _PSEUDOSYMBOLS_PER_BIT\n)\n\n# How many pseudosymbols of each phase we must collect.\n#\n# If we're unlucky all of the pseudosymbols will be equal and it won't be\n# possible to find the best offset. To avoid this we collect a minimum number of\n# pseudosymbols of each phase (-1 and +1) before attempting to calculate the\n# offset. This constant determines how many of each phase must be collected.\n_PSEUDOSYMBOLS_REQUIRED_PER_PHASE = _PSEUDOSYMBOLS_REQUIRED_TO_DETECT_BOUNDARIES / 2\n\nlogger = logging.getLogger(__name__)\n\n\nclass PseudosymbolIntegrator:\n    \"\"\"Integrates pseudosymbols into bits.\n\n    When a ``Tracker`` computes the correlation between a received signal and\n    the prompt local replica, the sign of the real part of the result tells us\n    the phase of the navigation bit at that time. This is called a pseudosymbol.\n\n    Pseudosymbols are computed 1000 times per second and the navigation message\n    is transmitted at 50 bps, so there are 20 pseudosymbols per navigation bit.\n    In theory all 20 should have the same phase. In practice, noise, samples\n    taken during bit transitions, and weak signals cause some to be incorrect.\n\n    This class takes pseudosymbols from a ``Tracker``, determines which groups\n    of 20 pseudosymbols should be considered a single navigation bit, and\n    forwards the results to a ``PseudobitIntegrator``.\n    \"\"\"\n\n    def __init__(\n        self, pseudobit_integrator: PseudobitIntegrator, satellite_id: SatelliteId\n    ) -> None:\n        self._bit_boundary_found = False\n        self._pseudobit_integrator = pseudobit_integrator\n        self._pseudosymbols: list[Pseudosymbol] = []\n        self._satellite_id = satellite_id\n\n    @property\n    def bit_boundary_found(self) -> bool:\n        return self._bit_boundary_found\n\n    def handle_pseudosymbol(self, pseudosymbol: Pseudosymbol) -> None:\n        self._pseudosymbols.append(pseudosymbol)\n\n        # Find the bit boundary if necessary…\n        if not self._bit_boundary_found:\n            # …but only if we have enough data.\n            counter = Counter(self._pseudosymbols)\n            if (\n                counter[-1] >= _PSEUDOSYMBOLS_REQUIRED_PER_PHASE\n                and counter[1] >= _PSEUDOSYMBOLS_REQUIRED_PER_PHASE\n            ):\n                self._find_bit_boundary()\n\n        # Group pseudosymbols into bits as long as we have enough data.\n        while (\n            len(self._pseudosymbols) >= _PSEUDOSYMBOLS_PER_BIT\n            and self._bit_boundary_found\n        ):\n            # Extract the pseudosymbols comprising the next bit.\n            pseudosymbols = self._pseudosymbols[:_PSEUDOSYMBOLS_PER_BIT]\n            del self._pseudosymbols[:_PSEUDOSYMBOLS_PER_BIT]\n\n            # Determine the (phase ambiguous) bit.\n            counter = Counter(pseudosymbols)\n            pseudobit: Pseudobit = counter.most_common(1)[0][0]\n            self._pseudobit_integrator.handle_pseudobit(pseudobit)\n\n    def _find_bit_boundary(self) -> None:\n        invariant(not self._bit_boundary_found, \"Bit boundary already found\")\n\n        # Calculate a score for each possible offset.\n        #\n        # If an offset is good most of the pseudosymbols within each chunk will\n        # be the same and the magnitude of their sum will be large. If an offset\n        # is bad the pseudosymbols within each chunk will be mixed, they will\n        # cancel each other, and the magnitude of their sum will be smaller.\n        #\n        # An offset's score is the mean of its chunks' sums.\n        offset_scores: list[float] = []\n        for offset in range(_PSEUDOSYMBOLS_PER_BIT):\n            chunks = _chunks(self._pseudosymbols[offset:], _PSEUDOSYMBOLS_PER_BIT)\n            offset_scores.append(np.mean(np.abs(np.sum(chunks, axis=1))))\n\n        # Find the offset with the best score. If it is non-zero then there are\n        # some pseudosymbols at the start of ``self._pseudosymbols`` that we\n        # won't be able to group into a bit so they must be discarded.\n        best_offset = np.argmax(offset_scores)\n        self._pseudosymbols = self._pseudosymbols[best_offset:]\n\n        logger.info(f\"[{self._satellite_id}] Found the bit boundary\")\n        self._bit_boundary_found = True\n\n\ndef _chunks[T](elements: list[T], chunk_size: int) -> list[list[T]]:\n    \"\"\"Splits ``elements`` into sub-lists of length ``chunk_size``.\n\n    If the length of ``elements`` isn't an integer multiple of ``chunk_size``\n    the leftover elements at the end of the array aren't included in the output.\n    \"\"\"\n\n    return [\n        elements[i : i + chunk_size]\n        for i in range(0, len(elements) - (chunk_size - 1), chunk_size)\n    ]\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/receiver.py",
    "content": "import asyncio\nimport logging\nimport math\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom multiprocessing import Process, Queue\nfrom queue import Empty\nfrom typing import AsyncGenerator\n\nfrom aiohttp import web\nfrom pydantic import BaseModel\n\nfrom .acquirer import Acquirer\nfrom .config import HTTP_UPDATE_INTERVAL_MS\nfrom .http_types import (\n    GeodeticCoordinates,\n    GeodeticSolution,\n    HttpData,\n    TrackedSatellite,\n    UntrackedSatellite,\n)\nfrom .pipeline import Pipeline\nfrom .pseudobit_integrator import UnknownBitPhaseError\nfrom .subframe_decoder import ParityError\nfrom .types import OneMsOfSamples, SatelliteId, UtcTimestamp\nfrom .utils import invariant\nfrom .world import EcefCoordinates, EcefSolution, World\n\nlogger = logging.getLogger(__name__)\n\n\nclass Receiver:\n    def __init__(self, acquirer: Acquirer, *, run_http_server: bool) -> None:\n        self._acquirer = acquirer\n\n        # Start an HTTP server in a subprocess.\n        #\n        # The receiver's data is periodically sent to the server via a queue\n        # and the server makes it available to clients, e.g. the dashboard.\n        self._http_queue: Queue = Queue()\n        self._http_subprocess = Process(\n            args=(self._http_queue,), daemon=True, target=_run_http_subprocess\n        )\n\n        if run_http_server:\n            self._http_subprocess.start()\n\n        # The number of ms since data was last sent to the HTTP subprocess.\n        self._ms_since_sending_http_data = 0\n\n        self._latest_solution: GeodeticSolution | None = None\n        self._pipelines_by_satellite_id: dict[SatelliteId, Pipeline] = {}\n        self._run_http_server = run_http_server\n        self._world = World()\n\n    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:\n        acquisition = self._acquirer.handle_1ms_of_samples(\n            samples, set(self._pipelines_by_satellite_id.keys())\n        )\n\n        if acquisition is not None:\n            invariant(\n                acquisition.satellite_id not in self._pipelines_by_satellite_id,\n                f\"Received acquisition for already tracked satellite {acquisition.satellite_id}\",\n            )\n\n            logger.info(\n                f\"[{acquisition.satellite_id}] Acquired:\"\n                f\" carrier_frequency_shift={acquisition.carrier_frequency_shift},\"\n                f\" carrier_phase_shift={acquisition.carrier_phase_shift},\"\n                f\" prn_code_phase_shift={acquisition.prn_code_phase_shift},\"\n                f\" strength={acquisition.strength}\"\n            )\n\n            self._pipelines_by_satellite_id[acquisition.satellite_id] = Pipeline(\n                acquisition, self._world\n            )\n\n        for satellite_id, pipeline in list(self._pipelines_by_satellite_id.items()):\n            try:\n                pipeline.handle_1ms_of_samples(samples)\n            except ParityError:\n                logger.info(\n                    f\"[{satellite_id}] Observed parity error, dropping satellite\"\n                )\n                self._drop_satellite(satellite_id)\n            except UnknownBitPhaseError:\n                logger.info(\n                    f\"[{satellite_id}] Unable to determine bit phase, dropping satellite\"\n                )\n                self._drop_satellite(satellite_id)\n\n        solution = self._world.compute_solution()\n        if solution is not None:\n            position = _ecef_to_geodetic(solution.position)\n            logger.info(f\"Found solution: {solution.clock_bias}, {position}\")\n            self._latest_solution = GeodeticSolution(\n                clock_bias=solution.clock_bias, position=position\n            )\n\n        # Periodically send updated data to the HTTP subprocess.\n        self._ms_since_sending_http_data += 1\n        if self._ms_since_sending_http_data == HTTP_UPDATE_INTERVAL_MS:\n            if self._run_http_server:\n                self._http_queue.put(self._get_http_data(samples.end_timestamp))\n            self._ms_since_sending_http_data = 0\n\n    def _drop_satellite(self, satellite_id: SatelliteId) -> None:\n        \"\"\"Stop tracking a satellite and remove it from the world model.\n\n        This is called when we lose lock on a satellite.\n        \"\"\"\n\n        del self._pipelines_by_satellite_id[satellite_id]\n        self._world.drop_satellite(satellite_id)\n\n    def _get_http_data(self, time: UtcTimestamp) -> HttpData:\n        return HttpData(\n            latest_solution=self._latest_solution,\n            tracked_satellites=[\n                pipeline.get_tracked_satellite(time)\n                for pipeline in self._pipelines_by_satellite_id.values()\n            ],\n            untracked_satellites=self._acquirer.untracked_satellites,\n        )\n\n\ndef _run_http_subprocess(queue: Queue) -> None:\n    data: HttpData | None = None\n\n    async def handler(request: web.Request) -> web.Response:\n        return web.Response(\n            content_type=\"application/json\",\n            headers={\"Access-Control-Allow-Origin\": \"*\"},\n            text=\"null\" if data is None else data.model_dump_json(),\n        )\n\n    async def check_for_data() -> None:\n        while True:\n            try:\n                arg = queue.get(False)\n                invariant(\n                    isinstance(arg, HttpData),\n                    f\"Invalid argument sent to HTTP server subprocess: {arg}\",\n                )\n\n                nonlocal data\n                data = arg\n            except Empty:\n                pass\n\n            await asyncio.sleep(0.001)\n\n    async def data_checker_ctx(app: web.Application) -> AsyncGenerator[None, None]:\n        data_checker = asyncio.create_task(check_for_data())\n\n        yield\n\n        data_checker.cancel()\n        await data_checker\n\n    app = web.Application()\n    app.add_routes([web.get(\"/\", handler)])\n    app.cleanup_ctx.append(data_checker_ctx)\n    web.run_app(app, print=lambda x: None)\n\n\ndef _ecef_to_geodetic(ecef: EcefCoordinates) -> GeodeticCoordinates:\n    \"\"\"Converts ECEF coordinates to geodetic coordinates.\n\n    Uses Bowring's method[1].\n\n    1: https://en.wikipedia.org/wiki/Geographic_coordinate_conversion#Simple_iterative_conversion_for_latitude_and_height\n    \"\"\"\n\n    # WGS 84 constants.\n    a = 6378137.0\n    b = 6356752.314245\n    e = math.sqrt(1 - (b / a) ** 2)\n\n    # Set h = 0 to get an initial latitude estimate.\n    p = math.sqrt(ecef.x**2 + ecef.y**2)\n    latitude = math.atan2(ecef.z, p * (1 - e**2))\n\n    # Iteratively calculate latitude.\n    for _ in range(5):\n        n = a / math.sqrt(1 - (e * math.sin(latitude)) ** 2)\n        height = p / math.cos(latitude) - n\n        latitude = math.atan2(ecef.z, p * (1 - e**2 * n / (n + height)))\n\n    longitude = math.atan2(ecef.y, ecef.x)\n\n    # Calculate height using the final latitude.\n    n = a / math.sqrt(1 - (e * math.sin(latitude)) ** 2)\n    height = p / math.cos(latitude) - n\n\n    return GeodeticCoordinates(\n        height=height,\n        # Convert to degrees.\n        latitude=latitude / math.pi * 180,\n        longitude=longitude / math.pi * 180,\n    )\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/subframe_decoder.py",
    "content": "import logging\nfrom typing import cast\n\nfrom .constants import BITS_PER_SUBFRAME\nfrom .subframes import (\n    Handover,\n    Subframe,\n    Subframe1,\n    Subframe2,\n    Subframe3,\n    Subframe4,\n    Subframe5,\n    SubframeId,\n)\nfrom .types import Bit, SatelliteId\nfrom .utils import InvariantError, invariant, parse_int_from_bits\nfrom .world import World\n\n_BITS_PER_WORD = 30\n\n# The first 24 bits in a word are data bits, the following 6 are parity bits.\n_DATA_BITS_PER_WORD = 24\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass SubframeDecoder:\n    \"\"\"Decodes subframes.\n\n    This class takes subframe ``Bit``s from a ``PseudobitIntegrator``, decodes\n    them into instances of data classes, and forwards them to the ``World``.\n    \"\"\"\n\n    def __init__(self, satellite_id: SatelliteId, world: World) -> None:\n        # The number of subframes that have been decoded.\n        self._count = 0\n\n        self._satellite_id = satellite_id\n        self._world = world\n\n    @property\n    def count(self) -> int:\n        return self._count\n\n    def handle_bits(self, bits: list[Bit]) -> None:\n        subframe = _SubframeDecoder(bits).decode()\n        logger.info(\n            f\"[{self._satellite_id}] Decoded subframe {subframe.handover.subframe_id}\"\n        )\n        self._count += 1\n        self._world.handle_subframe(self._satellite_id, subframe)\n\n\nclass _SubframeDecoder:\n    \"\"\"Implements the decoding logic.\n\n    This is separate from ``SubframeDecoder`` because decoding requires some\n    state and it's easier to create and discard an instance of this class for\n    each subframe than ensure we reset state appropriately for each subframe.\n    \"\"\"\n\n    def __init__(self, transmitted: list[Bit]) -> None:\n        self._cursor = 0\n        self._data = _decode_subframe_data(transmitted)\n\n    def decode(self) -> Subframe:\n        self._decode_telemetry()\n\n        handover = self._decode_handover()\n        match handover.subframe_id:\n            case 1:\n                return self._decode_subframe_1(handover)\n\n            case 2:\n                return self._decode_subframe_2(handover)\n\n            case 3:\n                return self._decode_subframe_3(handover)\n\n            case 4:\n                return self._decode_subframe_4(handover)\n\n            case 5:\n                return self._decode_subframe_5(handover)\n\n            case _:\n                raise InvariantError(f\"Invalid subframe ID: {handover.subframe_id}\")\n\n    def _decode_telemetry(self) -> None:\n        # We don't need anything from the TLM word so nothing is returned from\n        # this method, but we still need to parse it to move the cursor past.\n\n        # The preamble is fixed.\n        preamble = self._get_bits(8)\n        invariant(preamble == [1, 0, 0, 0, 1, 0, 1, 1], \"Invalid TLM preamble\")\n\n        # The TLM message contains information needed for the precise\n        # positioning service. We can't use that, so ignore it.\n        self._skip_bits(14)\n\n        # Integrity status flag.\n        self._get_bit()\n\n        # The last data bit is reserved.\n        self._skip_bits(1)\n\n    def _decode_handover(self) -> Handover:\n        tow_count_msbs = self._get_bits(17)\n\n        # Alert flag.\n        self._get_bit()\n\n        # Anti-spoof flag.\n        self._get_bit()\n\n        # A subframe ID may only be 1 through 5, inclusive.\n        subframe_id = self._get_int(3)\n        invariant(subframe_id in [1, 2, 3, 4, 5], f\"Invalid subframe ID: {subframe_id}\")\n\n        # Parity bits.\n        self._skip_bits(2)\n\n        return Handover(tow_count_msbs, cast(SubframeId, subframe_id))\n\n    def _decode_subframe_1(self, handover: Handover) -> Subframe1:\n        # GPS week number mod 1024.\n        self._get_int(10)\n\n        # Code(s) on L2 channel.\n        self._get_bits(2)\n\n        # URA index.\n        self._get_bits(4)\n\n        sv_health = self._get_bits(6)\n\n        # Issue of data, clock (IODC) MSBs.\n        self._get_bits(2)\n\n        # Data flag for L2 P-Code.\n        self._get_bit()\n\n        # Reserved.\n        self._skip_bits(87)\n\n        t_gd = self._get_float(8, -31, True)\n\n        # IODC LSBs.\n        self._get_bits(8)\n\n        t_oc = self._get_float(16, 4, False)\n        a_f2 = self._get_float(8, -55, True)\n        a_f1 = self._get_float(16, -43, True)\n        a_f0 = self._get_float(22, -31, True)\n\n        # Parity bits.\n        self._skip_bits(2)\n\n        return Subframe1(\n            handover,\n            sv_health,\n            t_gd,\n            t_oc,\n            a_f2,\n            a_f1,\n            a_f0,\n        )\n\n    def _decode_subframe_2(self, handover: Handover) -> Subframe2:\n        # Issue of data (ephemeris).\n        self._get_bits(8)\n\n        c_rs = self._get_float(16, -5, True)\n        delta_n = self._get_float(16, -43, True)\n        m_0 = self._get_float(32, -31, True)\n        c_uc = self._get_float(16, -29, True)\n        e = self._get_float(32, -33, False)\n        c_us = self._get_float(16, -29, True)\n        sqrt_a = self._get_float(32, -19, False)\n        t_oe = self._get_float(16, 4, False)\n\n        # Fit interval flag.\n        self._get_bit()\n\n        # Age of data offset.\n        self._get_bits(5)\n\n        # Parity bits.\n        self._skip_bits(2)\n\n        return Subframe2(\n            handover,\n            c_rs,\n            delta_n,\n            m_0,\n            c_uc,\n            e,\n            c_us,\n            sqrt_a,\n            t_oe,\n        )\n\n    def _decode_subframe_3(self, handover: Handover) -> Subframe3:\n        c_ic = self._get_float(16, -29, True)\n        omega_0 = self._get_float(32, -31, True)\n        c_is = self._get_float(16, -29, True)\n        i_0 = self._get_float(32, -31, True)\n        c_rc = self._get_float(16, -5, True)\n        omega = self._get_float(32, -31, True)\n        omega_dot = self._get_float(24, -43, True)\n\n        # Issue of data (ephemeris).\n        self._get_bits(8)\n\n        i_dot = self._get_float(14, -43, True)\n\n        # Parity bits.\n        self._skip_bits(2)\n\n        return Subframe3(\n            handover,\n            c_ic,\n            omega_0,\n            c_is,\n            i_0,\n            c_rc,\n            omega,\n            omega_dot,\n            i_dot,\n        )\n\n    def _decode_subframe_4(self, handover: Handover) -> Subframe4:\n        # We don't need anything from subframe 4 other than the TOW count.\n        return Subframe4(handover)\n\n    def _decode_subframe_5(self, handover: Handover) -> Subframe5:\n        # We don't need anything from subframe 5 other than the TOW count.\n        return Subframe5(handover)\n\n    def _get_bit(self) -> Bit:\n        [bit] = self._get_bits(1)\n        return bit\n\n    def _get_bits(self, bit_count: int) -> list[Bit]:\n        invariant(\n            self._cursor + bit_count <= len(self._data),\n            \"Can't read past end of subframe\",\n        )\n\n        bits = self._data[self._cursor : self._cursor + bit_count]\n        self._cursor += bit_count\n        return bits\n\n    def _get_bool(self) -> bool:\n        return self._get_bit() == 1\n\n    def _get_float(\n        self, bit_count: int, scale_factor_exponent: int, twos_complement: bool\n    ) -> float:\n        \"\"\"Reads ``bit_count`` bits as an integer, optionally interprets it in\n        two's complement representation, multiplies it by 2 to the power of\n        ``scale_factor_exponent``, and returns the result as a ``float``.\n        \"\"\"\n        number = self._get_int(bit_count)\n\n        # If we're to interpret the number in two's complement representation\n        # and the most significant bit is 1, convert it to a negative number.\n        if twos_complement and number & (1 << (bit_count - 1)):\n            number -= 1 << bit_count\n\n        return number * 2**scale_factor_exponent\n\n    def _get_int(self, bit_count: int) -> int:\n        return parse_int_from_bits(self._get_bits(bit_count))\n\n    def _skip_bits(self, bit_count: int) -> None:\n        self._get_bits(bit_count)\n\n\ndef _decode_subframe_data(subframe_transmitted: list[Bit]) -> list[Bit]:\n    \"\"\"Decodes a subframe's data bits from its transmitted bits.\n\n    As per section 20.3.5 of IS-GPS-200, a subframe's source data bits are\n    transformed before transmission. This function takes the transmitted bits\n    and attempts to decode the source data bits, checking parity in the process.\n    Raises a ``ParityError`` if any parity checks fail.\n\n    Parity bits aren't included in the returned value. This means that the input\n    list has a length of 300 but the output list has a length of 240.\n    \"\"\"\n\n    # Decoding the data bits is quite simple. A subframe contains 10 words and\n    # each word contains 30 bits. The first 24 bits of each word are data bits\n    # and the following 6 bits are parity bits. Each data bit has been XORed\n    # with bit 30 of the previous word. To undo this we just XOR it again.\n    #\n    # Checking the parity bits is also reasonably straightforward. Table 20-XIV\n    # of IS-GPS-200 lists how each parity bit is computed using either bit 29 or\n    # 30 from the previous word and a subset of the data bits. We perform the\n    # same computations and ensure they equal what was transmitted.\n\n    invariant(\n        len(subframe_transmitted) == BITS_PER_SUBFRAME,\n        f\"Invalid number of bits to decode subframe. Expected 300, got: {len(subframe_transmitted)}\",\n    )\n\n    subframe_data: list[Bit] = []\n\n    # For the first word we assume bits 29 and 30 of the \"previous word\" are 0.\n    last_word_bit_29: Bit = 0\n    last_word_bit_30: Bit = 0\n\n    for i in range(0, BITS_PER_SUBFRAME, _BITS_PER_WORD):\n        word_transmitted = subframe_transmitted[i : i + _BITS_PER_WORD]\n        word_data: list[Bit] = []\n\n        for j in range(_DATA_BITS_PER_WORD):\n            word_data.append(cast(Bit, word_transmitted[j] ^ last_word_bit_30))\n\n        _verify_parity(\n            word_transmitted[24],\n            last_word_bit_29,\n            word_data,\n            [1, 2, 3, 5, 6, 10, 11, 12, 13, 14, 17, 18, 20, 23],\n        )\n\n        _verify_parity(\n            word_transmitted[25],\n            last_word_bit_30,\n            word_data,\n            [2, 3, 4, 6, 7, 11, 12, 13, 14, 15, 18, 19, 21, 24],\n        )\n\n        _verify_parity(\n            word_transmitted[26],\n            last_word_bit_29,\n            word_data,\n            [1, 3, 4, 5, 7, 8, 12, 13, 14, 15, 16, 19, 20, 22],\n        )\n\n        _verify_parity(\n            word_transmitted[27],\n            last_word_bit_30,\n            word_data,\n            [2, 4, 5, 6, 8, 9, 13, 14, 15, 16, 17, 20, 21, 23],\n        )\n\n        _verify_parity(\n            word_transmitted[28],\n            last_word_bit_30,\n            word_data,\n            [1, 3, 5, 6, 7, 9, 10, 14, 15, 16, 17, 18, 21, 22, 24],\n        )\n\n        _verify_parity(\n            word_transmitted[29],\n            last_word_bit_29,\n            word_data,\n            [3, 5, 6, 8, 9, 10, 11, 13, 15, 19, 22, 23, 24],\n        )\n\n        subframe_data += word_data\n        last_word_bit_29 = word_transmitted[28]\n        last_word_bit_30 = word_transmitted[29]\n\n    return subframe_data\n\n\nclass ParityError(Exception):\n    \"\"\"Indicates that one or more parity bits in a subframe were invalid.\"\"\"\n\n    pass\n\n\ndef _verify_parity(\n    transmitted_parity: Bit,\n    previous_word_parity: Bit,\n    word_data: list[Bit],\n    word_data_indices: list[int],\n) -> None:\n    \"\"\"Uses an equation from table 20-XIV of IS-GPS-200 to compute a parity bit\n    and asserts that the computed value equals the transmitted value.\n\n    Raises a ``ParityError`` if the values aren't equal.\n\n    ``word_data_indices`` are 1-based to match the definitions in the table.\n    \"\"\"\n\n    computed_parity: Bit = cast(\n        Bit,\n        (previous_word_parity + sum([word_data[i - 1] for i in word_data_indices])) % 2,\n    )\n\n    if computed_parity != transmitted_parity:\n        raise ParityError()\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/subframes.py",
    "content": "from dataclasses import dataclass\nfrom typing import Literal\n\nfrom .types import Bit\n\nSubframeId = Literal[1, 2, 3, 4, 5]\n\n\n@dataclass\nclass Handover:\n    \"\"\"A handover word (HOW).\n\n    See section 20.3.3.2 of IS-GPS-200 for more information.\n    \"\"\"\n\n    # The time-of-week (TOW) count at the leading edge of the next subframe.\n    #\n    # The TOW count is a 19 bit value representing the number of X1 epochs\n    # (1.5 s periods) that have occurred since the start of the week. This field\n    # contains the 17 most significant bits (MSBs) of the TOW count as it will\n    # be at the leading edge of the next subframe. With 17 bits we have a\n    # granularity of 1.5 s * 2^2 = 6 s which is exactly how long it takes to\n    # transmit a subframe. Thus, the two LSBs aren't even necessary!\n    tow_count_msbs: list[Bit]\n\n    subframe_id: SubframeId\n\n\n@dataclass\nclass Subframe:\n    handover: Handover\n\n\n@dataclass\nclass Subframe1(Subframe):\n    \"\"\"Subframe 1.\n\n    See section 20.3.3.3 of IS-GPS-200 for more information.\n    \"\"\"\n\n    # A 6 bit field indicating the health of the satellite's navigation data.\n    #\n    # If the MSB is 0 the data is healthy, if it's 1 the data is unhealthy in\n    # some way. The next 5 bits indicate the health of different components.\n    sv_health: list[Bit]\n\n    t_gd: float  # seconds\n    t_oc: float  # seconds\n    a_f2: float  # seconds/second^2\n    a_f1: float  # seconds/second\n    a_f0: float  # seconds\n\n\n@dataclass\nclass Subframe2(Subframe):\n    \"\"\"Subframe 2.\n\n    See section 20.3.3.4 of IS-GPS-200 for more information.\n    \"\"\"\n\n    c_rs: float  # meters\n    delta_n: float  # semi-circles/second\n    m_0: float  # semi-circles\n    c_uc: float  # radians\n    e: float  # dimensionless\n    c_us: float  # radians\n    sqrt_a: float  # √meters\n    t_oe: float  # seconds\n\n\n@dataclass\nclass Subframe3(Subframe):\n    \"\"\"Subframe 3.\n\n    See section 20.3.3.4 of IS-GPS-200 for more information.\n    \"\"\"\n\n    c_ic: float  # radians\n    omega_0: float  # semi-circles\n    c_is: float  # radians\n    i_0: float  # semi-circles\n    c_rc: float  # meters\n    omega: float  # semi-circles\n    omega_dot: float  # semi-circles/second\n    i_dot: float  # semi-circles/second\n\n\n@dataclass\nclass Subframe4(Subframe):\n    \"\"\"Subframe 4.\n\n    See section 20.3.3.5 of IS-GPS-200 for more information.\n    \"\"\"\n\n    # We don't need anything from subframe 4 other than the TOW count.\n    pass\n\n\n@dataclass\nclass Subframe5(Subframe):\n    \"\"\"Subframe 5.\n\n    See section 20.3.3.5 of IS-GPS-200 for more information.\n    \"\"\"\n\n    # We don't need anything from subframe 5 other than the TOW count.\n    pass\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/tracker.py",
    "content": "import math\nfrom collections import deque\nfrom datetime import timedelta\n\nimport numpy as np\nfrom typing_extensions import assert_never\n\nfrom .acquirer import Acquisition\nfrom .config import (\n    CARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN,\n    CARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN,\n    PRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN,\n    TRACKING_HISTORY_SIZE,\n)\nfrom .constants import L1_FREQUENCY, SAMPLE_TIMES\nfrom .prn_codes import COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID\nfrom .pseudosymbol_integrator import PseudosymbolIntegrator\nfrom .types import OneMsOfSamples, Side\nfrom .utils import invariant\nfrom .world import World\n\n\nclass Tracker:\n    \"\"\"Tracks a satellite's signal and decodes pseudosymbols.\"\"\"\n\n    def __init__(\n        self,\n        acquisition: Acquisition,\n        pseudosymbol_integrator: PseudosymbolIntegrator,\n        world: World,\n    ) -> None:\n        # The most recent estimates of the carrier's frequency shift in Hz.\n        self._carrier_frequency_shifts = deque[float](\n            [acquisition.carrier_frequency_shift], maxlen=TRACKING_HISTORY_SIZE\n        )\n\n        # The most recent estimates of the carrier's phase shift in radians.\n        self._carrier_phase_shifts = deque[float](\n            [acquisition.carrier_phase_shift], maxlen=TRACKING_HISTORY_SIZE\n        )\n\n        # The most recent correlations of 1 ms of received signal and the prompt\n        # local replica. These can be used to plot a constellation diagram.\n        self._correlations = deque[complex]([], maxlen=TRACKING_HISTORY_SIZE)\n\n        # The satellite's complex, upsampled PRN code.\n        self._prn_code = COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID[\n            acquisition.satellite_id\n        ]\n\n        self._prn_code_length = len(self._prn_code)\n\n        # The most recent estimates of the PRN code's phase shift in half-chips.\n        #\n        # The values are floats because the delay-locked-loop that tracks the\n        # PRN code phase shift gradually changes it by adding floating-point\n        # values. When we actually need to use them we cast them to integers.\n        self._prn_code_phase_shifts = deque[float](\n            [acquisition.prn_code_phase_shift], maxlen=TRACKING_HISTORY_SIZE\n        )\n\n        self._pseudosymbol_integrator = pseudosymbol_integrator\n        self._satellite_id = acquisition.satellite_id\n\n        # The PRN code phase shift is typically non-zero, i.e. PRN codes in the\n        # signal aren't aligned with the receiver's sample chunks. This means\n        # that each chunk contains the end of one PRN code (the \"left\" side of\n        # the chunk) followed by the start of another (the \"right\" side). The\n        # larger of the two determines the chunk's correlation with the local\n        # replica. Their sizes are determined by the PRN code phase shift.\n        #\n        # For example, in this diagram PRN code n + 1 is larger so it determines\n        # the chunk's correlation, which determines the pseudosymbol, etc.\n        #\n        #   Start of 1 ms chunk          End of 1 ms chunk\n        #                     ▼          ▼\n        #                     +---+------+\n        # End of PRN code n ▶ |   |      | ◀ Start of PRN code n + 1\n        #                     +---+------+\n        #                         ▲\n        #                         PRN code phase shift\n        #\n        # This attribute stores which side was larger on initialisation or after\n        # the last PRN code phase shift wrap (whichever happened last). We must\n        # track this because it affects PRN code counting, which affects time\n        # calculation. For example, if the right side is dominant at the end of\n        # a subframe we haven't seen the trailing edge of its last PRN code and\n        # thus we're not at the next subframe's TOW yet. If we increment the PRN\n        # count on receiving the next sample chunk (when we actually see the end\n        # of the previous subframe) we'll introduce a 1 ms (~300 km) error.\n        self._side = (\n            Side.LEFT\n            if self._prn_code_phase_shift > self._prn_code_length / 2\n            else Side.RIGHT\n        )\n\n        self._world = world\n\n    @property\n    def carrier_frequency_shifts(self) -> list[float]:\n        return list(self._carrier_frequency_shifts)\n\n    @property\n    def correlations(self) -> list[complex]:\n        return list(self._correlations)\n\n    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:\n        \"\"\"Uses 1 ms of samples to determine the transmitted pseudosymbol and\n        update tracking parameters.\"\"\"\n\n        # Perform carrier wipeoff.\n        shifted_samples = samples.samples * np.exp(\n            -1j\n            * (\n                2 * np.pi * self._carrier_frequency_shift * SAMPLE_TIMES\n                + self._carrier_phase_shift\n            )\n        )\n\n        # Update the PRN code phase shift.\n        #\n        # PRN code phase shift wrapping may impact ``PseudoSymbolIntegrator``\n        # synchronisation but I haven't though about it too much. If so, it will\n        # eventually produce rubbish bits, they will cause parity errors, the\n        # satellite will be dropped, then re-acquired, and all will be well.\n        wrap_side = self._track_prn_code_phase_shift(shifted_samples)\n\n        # Determine how many trailing edges of PRN codes we've observed in this\n        # 1 ms period (if any) and handle wrapping of the PRN code phase shift.\n        if wrap_side is None:\n            prn_count = 1\n        elif wrap_side == Side.LEFT:\n            # Wrapping past the left side means we've observed one additional\n            # trailing edge of a PRN code and the left side is now dominant.\n            prn_count = 2\n            self._side = Side.LEFT\n        elif wrap_side == Side.RIGHT:\n            # Wrapping past the right side means we've observed one fewer\n            # trailing edge of a PRN code and the right side is now dominant.\n            prn_count = 0\n            self._side = Side.RIGHT\n        else:\n            assert_never(wrap_side)\n\n        # Report the current side and the number of PRN codes that were observed\n        # to the ``World`` instance for use in its time calculations.\n        self._world.handle_prns_tracked(\n            prn_count,\n            self._satellite_id,\n            self._side,\n            # Calculate the time of the trailing edge of the last PRN code.\n            samples.start_timestamp\n            + timedelta(\n                seconds=self._prn_code_phase_shift / self._prn_code_length / 1000\n            ),\n        )\n\n        # Calculate the correlation of the shifted samples and the prompt local\n        # replica. If our estimates are good, multiplying the shifted samples by\n        # the prompt local replica removes the PRN code, leaving only the\n        # navigation bit. The correlation is thus the navigation by times the\n        # number of samples per millisecond - hopefully a (mostly) real number.\n        #\n        # No need to take the conjucate of the replica - it only contains ±1.\n        prn_code = np.roll(self._prn_code, int(self._prn_code_phase_shift))\n        correlation = np.sum(shifted_samples * prn_code)\n        self._correlations.append(correlation)\n\n        # Decode and handle the pseudosymbol.\n        self._pseudosymbol_integrator.handle_pseudosymbol(\n            -1 if correlation.real < 0 else 1\n        )\n\n        # Update the carrier wave frequency/phase shift.\n        self._track_carrier(correlation)\n\n    @property\n    def prn_code_phase_shifts(self) -> list[float]:\n        return list(self._prn_code_phase_shifts)\n\n    @property\n    def _carrier_frequency_shift(self) -> float:\n        \"\"\"Returns the most recent estimate of the carrier wave's frequency\n        shift in Hz.\"\"\"\n\n        invariant(len(self._carrier_frequency_shifts) > 0)\n        return self._carrier_frequency_shifts[-1]\n\n    @property\n    def _carrier_phase_shift(self) -> float:\n        \"\"\"Returns the most recent estimate of the carrier wave's phase shift in\n        radians.\"\"\"\n\n        invariant(len(self._carrier_phase_shifts) > 0)\n        return self._carrier_phase_shifts[-1]\n\n    def _track_prn_code_phase_shift(self, shifted_samples: np.ndarray) -> Side | None:\n        \"\"\"Tracks the C/A PRN code's phase shift using a delay-locked loop.\n\n        Returns the side over which the phase shift wrapped (if any). For\n        example, if it becomes negative (moves past the \"left\" side) the PRN\n        length is added to wrap it (to the \"right\" side). This is required\n        because wrapping from the left to the right means we've seen one extra\n        PRN code and the opposite means we've seen one fewer.\n        \"\"\"\n\n        # Generate replicas that are early and late by a half-chip.\n        early = np.roll(self._prn_code, int(self._prn_code_phase_shift - 1))\n        late = np.roll(self._prn_code, int(self._prn_code_phase_shift + 1))\n\n        # Calculate the correlation of the shifted samples and the replicas.\n        #\n        # No need to take the conjugate of the replicas - they only contain ±1.\n        early_correlation = np.sum(shifted_samples * early)\n        late_correlation = np.sum(shifted_samples * late)\n\n        # Calculate the discriminator.\n        #\n        # This the non-coherent early minus late power discriminator. It is\n        # defined here[1], is used by Gypsum[2], and was suggested by Claude.\n        #\n        # 1: https://gssc.esa.int/navipedia/index.php/Delay_Lock_Loop_(DLL)#Discriminators\n        # 2: https://github.com/codyd51/gypsum/blob/b9a5b4ec98557cf107f589dbffa0ad522851c14c/gypsum/tracker.py#L297\n        discriminator = (\n            (early_correlation.real**2 + early_correlation.imag**2)\n            - (late_correlation.real**2 + late_correlation.imag**2)\n        ) / 2\n\n        # Calculate the number of additional (or fewer) half chips that will be\n        # present in 1 ms of samples due to Doppler shift of the carrier wave.\n        #\n        # If a satellite's carrier wave has been Doppler shifted, so too will\n        # the PRN code within. It will stretch or shrink in time and it will no\n        # longer be the case that 1 ms of samples contains exactly one cycle.\n        # We need to account for this when updating the phase shift, otherwise\n        # the differences will accumulate over time and we'll lose lock.\n        half_chips_due_to_doppler_effect = (\n            len(self._prn_code) * self._carrier_frequency_shift / L1_FREQUENCY\n        )\n\n        # Update the PRN code phase shift.\n        prn_code_phase_shift = (\n            self._prn_code_phase_shift\n            - discriminator * PRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN\n            - half_chips_due_to_doppler_effect\n        )\n\n        # If it wraps, record over which side.\n        wrap_side: Side | None = None\n\n        if prn_code_phase_shift < 0:\n            prn_code_phase_shift += self._prn_code_length\n            prn_count_adjustment = 1\n            wrap_side = Side.LEFT\n        elif prn_code_phase_shift >= self._prn_code_length:\n            prn_code_phase_shift -= self._prn_code_length\n            wrap_side = Side.RIGHT\n\n        self._prn_code_phase_shifts.append(prn_code_phase_shift)\n        return wrap_side\n\n    @property\n    def _prn_code_phase_shift(self) -> float:\n        \"\"\"Returns the most recent estimate of the C/A PRN code's phase shift in\n        half-chips.\"\"\"\n\n        invariant(len(self._prn_code_phase_shifts) > 0)\n        return self._prn_code_phase_shifts[-1]\n\n    def _track_carrier(self, correlation: complex) -> None:\n        \"\"\"Tracks the carrier wave's frequency and phase shifts using a Costas\n        loop.\n\n        ``correlation`` is the correlation between the (post wipeoff) received\n        signal and the prompt local replica of the PRN code.\n        \"\"\"\n\n        # The received signal can be expressed as\n        # n(t) * prn_code(t) * exp(2 π ((f + Δf) t + θ)) where n(t) = ±1 is the\n        # navigation bit at time t, prn_code(t) = ±1 is the PRN code chip at\n        # time t, exp(...) is the exponential function, f is the L1 frequency\n        # 1.56542 GHz, Δf is the signal's frequency shift due to the Doppler\n        # effect, and θ is the phase shift of the carrier wave.\n        #\n        # If we undersample the antenna such that the L1 frequency is aliased at\n        # 0 Hz, f disappears from this expression leaving\n        # n(t) * prn_code(t) * exp(2 π (Δf t + θ)).\n        #\n        # If we're tracking the carrier wave well (i.e. our estimates of Δf and\n        # θ are good), carrier wipeoff removes the exponential term leaving\n        # n(t) * prn_code(t).\n        #\n        # If we're tracking the PRN code phase shift well (i.e. our local\n        # replica is equal to prn_code(t)) then multiplying the (post wipeoff)\n        # signal by the local replica leaves\n        #\n        #     n(t) * prn_code(t) * prn_code(t)\n        #     = n(t) * prn_code(t) ** 2\n        #     = n(t) * (±1) ** 2\n        #     = n(t).\n        #\n        # In other words, if we're tracking the signal well, the correlation of\n        # the (post wipeoff) signal and the local replica of the PRN code will\n        # be the value of the navigation bit during that period multiplied by\n        # the sampling rate - a real value. If we find that the correlation\n        # isn't (at least mostly) real, our estimates of Δf, θ, and/or the PRN\n        # code phase shift are wrong. For this reason we use the complex\n        # argument of the correlation as the error signal in this loop.\n\n        # Normalise the correlation so the loop is independent of signal\n        # amplitude. A small epsilon value is added to avoid numerical\n        # instability when the correlation itself has a small magnitude.\n        correlation /= abs(correlation) + 1e-8\n\n        # Calculate the error signal.\n        #\n        # We want each correlation to be a real value, i.e. have no imaginary\n        # component. Some correlations correspond to binary 0s and will lay on\n        # one side of the Q axis while others correspond to binary 1s and will\n        # lay on the other side. This 180° phase shift between 0s and 1s means\n        # we can't simply use the complex argument of the correlation as the\n        # error signal as it would try to move both to the positive I axis.\n        # Instead we use the complex argument as calculated by ``math.atan``\n        # which is restricted to the range [-π/2, π/2]. This means the error\n        # signal will tend to move correlations towards their closest I axis.\n        #\n        # Later on we determine the overall phase, i.e. are correlations on the\n        # negative I axis 0s and those on the positive I axis 1s, or vice versa?\n        error = (\n            0\n            if correlation.real == 0\n            else math.atan(correlation.imag / correlation.real)\n        )\n\n        # The interval between successive Tracker updates in seconds.\n        tracker_update_interval = 0.001\n\n        # Update the carrier wave's frequency shift.\n        self._carrier_frequency_shifts.append(\n            self._carrier_frequency_shift\n            + CARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN\n            * error\n            * tracker_update_interval\n        )\n\n        # Update the carrier wave's phase shift.\n        #\n        # It's important that this include the current estimate of the carrier\n        # frequency shift to account for the change in phase it will cause in\n        # between Tracker updates. This is why we update the estimate of the\n        # carrier frequency shift first - to ensure we're using the latest data.\n        carrier_phase_shift = (\n            self._carrier_phase_shift\n            + (\n                CARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN * error\n                + 2 * np.pi * self._carrier_frequency_shift\n            )\n            * tracker_update_interval\n        )\n        carrier_phase_shift %= 2 * np.pi\n        self._carrier_phase_shifts.append(carrier_phase_shift)\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/types.py",
    "content": "from __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta\nfrom enum import Enum\nfrom typing import Annotated, Literal\n\nimport numpy as np\nfrom pydantic import Field\n\nfrom .constants import SAMPLES_PER_SECOND, SECONDS_PER_SAMPLE\n\n# A bit.\n#\n# This is the result of ``PseudobitIntegrator`` determining the overall bit\n# phase and applying it to an ``Pseudobit``. There's no phase ambiguity here.\nBit = Literal[0, 1]\n\n# A signal's bit phase.\n#\n# -1 means -1 maps to 1 and 1 maps to 0. 1 means the opposite.\nBitPhase = Literal[-1, 1]\n\n# A pseudosymbol emitted by a ``Tracker``.\n#\n# Can also be considered one twentieth of a navigation bit.\n#\n# Defined as -1 or 1 rather than 0 or 1 because the latter suggests we know how\n# pseudosymbols map to bits. However, due to the phase ambiguity of BPSK, we\n# don't know how they map until the overall phase of the signal is determined.\nPseudosymbol = Literal[-1, 1]\n\n\n@dataclass(kw_only=True)\nclass Samples:\n    \"\"\"A set of samples taken at a rate of ``constants.SAMPLES_PER_SECOND``.\"\"\"\n\n    # The time just after the last sample was taken.\n    end_timestamp: UtcTimestamp\n\n    # The samples.\n    #\n    # Has shape ``(n,)`` where ``n`` is the number of samples that were taken\n    # and contains ``np.complex64`` values.\n    samples: np.ndarray\n\n    # The time just before the first sample was taken.\n    start_timestamp: UtcTimestamp\n\n    def __add__(self, other: Samples) -> Samples:\n        \"\"\"Concatenates two sets of samples.\n\n        When concatenating two sets of samples ``x + y``, it is assumed that\n        ``y`` immediately follows ``x`` in time. This means it makes sense to:\n\n        - use the start timestamp of ``x`` as the start timestamp of the result,\n        - use the end timestamp of ``y`` as the end timestamp of the result, and\n        - use the concatentation of ``x``'s samples followed by ``y``'s samples\n          as the samples of the result.\n        \"\"\"\n\n        return Samples(\n            end_timestamp=other.end_timestamp,\n            samples=np.concatenate((self.samples, other.samples)),\n            start_timestamp=self.start_timestamp,\n        )\n\n    def __getitem__(self, key: slice) -> Samples:\n        \"\"\"Returns a subset of the samples.\n\n        For example, if ``x`` contains 2046 samples then ``x[0 : 1023]``\n        contains the first 1023 samples with appropriate timestamps.\n\n        Empty slices and negative indices aren't supported.\n        \"\"\"\n\n        # Check that the slice bounds are of the correct type.\n        if not (\n            (isinstance(key.start, int) or key.start is None)\n            and (isinstance(key.stop, int) or key.stop is None)\n            and key.step is None\n        ):\n            raise TypeError(\"Invalid slice\")\n\n        start = 0 if key.start is None else key.start\n        stop = len(self.samples) if key.stop is None else key.stop\n\n        # Check that the slice bounds are valid indices.\n        if not (\n            start >= 0\n            and start < len(self.samples)\n            and stop >= 0\n            and stop <= len(self.samples)\n            and stop > start\n        ):\n            raise IndexError(\"Invalid slice\")\n\n        return Samples(\n            end_timestamp=self.start_timestamp\n            + timedelta(seconds=stop * SECONDS_PER_SAMPLE),\n            samples=self.samples[start:stop],\n            start_timestamp=self.start_timestamp\n            + timedelta(seconds=start * SECONDS_PER_SAMPLE),\n        )\n\n\n# 1 ms of samples.\n#\n# This type primarily exists for documentation purposes.\nOneMsOfSamples = Samples\n\n\n# The ID of a GPS satellite based on its PRN number. This can be an integer\n# between 1 and 32 inclusive, but PRN number 1 is not currently in use[1].\n#\n# 1: https://en.wikipedia.org/wiki/List_of_GPS_satellites#PRN_status_by_satellite_block\nSatelliteId = Annotated[int, Field(ge=1, le=32)]\n\n\nclass Side(Enum):\n    \"\"\"A side of a chunk of samples.\"\"\"\n\n    # The left side (earlier in time).\n    LEFT = 0\n\n    # The right side (later in time).\n    RIGHT = 1\n\n\n# A phase ambiguous bit emitted by a ``PseudosymbolIntegrator``.\n#\n# ``PseudosymbolIntegrator`` identifies groups of pseudosymbols that correspond\n# to the same underlying navigation bit, determines the predominant phase within\n# that group, and emits the result. We can't call these navigation bits yet\n# because we haven't applied the bit phase. This is one of those values.\nPseudobit = Literal[-1, 1]\n\n# A datetime in the UTC time zone.\n#\n# The time zone isn't enforced by this type, but the name is a helpful reminder.\nUtcTimestamp = datetime\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/utils.py",
    "content": "from .types import Bit\n\n\nclass InvariantError(Exception):\n    \"\"\"An exception raised when an invariant condition is violated.\"\"\"\n\n    pass\n\n\ndef invariant(condition: bool, message: str = \"\") -> None:\n    \"\"\"Checks an invariant condition.\n\n    This is similar to the built-in ``assert`` keyword, but remains present even\n    if the code is run with the ``-O`` option and ``__debug__`` is ``False``.\n    \"\"\"\n    if not condition:\n        raise InvariantError(message)\n\n\ndef parse_int_from_bits(bits: list[Bit]) -> int:\n    \"\"\"Parses the given bits as an unsigned integer.\"\"\"\n\n    return int(\"\".join([str(b) for b in bits]), 2)\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/world.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport math\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta, timezone\n\nimport numpy as np\n\nfrom .subframes import Subframe, Subframe1, Subframe2, Subframe3, Subframe4, Subframe5\nfrom .types import Bit, SatelliteId, Side, UtcTimestamp\nfrom .utils import InvariantError, invariant, parse_int_from_bits\n\n# Section 3.3.4.\n_SECONDS_PER_WEEK: int = 60 * 60 * 24 * 7  # 604,800\n\n# Section 20.3.4.3.\n_SPEED_OF_LIGHT: float = 2.99792458e8\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass(kw_only=True)\nclass PendingSatelliteParameters:\n    \"\"\"A subset of the information required for satellite calculations.\n\n    After we start tracking a satellite, it takes some time to receive all\n    of the information we need to calculate its position and pseudorange. This\n    class exists to collect that information until we have it all, at which\n    point it can be promoted into the (more type safe) ``SatelliteParameters``.\n    \"\"\"\n\n    prn_code_trailing_edge_timestamp: UtcTimestamp | None = None\n    side: Side | None = None\n    subframe_1: Subframe1 | None = None\n    subframe_2: Subframe2 | None = None\n    subframe_3: Subframe3 | None = None\n    tow_count: int | None = None\n\n    def handle_subframe(self, subframe: Subframe) -> None:\n        self.tow_count = parse_int_from_bits(subframe.handover.tow_count_msbs)\n\n        if isinstance(subframe, Subframe1):\n            self.subframe_1 = subframe\n        elif isinstance(subframe, Subframe2):\n            self.subframe_2 = subframe\n        elif isinstance(subframe, Subframe3):\n            self.subframe_3 = subframe\n        elif isinstance(subframe, Subframe4) or isinstance(subframe, Subframe5):\n            # We don't need subframes 4 or 5.\n            pass\n        else:\n            raise InvariantError(f\"Unexpected subframe: {subframe}\")\n\n    def to_satellite_parameters(self) -> SatelliteParameters | None:\n        \"\"\"Attempt to construct a ``SatelliteParameters`` instance.\n\n        Returns ``None`` if we don't have all the required information yet.\n        \"\"\"\n\n        if (\n            self.prn_code_trailing_edge_timestamp is None\n            or self.side is None\n            or self.subframe_1 is None\n            or self.subframe_2 is None\n            or self.subframe_3 is None\n            or self.tow_count is None\n        ):\n            return None\n\n        # Multiplications by pi are converting semi-circles to radians.\n        return SatelliteParameters(\n            a_f0=self.subframe_1.a_f0,\n            a_f1=self.subframe_1.a_f1,\n            a_f2=self.subframe_1.a_f2,\n            c_ic=self.subframe_3.c_ic,\n            c_is=self.subframe_3.c_is,\n            c_rc=self.subframe_3.c_rc,\n            c_rs=self.subframe_2.c_rs,\n            c_uc=self.subframe_2.c_uc,\n            c_us=self.subframe_2.c_us,\n            delta_n=self.subframe_2.delta_n * np.pi,\n            e=self.subframe_2.e,\n            i_0=self.subframe_3.i_0 * np.pi,\n            i_dot=self.subframe_3.i_dot * np.pi,\n            m_0=self.subframe_2.m_0 * np.pi,\n            omega=self.subframe_3.omega * np.pi,\n            omega_0=self.subframe_3.omega_0 * np.pi,\n            omega_dot=self.subframe_3.omega_dot * np.pi,\n            prn_code_trailing_edge_timestamp=self.prn_code_trailing_edge_timestamp,\n            # If the right side is dominant we haven't seen the trailing edge of\n            # the previous subframe and we're not yet at the TOW of the next.\n            # Setting the PRN count to -1 means that when we increment it to 0\n            # in the next millisecond we'll be aligned with the subframe's TOW.\n            prn_count=-1 if self.side == Side.RIGHT else 0,\n            sqrt_a=self.subframe_2.sqrt_a,\n            sv_health=self.subframe_1.sv_health,\n            t_gd=self.subframe_1.t_gd,\n            t_oc=self.subframe_1.t_oc,\n            t_oe=self.subframe_2.t_oe,\n            tow_count=self.tow_count,\n        )\n\n\n@dataclass(kw_only=True)\nclass SatelliteParameters:\n    \"\"\"The information required for satellite calculations.\n\n    These parameters are updated as we receive PRN codes and subframes from the\n    satellite. All properties are required which simplifies type checking.\n    \"\"\"\n\n    a_f0: float  # seconds\n    a_f1: float  # seconds/second\n    a_f2: float  # seconds/second^2\n    c_ic: float  # radians\n    c_is: float  # radians\n    c_rc: float  # meters\n    c_rs: float  # meters\n    c_uc: float  # radians\n    c_us: float  # radians\n    delta_n: float  # radians/second\n    e: float  # dimensionless\n    i_0: float  # radians\n    i_dot: float  # radians/second\n\n    m_0: float  # radians\n    omega: float  # radians\n    omega_0: float  # radians\n    omega_dot: float  # radians/second\n\n    # The time at which the last PRN code trailing edge was observed.\n    prn_code_trailing_edge_timestamp: UtcTimestamp\n\n    # The number of PRN code trailing edges that have been observed since the\n    # start of the current subframe. Note that this may be negative.\n    prn_count: int\n\n    sqrt_a: float  # √meters\n\n    # A 6 bit field indicating the health of the satellite's navigation data.\n    #\n    # See ``Subframe1.sv_health`` for more information.\n    sv_health: list[Bit]\n\n    t_gd: float  # seconds\n    t_oc: float  # seconds since the start of the GPS week\n    t_oe: float  # seconds since the start of the GPS week\n\n    # The time-of-week (TOW) count at the leading edge of the current subframe.\n    #\n    # The transmitted value is the TOW count at the leading edge of the next\n    # subframe. However, this isn't updated until we've parsed a full subframe,\n    # so by the time this is set it actually applies to the current subframe.\n    #\n    # See ``Handover.tow_count_msbs`` for more information.\n    tow_count: int\n\n    def handle_subframe(self, subframe: Subframe) -> None:\n        # Reset the number of PRN code trailing edges we've observed in the\n        # current subframe. It's intentional that we subtract the number of PRN\n        # codes per subframe rather than setting it to 0 to handle the cases\n        # where, due to Doppler shift, we observe 0 or 2 PRN code trailing edges\n        # in a 1 ms period. If we set it to 0 the PRN count would be off by 1\n        # which results in an error of ~300 km in the pseudorange.\n        self.prn_count -= 6000\n\n        self.tow_count = parse_int_from_bits(subframe.handover.tow_count_msbs)\n\n        # Multiplications by pi are converting semi-circles to radians.\n        if isinstance(subframe, Subframe1):\n            self.a_f0 = subframe.a_f0\n            self.a_f1 = subframe.a_f1\n            self.a_f2 = subframe.a_f2\n            self.sv_health = subframe.sv_health\n            self.t_gd = subframe.t_gd\n            self.t_oc = subframe.t_oc\n        elif isinstance(subframe, Subframe2):\n            self.c_rs = subframe.c_rs\n            self.c_uc = subframe.c_uc\n            self.c_us = subframe.c_us\n            self.delta_n = subframe.delta_n * np.pi\n            self.e = subframe.e\n            self.m_0 = subframe.m_0 * np.pi\n            self.sqrt_a = subframe.sqrt_a\n            self.t_oe = subframe.t_oe\n        elif isinstance(subframe, Subframe3):\n            self.c_ic = subframe.c_ic\n            self.c_is = subframe.c_is\n            self.c_rc = subframe.c_rc\n            self.i_0 = subframe.i_0 * np.pi\n            self.i_dot = subframe.i_dot * np.pi\n            self.omega = subframe.omega * np.pi\n            self.omega_0 = subframe.omega_0 * np.pi\n            self.omega_dot = subframe.omega_dot * np.pi\n        elif isinstance(subframe, Subframe4) or isinstance(subframe, Subframe5):\n            # We don't need anything else from subframes 4 or 5.\n            pass\n        else:\n            raise InvariantError(f\"Unexpected subframe: {subframe}\")\n\n\n@dataclass\nclass EcefCoordinates:\n    \"\"\"A location expressed in Earth-centred, Earth-fixed (ECEF) coordinates.\"\"\"\n\n    x: float  # meters\n    y: float  # meters\n    z: float  # meters\n\n\n@dataclass\nclass EcefSolution:\n    \"\"\"A computed solution with the position in ECEF coordinates.\"\"\"\n\n    # An estimate of the receiver's clock bias, in seconds.\n    #\n    # Note that this is the amount by which the receiver's clock differs from\n    # GPS time. For example if it was 1.5 s behind GPS time this would be -1.5.\n    clock_bias: float\n\n    # An estimate of the receiver's position, in ECEF coordinates.\n    position: EcefCoordinates\n\n\nclass World:\n    \"\"\"Stores satellite parameters and implements solution computation.\"\"\"\n\n    def __init__(self) -> None:\n        # Information about satellites we started tracking recently. Once we\n        # have enough information they're promoted to ``_satellite_parameters``\n        # and can be used to calculate the receiver's position and pseudorange.\n        self._pending_satellite_parameters: dict[\n            SatelliteId, PendingSatelliteParameters\n        ] = {}\n\n        # Information about satellite's we're tracking.\n        self._satellite_parameters: dict[SatelliteId, SatelliteParameters] = {}\n\n    def compute_solution(self) -> EcefSolution | None:\n        \"\"\"Compute the receiver's clock bias and position.\n\n        If that's not possible, returns ``None``.\n        \"\"\"\n\n        # Determine which satellites can be used.\n        #\n        # In order to be used, a satellite's parameters must be present in\n        # ``self._satellite_parameters`` (i.e. we have all of the parameters we\n        # need to compute its position and pseudorange), and it must be healthy.\n        satellite_ids: list[SatelliteId] = []\n        for satellite_id, satellite_parameters in self._satellite_parameters.items():\n            if satellite_parameters.sv_health[0] == 0:\n                satellite_ids.append(satellite_id)\n\n        # We need at least four satellites to determine the receiver's location.\n        if len(satellite_ids) < 4:\n            return None\n\n        # Compute the solution using the Gauss-Newton algorithm[1].\n        #\n        # 1: https://en.wikipedia.org/wiki/Gauss%E2%80%93Newton_algorithm#Description\n\n        # x, y, z, t\n        guess = np.zeros(4, dtype=float)\n\n        satellite_positions_and_signal_transit_times = [\n            self._compute_satellite_position_and_signal_transit_time(satellite_id)\n            for satellite_id in satellite_ids\n        ]\n\n        for _ in range(10):\n            j = self._compute_jacobian(\n                guess, satellite_positions_and_signal_transit_times\n            )\n            r = self._compute_residuals(\n                guess, satellite_positions_and_signal_transit_times\n            )\n            guess -= np.linalg.inv(j.T @ j) @ j.T @ r\n\n        return EcefSolution(guess[3], EcefCoordinates(*guess[:3]))\n\n    def has_required_subframes(self, satellite_id: SatelliteId) -> bool:\n        \"\"\"Returns whether we have received all the subframes required to use a\n        particular satellite in solution calculation (subframes 1, 2, and 3).\"\"\"\n\n        return satellite_id in self._satellite_parameters\n\n    def _compute_satellite_position_and_signal_transit_time(\n        self, satellite_id: SatelliteId\n    ) -> tuple[EcefCoordinates, float]:\n        \"\"\"Computes a satellite's position and the time taken for its signal to\n        transit to the receiver.\"\"\"\n\n        # Table 20-IV.\n        t = self._compute_satellite_t(satellite_id)\n        sp = self._get_satellite_parameters_or_error(satellite_id)\n        t_k = self._wrap_time_delta(t - sp.t_oe)\n        e_k = self._compute_satellite_e_k(satellite_id, t_k)\n        v_k = 2 * math.atan(math.sqrt((1 + sp.e) / (1 - sp.e)) * math.tan(e_k / 2))\n        phi_k = v_k + sp.omega\n        delta_u_k = sp.c_us * math.sin(2 * phi_k) + sp.c_uc * math.cos(2 * phi_k)\n        delta_r_k = sp.c_rs * math.sin(2 * phi_k) + sp.c_rc * math.cos(2 * phi_k)\n        delta_i_k = sp.c_is * math.sin(2 * phi_k) + sp.c_ic * math.cos(2 * phi_k)\n        u_k = phi_k + delta_u_k\n        r_k = sp.sqrt_a**2 * (1 - sp.e * math.cos(e_k)) + delta_r_k\n        i_k = sp.i_0 + delta_i_k + sp.i_dot * t_k\n        x_k_prime = r_k * math.cos(u_k)\n        y_k_prime = r_k * math.sin(u_k)\n        omega_e_dot = 7.2921151467e-5\n        omega_k = (\n            sp.omega_0 + (sp.omega_dot - omega_e_dot) * t_k - omega_e_dot * sp.t_oe\n        )\n        x_k = x_k_prime * math.cos(omega_k) - y_k_prime * math.cos(i_k) * math.sin(\n            omega_k\n        )\n        y_k = x_k_prime * math.sin(omega_k) + y_k_prime * math.cos(i_k) * math.cos(\n            omega_k\n        )\n        z_k = y_k_prime * math.sin(i_k)\n        position = EcefCoordinates(x_k, y_k, z_k)\n\n        # Calculate the signal transit time.\n        #\n        # As both are GPS time of week values, we must handle the case where\n        # they're in different weeks due week boundaries and wrap the difference\n        # appropriately. Note that, due to the receiver's clock bias, it's not\n        # always the case that t_rcv > t, i.e. the transit time may be negative!\n        t_rcv = self._to_time_of_week(sp.prn_code_trailing_edge_timestamp)\n        signal_transit_time = self._wrap_time_delta(t_rcv - t)\n\n        return (position, signal_transit_time)\n\n    def _compute_satellite_t(self, satellite_id: SatelliteId) -> float:\n        \"\"\"Computes the GPS time at which a satellite transmitted the trailing\n        edge of its most recently received PRN code (t).\"\"\"\n\n        # Section 20.3.3.3.3.1.\n        #\n        # Page 98 notes that equations 1 and 2 are coupled (to calculate t you\n        # need to know delta_t_sv which itself is defined in terms of t). To\n        # break this it suggests using t_sv in place of t in equation 2.\n        sp = self._get_satellite_parameters_or_error(satellite_id)\n        t_sv = sp.tow_count * 6 + sp.prn_count * 0.001\n        delta_t = self._wrap_time_delta(t_sv - sp.t_oc)\n        f = -4.442807633e-10\n        e_k = self._compute_satellite_e_k(\n            satellite_id, self._wrap_time_delta(t_sv - sp.t_oe)\n        )\n        delta_t_r = f * sp.e * sp.sqrt_a * math.sin(e_k)\n        delta_t_sv = sp.a_f0 + sp.a_f1 * delta_t + sp.a_f2 * delta_t**2 + delta_t_r\n\n        # Section 20.3.3.3.3.2.\n        return t_sv - (delta_t_sv - sp.t_gd)\n\n    def _get_satellite_parameters_or_error(\n        self, satellite_id: SatelliteId\n    ) -> SatelliteParameters:\n        try:\n            return self._satellite_parameters[satellite_id]\n        except KeyError:\n            raise InvariantError(\n                f\"SatelliteParameters not present for ID: {satellite_id}\"\n            )\n\n    def _wrap_time_delta(self, t: float) -> float:\n        \"\"\"Accounts for week crossovers by wrapping time deltas.\n\n        ``t`` is the difference between two GPS time of week values, e.g.\n        ``t_1 - t_2``. If the difference has a large magnitude that suggests\n        one value was at the end of a week and the other at the start. We\n        wrap the difference to accurately represent the time between them.\n        \"\"\"\n\n        if t > _SECONDS_PER_WEEK / 2:\n            return t - _SECONDS_PER_WEEK\n        elif t < -_SECONDS_PER_WEEK / 2:\n            return t + _SECONDS_PER_WEEK\n        else:\n            return t\n\n    def _compute_satellite_e_k(self, satellite_id: SatelliteId, t_k: float) -> float:\n        \"\"\"Computes a satellite's eccentric anomaly (E_k) at a specified number\n        of seconds from the ephemeris reference epoch (t_k), in radians.\n\n        Assumes that ``t_k`` has been run through ``_wrap_time_delta``.\n        \"\"\"\n\n        # Table 20-IV.\n        mu = 3.986005e14\n        sp = self._get_satellite_parameters_or_error(satellite_id)\n        a = sp.sqrt_a**2\n        n_0 = math.sqrt(mu / a**3)\n        n = n_0 + sp.delta_n\n        m_k = sp.m_0 + n * t_k\n        e = m_k\n\n        # The specification states a minimum of 3 iterations.\n        for _ in range(3):\n            e += (m_k - e + sp.e * math.sin(e)) / (1 - sp.e * math.cos(e))\n\n        return e\n\n    def _to_time_of_week(self, timestamp: UtcTimestamp) -> float:\n        \"\"\"Converts a UTC timestamp to a GPS time of week.\n\n        This is the number of seconds since the start of the GPS week.\n        \"\"\"\n\n        # GPS doesn't track leap seconds, but UTC does. Thus, we must undo all\n        # 18 leap seconds that have occurred since the GPS zero time-point\n        # (midnight on the morning of January 6, 1980). Apparently leap seconds\n        # will be abandoned by 2035[1] so I'm just going to hard code this.\n        #\n        # 1: https://en.wikipedia.org/wiki/Leap_second#International_proposals_for_elimination_of_leap_seconds\n        leap_seconds = 18\n        timestamp += timedelta(seconds=leap_seconds)\n\n        # The GPS time of week is the number of seconds that have occurred since\n        # the GPS zero time-point mod the number of seconds in a week.\n        #\n        # The datetime module doesn't support leap seconds, so this expression\n        # is safe, i.e. it doesn't result in leap seconds being counted twice.\n        zero = datetime(1980, 1, 6, tzinfo=timezone.utc)\n        return (timestamp - zero).total_seconds() % _SECONDS_PER_WEEK\n\n    def _compute_jacobian(\n        self,\n        guess: np.ndarray,\n        satellite_positions_and_signal_transit_times: list[\n            tuple[EcefCoordinates, float]\n        ],\n    ) -> np.ndarray:\n        \"\"\"Computes the Jacobian matrix for the navigation equations.\n\n        Each satellite's pseudorange equation has the form\n\n            sqrt((X - x)^2 + (Y - y)^2 + (Z - z)^2) - c * (T - t) = 0\n\n        where X, Y, and Z are the satellite's coordinates, x, y, and z are an\n        estimate of the receiver's coordinates, T is the satellite's signal\n        transit time, and t is the receiver's clock bias.\n        \"\"\"\n        rows = []\n        x, y, z, _ = guess\n\n        for p, _ in satellite_positions_and_signal_transit_times:\n            distance = math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2)\n            rows.append(\n                [\n                    -(p.x - x) / distance,\n                    -(p.y - y) / distance,\n                    -(p.z - z) / distance,\n                    _SPEED_OF_LIGHT,\n                ]\n            )\n\n        return np.array(rows)\n\n    def _compute_residuals(\n        self,\n        guess: np.ndarray,\n        satellite_positions_and_signal_transit_times: list[\n            tuple[EcefCoordinates, float]\n        ],\n    ) -> np.ndarray:\n        \"\"\"Computes the residual vector for the navigation equations.\n\n        These are the values to be minimised by the Gauss-Newton algorithm.\n\n        See ``_compute_jacobian`` for the pseudorange equation.\n        \"\"\"\n        x, y, z, t = guess\n        return np.array(\n            [\n                # The LHS of the pseudorange equation.\n                math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2)\n                - _SPEED_OF_LIGHT * (stt - t)\n                for p, stt in satellite_positions_and_signal_transit_times\n            ]\n        )\n\n    def drop_satellite(self, satellite_id: SatelliteId) -> None:\n        \"\"\"Remove a satellite from the world model.\n\n        This is called when we lose lock on a satellite.\n        \"\"\"\n\n        if satellite_id in self._pending_satellite_parameters:\n            del self._pending_satellite_parameters[satellite_id]\n\n        if satellite_id in self._satellite_parameters:\n            del self._satellite_parameters[satellite_id]\n\n    def handle_prns_tracked(\n        self,\n        count: int,\n        satellite_id: SatelliteId,\n        side: Side,\n        trailing_edge_timestamp: UtcTimestamp,\n    ) -> None:\n        \"\"\"Handle the tracking of one or more PRN codes.\n\n        This required to accurately track time between subframes.\n\n        ``count`` is the number of trailing edges of PRN codes that were\n        observed. This will typically be 1, but may also be 0 or 2 if a\n        satellite's signal is Doppler shifted as this causes its PRN codes to\n        stretch or shrink in time, changing the number of chips per millisecond.\n\n        ``side`` designates which PRN code within the 1 ms chunk of samples\n        is dominant. This is required to determine if we've seen the subframe's\n        trailing edge when initialising ``SatelliteParameters`` as that affects\n        the initial ``prn_count`` and thus the timing. See ``Tracker._side``.\n\n        ``trailing_edge_timestamp`` is the timestamp of the trailing edge of the\n        most recently observed PRN code, in receiver time.\n        \"\"\"\n\n        if satellite_id in self._satellite_parameters:\n            sp = self._satellite_parameters[satellite_id]\n            sp.prn_code_trailing_edge_timestamp = trailing_edge_timestamp\n            sp.prn_count += count\n        elif satellite_id not in self._pending_satellite_parameters:\n            self._pending_satellite_parameters[satellite_id] = (\n                PendingSatelliteParameters()\n            )\n\n        if satellite_id in self._pending_satellite_parameters:\n            # We don't need to track PRN counts for pending parameters because\n            # they're always promoted on or 1 ms before the trailing edge of a\n            # subframe, i.e. the PRN count is set to either 0 or -1. These two\n            # cases are distinguished by the ``side`` parameter.\n            #\n            # There may be a rare scenario where a satellite has a positive\n            # Doppler shift and we happen to see two PRN code trailing edges in\n            # the 1 ms period when we receive the trailing edge of the last\n            # subframe we need. If that happens the PRN count will permanently\n            # be off by 1, i.e. the pseudorange will be off by ~300 km. That\n            # seems pretty unlikely though so I'm not going to bother with it.\n            psp = self._pending_satellite_parameters[satellite_id]\n            psp.prn_code_trailing_edge_timestamp = trailing_edge_timestamp\n            psp.side = side\n            self._maybe_promote_pending_satellite_parameters(satellite_id)\n\n    def handle_subframe(self, satellite_id: SatelliteId, subframe: Subframe) -> None:\n        \"\"\"Handle a subframe decoded from a satellite's signal.\"\"\"\n\n        if satellite_id in self._satellite_parameters:\n            self._satellite_parameters[satellite_id].handle_subframe(subframe)\n        elif satellite_id not in self._pending_satellite_parameters:\n            logger.info(f\"[{satellite_id}] Created pending parameters\")\n            self._pending_satellite_parameters[satellite_id] = (\n                PendingSatelliteParameters()\n            )\n\n        if satellite_id in self._pending_satellite_parameters:\n            self._pending_satellite_parameters[satellite_id].handle_subframe(subframe)\n            self._maybe_promote_pending_satellite_parameters(satellite_id)\n\n    def _maybe_promote_pending_satellite_parameters(\n        self, satellite_id: SatelliteId\n    ) -> None:\n        \"\"\"Promote ``PendingSatelliteParameters`` to ``SatelliteParameters`` if\n        all the required information is present.\"\"\"\n\n        invariant(\n            satellite_id in self._pending_satellite_parameters,\n            \"PendingSatelliteParameters not present\",\n        )\n        invariant(\n            satellite_id not in self._satellite_parameters,\n            \"SatelliteParameters already present\",\n        )\n\n        sp = self._pending_satellite_parameters[satellite_id].to_satellite_parameters()\n        if sp is not None:\n            logger.info(f\"[{satellite_id}] Promoted pending parameters\")\n            self._satellite_parameters[satellite_id] = sp\n            del self._pending_satellite_parameters[satellite_id]\n"
  },
  {
    "path": "gpsreceiver/makefile",
    "content": "SHELL := bash -eu\n\n.PHONY: default\ndefault:\n\t@echo \"Specify a target\"\n\n.PHONY: clean\nclean:\n\trm -fr .mypy_cache\n\tfind gpsreceiver -name __pycache__ -exec rm -fr {} \\; -prune\n\n.PHONY: format\nformat:\n\tisort --profile black gpsreceiver\n\tblack gpsreceiver\n\n.PHONY: type_check\ntype_check:\n\tmypy gpsreceiver\n"
  },
  {
    "path": "gpsreceiver/mypy.ini",
    "content": "[mypy]\nplugins = pydantic.mypy\n\ndisallow_any_explicit = True\ndisallow_subclassing_any = True\ndisallow_incomplete_defs = True\ndisallow_untyped_calls = True\ndisallow_untyped_decorators = True\ndisallow_untyped_defs = True\nenable_incomplete_feature = NewGenericSyntax\nstrict_equality = True\nwarn_no_return = True\nwarn_redundant_casts = True\nwarn_unreachable = True\nwarn_unused_ignores = True\n\n[mypy-rtlsdr.*]\nignore_missing_imports = True\n\n[pydantic-mypy]\ninit_forbid_extra = True\ninit_typed = True"
  },
  {
    "path": "gpsreceiver/requirements.txt",
    "content": "# Development\nblack==24.10.0\nisort==5.13.2\nmypy==1.13.0\n\n# Runtime\naiohttp==3.11.11\nnumpy==2.1.2\npydantic==2.10.5\npyrtlsdr==0.3.0\npyrtlsdrlib==0.0.3\nsetuptools==75.6.0 # Required by pyrtlsdr"
  },
  {
    "path": "presentation/.gitignore",
    "content": "*.aux\n*.fdb_latexmk\n*.fls\n*.log\n*.nav\n*.out\n*.snm\n*.synctex.gz\n*.toc\n*.vrb"
  },
  {
    "path": "presentation/1 introduction/presentation.tex",
    "content": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\usepackage{siunitx}\n\\usepackage{tikz}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 1: Introduction}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n{\n    \\usebackgroundtemplate{\\includegraphics[width=\\paperwidth]{1 bartosz.png}}\n    \\begin{frame}[b,plain]\n        \\colorbox{white}{https://ciechanow.ski/gps/}\n        \\vspace{0.3cm}\n    \\end{frame}\n}\n\n{\n    \\usebackgroundtemplate{\\includegraphics[width=\\paperwidth]{2 phillip.png}}\n    \\begin{frame}[b,plain]\n        \\colorbox{black}{\\color{white}{https://axleos.com/building-a-gps-receiver-part-1-hearing-whispers/}}\n        \\vspace{0.3cm}\n    \\end{frame}\n}\n\n\\begin{frame}\n    \\frametitle{GPS receiver}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 3 / 5]{9 setup.jpg}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Dashboard}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 11 / 20]{10 dashboard.png}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Overview of GPS}\n\n    \\centering\n    \\only<1>{\\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\\textwidth * 35 / 100]{3 constellation.pdf}}%\n    \\only<2>{\\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\\textwidth * 35 / 100]{4 one satellite.pdf}}%\n    \\only<3-5>{\\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\\textwidth * 35 / 100]{5 one satellite moved.pdf}}%\n    \\only<6>{\\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\\textwidth * 35 / 100]{6 one satellite signal sphere.pdf}}%\n    \\only<7->{\\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\\textwidth * 35 / 100]{7 four satellites signal sphere.pdf}}%\n    \\onslide<4->{\n        \\begin{align*}\n            T &= t_\\text{received} - t_\\text{transmitted}\n            \\onslide<5->{\\\\ d &= c T}\n        \\end{align*}\n    }\n    \\onslide<8>{\n        \\begin{tikzpicture}[remember picture, overlay]\n            \\node[shift={(0cm, 1.85cm)}] at (current page.center) {\n                \\includegraphics[width=1cm]{8 pin.png}\n            };\n        \\end{tikzpicture}\n    }\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Overview of series}\n\n    \\begin{itemize}\n        \\item<2-> Theory\n        \n        \\begin{enumerate}\n            \\setcounter{enumi}{1}\n\n            \\item<3-> Correlation\n           \n            \\item<4-> GPS signals\n        \\end{enumerate}\n\n        \\item<5-> Stages\n        \n        \\begin{enumerate}\n            \\setcounter{enumi}{3}\n\n            \\item<6-> Sampling\n            \n            \\item<7-> Acquisition\n            \n            \\item<8-> Tracking\n            \n            \\item<9-> Decoding\n            \n            \\item<10-> Solving\n        \\end{enumerate}\n    \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/2 correlation/presentation.tex",
    "content": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 2: Correlation}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents\n\\end{frame}\n\n\\section{Correlation}\n\n\\begin{frame}\n    \\frametitle{Positive correlation}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 3 / 4]{1 positive.pdf}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Negative correlation}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 3 / 4]{2 negative.pdf}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Near-zero correlation}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 3 / 4]{3 zero.pdf}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Definition}\n\n  The correlation of two signals $f(t)$ and $g(t)$ is defined as \\[\\int_{-\\infty}^{+\\infty} f(t) g(t) \\,dt.\\]\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{$f(t) \\rightarrow 0$ as $|t| \\rightarrow \\infty$}\n\n  \\centering\n  \\includegraphics[width=\\textwidth * 3 / 4]{4 square integrable.pdf}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Periodic signals}\n\n  \\centering\n  \\only<1>{\\includegraphics[width=\\textwidth * 3 / 4]{5 periodic.pdf}}%\n  \\only<2>{\\includegraphics[width=\\textwidth * 3 / 4]{6 periodic with bounds.pdf}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Definition}\n  \n  The correlation of two periodic signals $f(t)$ and $g(t)$ is defined as \\[\\int_{t_0}^{t_0 + T} f(t) g(t) \\,dt\\] where $t_0$ is an arbitrary point in time and $T$ is their shared period.\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Multiple periods}\n\n  \\centering\n  \\includegraphics[width=\\textwidth / 2]{7 multiple periods.pdf}\n\n  \\onslide<2->{\\[\\int_{t_0}^{t_0 + n T} f(t) g(t) \\,d t = n \\int_{t_0}^{t_0 + T} f(t) g(t) \\,dt\\]}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Definition vs. intuition}\n\n  \\[\\int_{-\\infty}^{+\\infty} f(t) g(t) \\,dt\\]\n\n  \\begin{itemize}\n    \\item<2-> Same $\\Rightarrow$ same signs $\\Rightarrow$ positive products $\\Rightarrow$ positive sum\n    \\item<3-> Opposite $\\Rightarrow$ opposite signs $\\Rightarrow$ negative products $\\Rightarrow$ negative sum\n    \\item<4-> Not similar at all $\\Rightarrow$ positive and negative products $\\Rightarrow$ sums cancel\n  \\end{itemize}\n\\end{frame}\n\n\\section{Cross-correlation}\n\n\\section{Autocorrelation}\n\n\\begin{frame}\n  \\frametitle{Conclusion}\n\n  \\begin{itemize}\n    \\item<2-> Intuition of correlation\n    \n      \\begin{itemize}\n        \\item Almost the same $\\Rightarrow$ positive correlation\n        \n        \\item Almost opposites $\\Rightarrow$ negative correlation\n        \n        \\item Not similar at all $\\Rightarrow$ near-zero correlation\n      \\end{itemize}\n        \n    \\item<3-> Definition of correlation\n    \n      \\begin{itemize}\n        \\item $\\int_{-\\infty}^{+\\infty} f(t) g(t) \\,dt$\n        \n        \\item $\\int_{t_0}^{t_0 + T} f(t) g(t) \\,dt$\n      \\end{itemize}\n    \n    \\item<4-> Cross-correlation\n    \n      \\begin{itemize}\n        \\item The correlation of two signals at different shifts\n      \\end{itemize}\n    \n    \\item<5-> Autocorrelation\n    \n      \\begin{itemize}\n        \\item The cross-correlation of a signal with itself\n      \\end{itemize}\n  \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/3 GPS signals/presentation.tex",
    "content": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 3: GPS signals}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents\n\\end{frame}\n\n\\section{The C/A signal}\n\n\\begin{frame}\n    \\frametitle{GPS frequencies}\n\n    \\centering\n    \\only<1>{\\includegraphics[width=\\textwidth / 2]{1 gps frequencies.png}}%\n    \\only<2>{\\includegraphics[width=\\textwidth / 2]{2 gps frequencies.png}}%\n    \\only<3>{\\includegraphics[width=\\textwidth / 2]{3 gps frequencies.png}}%\n    \\only<4>{\\includegraphics[width=\\textwidth / 2]{4 gps frequencies.png}}%\n    \\only<5>{\\includegraphics[width=\\textwidth / 2]{5 gps frequencies.png}}%\n    \\only<6>{\\includegraphics[width=\\textwidth / 2]{6 gps frequencies.png}}%\n    \\\\\n    \\texttt{\\tiny{Source: \"GPS signals\" from Wikipedia, CC BY-SA 4.0, https://en.wikipedia.org/wiki/GPS\\_signals}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The navigation message}\n\n    \\begin{itemize}\n        \\item<2-> Binary\n        \n        \\item<3-> Transmitted at 50 bps\n        \n        \\item<4-> Contains the information required to calculate satellite location and time\n        \n        \\item<5-> $D_i(t)$ represents the navigation message bit of satellite number $i$ at time $t$\n    \\end{itemize}\n\\end{frame}\n\n\\section{Modulation}\n\n\\begin{frame}\n  \\frametitle{Modulation}\n\n  \\centering\n  \\begin{tabular}{c c}\n    \\onslide<2->{\\includegraphics[width=\\textwidth / 3]{7 carrier.pdf}} & \\onslide<3->{\\includegraphics[width=\\textwidth / 3]{8 modulation.pdf}} \\\\\n    \\onslide<4->{\\includegraphics[width=\\textwidth / 3]{9 am.pdf}}      & \\onslide<5->{\\includegraphics[width=\\textwidth / 3]{10 fm.pdf}}\n  \\end{tabular}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Modulation of the C/A signal}\n\n  \\begin{itemize}\n    \\item<2-> The carrier signal is a radio wave at the L1 frequency, $\\qty{1575.42}{MHz}$\n    \n    \\item<3-> $f_i(t)$ is the amplitude of satellite number $i$'s carrier wave at time $t$\n    \n    \\item<4-> The modulation signal is the navigation message $D_i(t)$, transmitted at $\\qty{50}{bps}$\n  \\end{itemize}\n\n  \\leavevmode \\\\\n\n  \\centering\n  \\onslide<5->{\\includegraphics[width=\\textwidth / 3]{11 phase shift.pdf}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Modulation of the C/A signal}\n\n  \\begin{center}\n    \\huge\n    \\begin{tabular}{c c c c c c c}\n      \\onslide<1->{$D_i =$ & 0 & 1 & 0 & 0 & 1 & 1 \\\\}\n      & \\onslide<2>{$\\downarrow$} & \\onslide<3>{$\\downarrow$} & \\onslide<2>{$\\downarrow$} & \\onslide<2>{$\\downarrow$} & \\onslide<3>{$\\downarrow$} & \\onslide<3>{$\\downarrow$} \\\\\n      \\onslide<4->{$\\hat{D}_i =$} & \\onslide<2->{1} & \\onslide<3->{-1} & \\onslide<2->{1} & \\onslide<2->{1} & \\onslide<3->{-1} & \\onslide<3->{-1} \\\\\n    \\end{tabular}\n\n    \\leavevmode \\newline\n\n    \\onslide<5->{$\\hat{D}_i(t) f_i(t)$}\n  \\end{center}\n\\end{frame}\n\n\\section{CDMA}\n\n\\begin{frame}\n  \\frametitle{PRN codes}\n\n  \\begin{itemize}\n    \\item<2-> Each satellite is assigned a pseudorandom noise code (PRN code)\n    \n    \\begin{itemize}\n      \\item<3-> Binary\n      \n      \\item<4-> 1023 bits long\n    \\end{itemize}\n  \\end{itemize}\n\n  \\leavevmode \\newline\n\n  \\onslide<5->{\\includegraphics[width=\\textwidth]{12 prn code.pdf}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{PRN codes}\n\n  \\begin{itemize}\n    \\item<2-> Satellites modulate $D_i(t) \\oplus PRN_i(t)$\n    \n    \\item<3-> $PRN_i(t)$ is the bit of satellite number $i$'s PRN code at time $t$\n    \n    \\item<4-> Transmitted at $\\qty{1.023}{Mbps}$\n      \n    \\item<4-> Full code repeats once per millisecond, 20 times per navigation message bit\n  \\end{itemize}\n\n  \\onslide<5->{\n    \\begin{center}\n      $0 \\rightarrow 1$, $1 \\rightarrow -1$\n\n      \\vspace{1em}\n\n      \\begin{tabular}{c c}\n        \\begin{tabular}{|c|c|c|}\n          \\hline\n          $\\oplus$ & 0 & 1 \\\\\n          \\hline\n          0 & 0 & 1 \\\\\n          \\hline\n          1 & 1 & 0 \\\\\n          \\hline\n        \\end{tabular}\n\n        \\begin{tabular}{|c|c|c|}\n          \\hline\n          $\\times$ & 1 & -1 \\\\\n          \\hline\n          1 & 1 & -1 \\\\\n          \\hline\n          -1 & -1 & 1 \\\\\n          \\hline\n        \\end{tabular}\n      \\end{tabular}\n    \\end{center}\n  }\n\n  \\begin{itemize}\n    \\item<6-> The signal transmitted by a satellite is $\\hat{D}_i(t) \\hat{PRN}_i(t) f_i(t)$\n  \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{The properties of PRN codes}\n\n  \\begin{itemize}\n    \\item<2-> Strong, positive correlation with an aligned version of itself\n    \n    \\item<3-> Near-zero correlation with misaligned versions of itself\n  \\end{itemize}\n\n  \\vspace{0.5em}\n\n  \\centering\n  \\onslide<4->{\\includegraphics[width=\\textwidth * 3 / 4]{13 autocorrelation.pdf}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{The properties of PRN codes}\n\n  \\begin{itemize}\n    \\item<2-> The correlation of two different PRN codes is always near zero\n  \\end{itemize}\n\n  \\vspace{0.5em}\n\n  \\centering\n  \\onslide<3->{\\includegraphics[width=\\textwidth * 3 / 4]{14 cross-correlation.pdf}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Decoding a bit from a satellite}\n\n  \\centering\n  \\Large\n\n  \\only<2>{\\[\\hat{D}_i(t) \\hat{PRN}_i(t) f_i(t)\\]}\n  \\only<3>{\\[\\hat{D}_1(t) \\hat{PRN}_1(t) f_1(t) + \\hat{D}_2(t) \\hat{PRN}_2(t) f_2(t)\\]}\n  \\only<4>{\\[\\hat{D}_1(t) \\hat{PRN}_1(t) f_1(t) + \\hat{D}_2(t) \\hat{PRN}_2(t) f_2(t) + \\textcolor{red}{N_1(t)}\\]}\n  \\only<5>{\\[\\hat{D}_1(t) \\hat{PRN}_1(t) \\textcolor{red}{f_1(t)} + \\hat{D}_2(t) \\hat{PRN}_2(t) \\textcolor{red}{f_2(t)} + N_1(t)\\]}\n  \\only<6>{\\[\\textcolor{red}{c_1} \\hat{D}_1(t) \\hat{PRN}_1(t) + \\textcolor{red}{c_2} \\hat{D}_2(t) \\hat{PRN}_2(t) + \\textcolor{red}{N_2(t)}\\]}\n  \\only<7>{\\[\\textcolor{red}{\\int_0^T [} c_1 \\hat{D}_1(t) \\hat{PRN}_1(t) + c_2 \\hat{D}_2(t) \\hat{PRN}_2(t) + N_2(t) \\textcolor{red}{] \\hat{PRN}_1(t) \\,dt}\\]}\n  \\only<8>{\\[\\int_0^T [c_1 \\hat{D}_1(t) \\hat{PRN}_1(t) + c_2 \\hat{D}_2(t) \\hat{PRN}_2(t) + N_2(t)] \\hat{PRN}_1(t \\begingroup \\color{red} - \\tau \\endgroup) \\,dt\\]}\n  \\only<9>{\n    \\begin{align*}\n      & \\int_0^T c_1 \\hat{D}_1(t) \\hat{PRN}_1(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + \\int_0^T c_2 \\hat{D}_2(t) \\hat{PRN}_2(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + \\int_0^T N_2(t) \\hat{PRN}_1(t - \\tau) \\,d t\n    \\end{align*}\n  }\n  \\only<10>{\n    \\begin{align*}\n      & \\textcolor{red}{c_1} \\int_0^T \\hat{D}_1(t) \\hat{PRN}_1(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + \\textcolor{red}{c_2} \\int_0^T \\hat{D}_2(t) \\hat{PRN}_2(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + \\int_0^T N_2(t) \\hat{PRN}_1(t - \\tau) \\,d t\n    \\end{align*}\n  }\n  \\only<11>{\n    \\begin{align*}\n      & c_1 \\textcolor{red}{\\hat{D}_1(0)} \\int_0^T \\hat{PRN}_1(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + c_2 \\textcolor{red}{\\hat{D}_2(0)} \\int_0^T \\hat{PRN}_2(t) \\hat{PRN}_1(t - \\tau) \\,d t \\\\\n      & \\qquad + \\int_0^T N_2(t) \\hat{PRN}_1(t - \\tau) \\,d t\n    \\end{align*}\n  }\n  \\only<12>{\\[c_1 \\hat{D}_1(0) \\int_0^T \\hat{PRN}_1(t) \\hat{PRN}_1(t - \\tau) \\,d t + \\int_0^T N_2(t) \\hat{PRN}_1(t - \\tau) \\,d t\\]}\n  \\only<13>{\\[c_1 \\hat{D}_1(0) \\int_0^T \\hat{PRN}_1(t) \\hat{PRN}_1(t) \\,d t + \\int_0^T N_2(t) \\hat{PRN}_1(t) \\,d t\\]}\n  \\only<14>{\\[\\textcolor{red}{\\alpha} \\hat{D}_1(0) + \\int_0^T N_2(t) \\hat{PRN}_1(t) \\,d t\\]}\n  \\only<15>{\\[\\alpha \\hat{D}_1(0) + \\textcolor{red}{\\beta}\\]}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Recap}\n\n  \\begin{itemize}\n    \\item<2-> We'll use the C/A signal on the L1 frequency\n    \n    \\item<3-> The C/A signal contains the navigation message\n    \n    \\item<4-> The navigation message lets us calculate a satellite's location and time\n    \n    \\item<5-> Each satellite is assigned a PRN code\n    \n    \\item<6-> The PRN code is XOR-ed with the navigation message and repeats once per ms\n    \n    \\item<7-> To decode the navigation message bit of satellite number $i$:\n    \n    \\begin{itemize}\n      \\item<8-> Record the received signal for $\\qty{1}{ms}$\n      \n      \\item<9-> Calculate its correlation with an aligned copy of satellite number $i$'s PRN code\n    \\end{itemize}\n  \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/4 sampling/presentation.tex",
    "content": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 4: Sampling}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents\n\\end{frame}\n\n\\section{Hardware}\n\n\\begin{frame}\n    \\frametitle{Hardware}\n\n    \\begin{itemize}\n        \\item Software defined radio (SDR) dongle\n    \\end{itemize}\n\n    \\leavevmode \\\\\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 4 / 15]{1 hardware.jpg}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Hardware}\n\n    \\centering\n    \\includegraphics[width=\\textwidth / 2]{2 GPS antenna.jpg}\n\\end{frame}\n\n\\section{Parameters}\n\n\\subsection{Centre frequency}\n\n\\begin{frame}\n    \\frametitle{Centre frequency}\n\n    \\Large\n    \\[f = \\qty{1575.42}{MHz}\\]\n\\end{frame}\n\n\\subsection{Bandwidth}\n\n\\begin{frame}\n    \\frametitle{The power spectrum of BPSK}\n\n    \\centering\n    \\only<1>{\\includegraphics{3 BPSK PSD.pdf}}%\n    \\only<2>{\\includegraphics{4 BPSK PSD main lobe.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Bandwidth}\n\n    \\Large\n    \\[B = \\qty{2.046}{MHz}\\]\n\\end{frame}\n\n\\subsection{Sampling rate}\n\n\\begin{frame}\n    \\frametitle{Sampling rate}\n\n    \\Large\n    \\[f_{L1} = \\qty{1575.42}{Mhz} \\approx \\qty{1.6}{GHz}\\]\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The Nyquist-Shannon sampling theorem}\n\n    \\centering\n    \\Large\n    If the maximum frequency contained within a signal is $f_\\text{max}$,\\\\then the signal can be determined from its samples\\\\if the sampling rate is greater than $2 f_\\text{max}$.\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Sampling rate}\n\n    \\Large\n    \\begin{align*}\n    f_\\text{max} &= f + \\frac{B}{2} \\\\\n    \\onslide<2->{&= \\qty{1575.42}{MHz} + \\frac{\\qty{2.046}{MHz}}{2} \\\\}\n    \\onslide<3->{&= \\qty{1576.443}{MHz} \\\\}\n    \\onslide<3->{& \\approx \\qty{1.6}{GHz} \\\\}\n    \\onslide<4->{f_s &= 2 f_\\text{max} \\\\}\n    \\onslide<4->{& \\approx \\qty{3.2}{GHz}}\n    \\end{align*}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Sampling rate}\n\n    \\centering\n    \\includegraphics[width=\\textwidth]{5 RTL-SDR maximum sampling rate.png}\n    \\texttt{\\tiny{https://www.rtl-sdr.com/about-rtl-sdr/}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Sampling rate}\n\n    {\\Large \\[f_s = \\qty{2.046}{MHz}\\]}\n\n    \\leavevmode \\\\\n\n    \\begin{itemize}\n        \\item<2-> Large enough that aliases don't overlap\n        \n        \\item<3-> $1 / 770$ of the L1 frequency $\\Rightarrow$ alias at $\\qty{0}{Hz}$\n        \n        \\item<4-> $f_\\text{max} = \\qty{1.023}{MHz} \\Rightarrow f_\\text{Nyquist} = \\qty{2.046}{MHz} \\Rightarrow$ within SDR dongle's capabilities\n    \\end{itemize}\n\\end{frame}\n\n\\section{I/Q samples}\n\n\\subsection{Definition}\n\n\\begin{frame}\n    \\frametitle{The definition of I/Q samples}\n\n    \\Large\n    \\only<1-3>{\n        \\only<1-2>{\\[f(t) = A(t) \\cos(2 \\pi f t + \\phi(t))\\]}\n        \\only<3->{\\[f(t) = A(t) \\, \\textcolor{red}{\\cos(2 \\pi f t + \\phi(t))}\\]}\n        \\onslide<2->{\\[\\cos(\\alpha + \\beta) = \\cos(\\alpha) \\cos(\\beta) - \\sin(\\alpha) \\sin(\\beta)\\]}\n    }\n    \\only<4-6>{\n        \\only<4-5>{\\[f(t) = A(t) [\\cos(2 \\pi f t) \\cos(\\phi(t)) \\, \\textcolor{black}{- \\sin(2 \\pi f t)} \\sin(\\phi(t))]\\]}\n        \\only<6->{\\[f(t) = A(t) [\\cos(2 \\pi f t) \\cos(\\phi(t)) \\, \\textcolor{red}{- \\sin(2 \\pi f t)} \\sin(\\phi(t))]\\]}\n        \\onslide<5->{\\[-\\sin(\\theta) = \\cos(\\theta + \\pi / 2)\\]}\n    }\n    \\only<7->{\n        \\begin{align*}\n            f(t) &= A(t) [\\cos(2 \\pi f t) \\cos(\\phi(t)) + \\cos(2 \\pi f t + \\pi / 2) \\sin(\\phi(t))] \\\\\n            \\onslide<8->{&= A(t) \\cos(\\phi(t)) \\cos(2 \\pi f t) + A(t) \\sin(\\phi(t)) \\cos(2 \\pi f t + \\pi / 2) \\\\}\n            \\onslide<9->{&= I(t) \\cos(2 \\pi f t) + Q(t) \\cos(2 \\pi f t + \\pi / 2)}\n        \\end{align*}\n\n        \\onslide<9->{where $I(t) = A(t) \\cos(\\phi(t))$ and $Q(t) = A(t) \\sin(\\phi(t))$.}\n    }\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Determining a signal's phase}\n\n    \\Large\n    \\begin{align*}\n        Q / I &= \\onslide<2->{\\frac{A(t) \\sin(\\phi(t))}{A(t) \\cos(\\phi(t))}} \\\\\n        \\onslide<3->{&= \\tan(\\phi(t))} \\\\\n        \\onslide<4->{\\phi(t) &= \\arctan(Q / I)}\n    \\end{align*}\n\\end{frame}\n\n\\subsection{Different frequencies}\n\n\\begin{frame}\n    \\frametitle{Sampling a signal of a different frequency}\n\n    \\Large\n    \\begin{align*}\n        \\onslide<2->{f_1 & \\\\}\n        \\onslide<3->{f_2 & = f_1 + \\Delta f} \\\\\n        \\onslide<4->{f(t) &= A \\cos (2 \\pi f_2 t) \\\\}\n        \\onslide<5->{&= A \\cos (2 \\pi (f_1 + \\Delta f) t) \\\\}\n        \\onslide<5->{&= A \\cos (2 \\pi f_1 t + 2 \\pi \\Delta f t) \\\\}\n        \\onslide<6->{&= A \\cos (2 \\pi f_1 t + \\phi(t))}\n    \\end{align*}\n\n    \\onslide<6->{where $\\phi(t) = 2 \\pi \\Delta f t$.}\n\\end{frame}\n\n\\subsection{Complex values}\n\n\\begin{frame}\n    \\frametitle{Complex I/Q samples}\n\n    \\begin{itemize}\n        \\item<1-> $I + j Q$\n        \n        \\item<2-> Bandpass sampling\n        \n        \\begin{itemize}\n            \\item<3-> Real-valued samples $\\Rightarrow$ carrier wave replaced with a real-valued constant\n        \n            \\item<4-> Complex-valued samples $\\Rightarrow$ carrier wave replaced with a complex-valued constant\n            \n            \\item<5-> Using Euler's formula $z = A e^{j \\phi}$ where $A$ is the carrier wave's amplitude and $\\phi$ is its phase\n        \\end{itemize}\n\n        \\onslide<6->{\n            \\begin{align*}\n                \\onslide<6->{\\hat{D}_i(t) \\hat{PRN}_i(t) &= \\pm 1 \\\\}\n                \\onslide<7->{z \\hat{D}_i(t) \\hat{PRN}_i(t) &= z (\\pm 1) \\\\}\n                \\onslide<7->{&= A e^{j \\phi} (\\pm 1) \\\\}\n                \\onslide<7->{&= \\pm A e^{j \\phi}}\n            \\end{align*}\n        }\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Recap}\n\n    \\begin{itemize}\n        \\item<2-> Hardware\n        \n        \\begin{itemize}\n            \\item GPS antenna\n            \n            \\item SDR dongle\n        \\end{itemize}\n\n        \\item<3-> Parameters\n        \n        \\begin{itemize}\n            \\item Central frequency $f = \\qty{1575.42}{MHz}$\n            \n            \\item Bandwidth $B = \\qty{2.046}{MHz}$\n            \n            \\item Sampling rate $f_s = \\qty{2.046}{MHz}$\n        \\end{itemize}\n\n        \\item<4-> I/Q samples\n        \n        \\begin{itemize}\n            \\item<5-> Pairs of numbers that describe the signal\n\n            \\item<6-> Often expressed as a single complex number\n            \n            \\item<7-> If the signal has a different frequency, the I/Q samples will continually rotate\n        \\end{itemize}\n    \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/5 acquisition/presentation.tex",
    "content": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 5: Acquisition}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsubsection\n\\AtBeginSubsubsection[]\n{\n    \\begin{frame}\n        \\frametitle{Topics}\n        \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=show/shaded]\n    \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents[subsubsectionstyle=hide]\n\\end{frame}\n\n\\section{Parameters}\n\n\\subsection{PRN code phase}\n\n\\begin{frame}\n    \\frametitle{PRN code phase}\n\n    \\begin{itemize}\n        \\item To decode satellite $i$'s navigation message bit, calculate \\[\\int_{t_0}^{t_0 + 0.001} r(t) \\hat{PRN}_i(t) \\,d t\\] where $t_0$ is a point in time and $r(t)$ is the received signal\n        \n        \\begin{itemize}\n            \\item<2-> PRN codes aligned $\\Rightarrow$ navigation message bit\n            \n            \\item<3-> PRN codes misaligned $\\Rightarrow$ noise\n        \\end{itemize}\n\n        \\item<4-> In order to align them, we need to know the phase\n    \\end{itemize}\n\\end{frame}\n\n\\subsection{Carrier wave frequency shift}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=show]\n\\end{frame}\n\n\\subsubsection{Modulation signal, misaligned PRN code}\n\\subsubsection{Modulation signal, aligned PRN code}\n\\subsubsection{Modulated signal, not frequency shifted}\n\n\\begin{frame}\n    \\frametitle{Modulated signal, not frequency shifted}\n\n    \\begin{itemize}\n        \\item $\\hat{D}_i(t) \\hat{PRN}_i(t) f(t)$\n        \n        \\item<2-> Bandpass sampling results in $f(t) \\Rightarrow A e^{j \\phi}$\n        \n        \\item<3-> Samples will be equal to $\\pm A e^{j \\phi}$\n    \\end{itemize}\n\\end{frame}\n\n\\subsubsection{Modulated signal, frequency shifted}\n\n\\begin{frame}\n    \\frametitle{Modulated signal, frequency shifted}\n\n    \\begin{itemize}\n        \\item $\\hat{D}_i(t) \\hat{PRN}_i(t) g(t)$\n        \n        \\item<2-> A frequency shifted signal $\\Leftrightarrow$ a signal with a constantly changing phase\n        \n        \\item<3-> Bandpass sampling results in $g(t) \\Rightarrow A e^{j (2 \\pi \\Delta f t + \\phi)} = A e^{j \\phi} e^{j 2 \\pi \\Delta f t}$\n        \n        \\item<4-> Samples will be equal to $\\pm A e^{j \\phi} e^{j 2 \\pi \\Delta f t}$\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Recovering the signal}\n\n    \\begin{itemize}\n        \\item<2-> Undo the rotation by multiplying each sample by $e^{-j 2 \\pi \\Delta f t}$\n        \n        \\item<3-> This is called carrier wipeoff and it's why we need to know $\\Delta f$\n    \\end{itemize}\n\\end{frame}\n\n\\section{Finding parameters}\n\n\\begin{frame}\n    \\frametitle{Finding parameters}\n\n    \\centering\n    \\only<1>{\\includegraphics[width=\\textwidth * 3 / 4]{1 acquisition space axes.pdf}}%\n    \\only<2>{\\includegraphics[width=\\textwidth * 3 / 4]{2 acquisition space.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Finding parameters}\n\n    \\begin{itemize}\n        \\item<2-> We want to calculate the correlation over a $\\qty{10}{ms}$ period\n        \n        \\item<3-> There's a higher chance that the navigation message bit will change\n        \n        \\item<4-> If it does, some samples will cancel each other out $\\Rightarrow$ smaller correlation\n        \n        \\item<5-> Calculate correlation over $10 \\times \\qty{1}{ms}$ periods, add their magnitudes\n        \n        \\item<6-> This is called non-coherent integration\n    \\end{itemize}\n\\end{frame}\n\n\\section{Parameter space}\n\n\\begin{frame}\n    \\frametitle{PRN code phase}\n\n    \\begin{itemize}\n        \\item $f_s = \\qty{2.046}{MHz} \\Rightarrow \\qty{2046}{samples/ms}$\n        \n        \\item<2-> PRN code is 1023 bits long, repeats once per $\\unit{ms}$\n        \n        \\item<3-> Upsample the PRN code to be 2046 half-bits long\n        \n        \\item<4-> There are 2046 phases to check\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Carrier frequency shift}\n\n    \\begin{itemize}\n        \\item<2-> $\\Delta f$ due to satellite motion $\\pm \\qty{4.9}{kHz}$\n        \n        \\item<3-> $\\Delta f$ due to Earth's rotation $\\pm \\qty{2.4}{kHz}$\n        \n        \\item<4-> $\\Delta f$ due to receiver motion $\\pm \\qty{150}{Hz}$\n        \n        \\item<5-> $\\Delta t_\\text{total} \\approx \\pm \\qty{7.5}{kHz}$\n    \\end{itemize}\n\\end{frame}\n\n\\section{Determining presence}\n\n\\begin{frame}\n    \\frametitle{Determining presence}\n\n    \\begin{enumerate}\n        \\item On startup, find the best parameters and signal strength for every satellite\n        \n        \\item<2-> Compare the signal strength with a threshold\n        \n        \\item<3-> If it's greater $\\Rightarrow$ present\n        \n        \\item<4-> If it's smaller $\\Rightarrow$ absent\n\n        \\item<5-> Periodically try to acquire satellites we're not tracking\n    \\end{enumerate}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Recap}\n\n    \\begin{itemize}\n        \\item<2-> Two required parameters\n        \n        \\begin{itemize}\n            \\item<3-> PRN code phase $\\Rightarrow$ align the PRN codes\n            \n            \\item<4-> Carrier frequency shift $\\Rightarrow$ undo the effects of frequency shift (carrier wipeoff)\n        \\end{itemize}\n\n        \\item<5-> These parameters are found by brute force\n        \n        \\item<6-> Size of the parameter space\n        \n        \\begin{itemize}\n            \\item<7-> PRN code phase $\\Rightarrow [0, 2046)$\n            \n            \\item<8-> Carrier frequency shift $\\Rightarrow \\pm \\qty{7.5}{kHz}$\n        \\end{itemize}\n\n        \\item<9-> Determining presence\n\n        \\begin{enumerate}\n            \\item<10-> Calculate the best parameters and signal strength\n            \n            \\item<11-> Compare the signal strength against a threshold\n            \n            \\item<12-> If above the threshold $\\Rightarrow$ present\n            \n            \\item<13-> If below the threshold $\\Rightarrow$ absent, check again later\n        \\end{enumerate}\n    \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/6 tracking/presentation.tex",
    "content": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\usepackage{siunitx}\n\\usepackage{xcolor}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 6: Tracking}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Goals}\n\n    \\begin{itemize}\n        \\item<2-> Track signal parameters over time\n        \n        \\begin{itemize}\n            \\item<3-> Carrier wave frequency shift\n            \n            \\item<4-> Carrier wave phase\n            \n            \\item<5-> PRN code phase\n        \\end{itemize}\n\n        \\item<6-> Decode fragments of navigation message bits\n        \n        \\begin{itemize}\n            \\item<7-> $\\qty{1}{ms}$ samples $\\Leftrightarrow$ 1 PRN code $\\Leftrightarrow$ $1/20$ navigation message bit\n        \\end{itemize}\n\n        \\item<8-> Count PRN codes\n        \n        \\begin{itemize}\n            \\item<9-> Required to calculate the signal transmission time\n        \\end{itemize}\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Topics}\n\n    \\tableofcontents\n\\end{frame}\n\n\\section{Carrier wipeoff}\n\n\\begin{frame}\n    \\frametitle{Carrier wipeoff}\n\n    \\centering\n    \\raisebox{0.66cm}{\\includegraphics[width=\\textwidth * 2 / 5]{1 frequency shifted.pdf} \\raisebox{2.75cm}{\\Large $\\Rightarrow$} \\qquad \\includegraphics[width=\\textwidth * 2 / 5]{2 post carrier wipeoff.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Carrier wipeoff}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 3 / 5]{3 correlations.pdf}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Carrier wipeoff}\n\n    \\centering\n    \\includegraphics[width=\\textwidth / 3]{4 bit ambiguity.pdf}\n\\end{frame}\n\n\\section{PRN code tracking}\n\n\\begin{frame}\n    \\frametitle{Delay-locked loop}\n\n    \\only<1>{\\includegraphics[width=\\textwidth]{5 prompt.pdf}}%\n    \\only<2>{\\includegraphics[width=\\textwidth]{6 prompt early.pdf}}%\n    \\only<3>{\\includegraphics[width=\\textwidth]{7 prompt early late.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Accounting for frequency shift}\n\n    \\only<1>{\\includegraphics[width=\\textwidth]{8 no frequency shift.pdf}}%\n    \\only<2>{\\includegraphics[width=\\textwidth]{9 positive.pdf}}%\n    \\only<3>{\\includegraphics[width=\\textwidth]{10 positive and negative.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Accounting for frequency shift}\n\n    \\centering\n    \\Large\n    \\[\\Delta \\phi = \\overbrace{2046}^{\\mathclap{\\text{PRN length}}} \\times \\underbrace{\\Delta f \\div f_{L1}}_{\\mathclap{\\text{Percentage frequency change}}}\\]\n\\end{frame}\n\n\\section{Correlation}\n\n\\begin{frame}\n    \\frametitle{Correlation}\n\n    \\begin{center}\n        \\only<1>{\\includegraphics[width=\\textwidth / 2]{11 correlations.pdf}}%\n        \\only<2->{\\includegraphics[width=\\textwidth / 2]{12 correlations with regions.pdf}}\n    \\end{center}\n\n    \\begin{itemize}\n        \\item<3-> These fragments are called ``pseudosymbols''\n    \\end{itemize}\n\\end{frame}\n\n\\section{Carrier wave tracking}\n\n\\begin{frame}\n    \\frametitle{Carrier wave tracking}\n\n    \\begin{itemize}\n        \\item Update our estimates of the carrier wave's frequency shift and phase\n        \n        \\item<2-> Use a phase-locked loop (Costas loop)\n        \n        \\begin{itemize}\n            \\item<3-> Calculate a single value that represents the error in both estimates\n\n            \\item<4-> Use it to update them\n        \\end{itemize}\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Carrier wave tracking}\n\n    \\centering\n    \\only<1>{\\includegraphics[width=\\textwidth * 3 / 4]{13 clusters.pdf}}%\n    \\only<2>{\\includegraphics[width=\\textwidth * 3 / 4]{14 cluster angles.pdf}}%\n    \\only<3->{\\includegraphics[width=\\textwidth * 3 / 4]{15 cluster angles 2.pdf}}%\n\n    \\begin{itemize}\n        \\item<4-> Use \\texttt{atan} rather than \\texttt{atan2}\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Carrier wave tracking}\n\n    \\begin{itemize}\n        \\item<2-> Each estimate has an associated gain factor\n        \n        \\item<3-> To update an estimate:\n\n        \\begin{enumerate}\n            \\item<4-> Calculate \\texttt{error $\\times$ gain}\n\n            \\item<5-> Subtract it from the current estimate\n        \\end{enumerate}\n\n        \\item<6-> Determine gain factors experimentally\n        \n        \\item<7-> Phase gain should be around 25 $\\times$ the frequency shift gain\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Recap}\n\n    \\begin{itemize}\n        \\item<2-> Negate rotation of samples caused by frequency shift and phase (carrier wipeoff)\n        \n        \\item<3-> Update PRN code phase using a delay-locked loop\n        \n        \\begin{enumerate}\n            \\item<4-> Generate early and late replicas of the PRN code\n            \n            \\item<5-> Calculate their correlation with the $\\qty{1}{ms}$ of samples\n\n            \\item<6-> Compare the magnitudes of correlations to determine best alignment\n            \n            \\item<7-> Update the estimate\n        \\end{enumerate}\n\n        \\item<8-> Calculate the correlation of the samples and the PRN code $\\Rightarrow$ pseudosymbol\n        \n        \\item<9-> Update carrier wave frequency shift and phase using a phase-locked loop (Costas loop)\n        \n        \\begin{enumerate}\n            \\item<10-> Calculate error as angle of correlation\n\n            \\item<11-> Multiply error by each estimate's gain factor\n\n            \\item<12-> Subtract the result from each estimate\n        \\end{enumerate}\n    \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/7 decoding/presentation.tex",
    "content": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\usepackage{siunitx}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 7: Decoding}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\begin{frame}\n    \\frametitle{Navigation message structure}\n\n    \\centering\n    \\begin{tabular}{ l|r|r }\n        \\textbf{Name} & \\textbf{Number of bits} & \\textbf{Emitted every} \\\\\n        \\hline\n        Pseudosymbol & 1/20 & $\\qty{1}{ms}$ \\onslide<2-> \\\\\n        Pseudobit / bit & 1 & $\\qty{20}{ms}$ \\onslide<3-> \\\\\n        Word & 30 & $\\qty{0.6}{s}$ \\onslide<4-> \\\\\n        Subframe & 300 & $\\qty{6}{s}$ \\onslide<5-> \\\\\n        Frame & 1,500 & $\\qty{30}{s}$\n    \\end{tabular}\n\\end{frame}\n\n\\section{Pseudosymbol integration}\n\n\\begin{frame}\n    \\frametitle{Pseudosymbol integration}\n\n    \\centering\n    \\includegraphics[width=\\textwidth / 2]{1 correlations with regions.pdf}\n\n    \\Large\n    \\onslide<2->{\\hspace{-0.5cm} $-1$ \\hspace{2.75cm} $+1$}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Pseudosymbol integration}\n\n    \\centering\n    \\only<1>{\\includegraphics[width=\\textwidth]{2 pseudosymbols.pdf}}%\n    \\only<2-3>{\\includegraphics[width=\\textwidth]{3 pseudosymbols.pdf}}%\n    \\only<4>{\\includegraphics[width=\\textwidth]{4 pseudosymbols.pdf}}%\n    \\only<5-6>{\\includegraphics[width=\\textwidth]{5 pseudosymbols.pdf}}%\n    \\only<7>{\\includegraphics[width=\\textwidth]{6 pseudosymbols.pdf}}%\n    \\only<8>{\\includegraphics[width=\\textwidth]{2 pseudosymbols.pdf}}%\n    \\only<9>{\\includegraphics[width=\\textwidth]{7 pseudosymbols.pdf}}%\n    \n    \\onslide<3->{\n        \\begin{tabular}{r|r}\n            Offset & Score \\\\\n            \\hline\n            0 & \\onslide<6->{1.0} \\\\\n            1 & \\onslide<8->{2.5} \\\\\n            \\only<-8>{2}\\only<9>{\\textbf{2}} & \\only<7-8>{4.0}\\only<9>{\\textbf{4.0}} \\\\\n            3 & \\onslide<8->{2.6}\n        \\end{tabular}\n    }\n\\end{frame}\n\n\\section{Pseudobit integration}\n\n\\begin{frame}\n    \\frametitle{Pseudobit integration}\n\n    \\centering\n    \\includegraphics[width=\\textwidth]{8 tlm word.png} \\\\\n    \\texttt{\\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\n    \\vspace{0.5cm}\n\n    \\Large\n    \\onslide<2->{\\texttt{-1 +1 +1 +1 -1 +1 -1 -1} $\\Rightarrow$ $-1$ maps to $1$, $+1$ maps to $0$} \\\\\n    \\onslide<3->{\\texttt{+1 -1 -1 -1 +1 -1 +1 +1} $\\Rightarrow$ $+1$ maps to $1$, $-1$ maps to $0$}\n\\end{frame}\n\n\\section{Decoding subframes}\n\n\\begin{frame}\n    \\frametitle{Parity bits}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 6 / 10]{9 parity equations.png} \\\\\n    \\texttt{\\tiny{Source: Table 20-XIV, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The telemetry word}\n\n    \\centering\n    \\includegraphics[width=\\textwidth]{8 tlm word.png} \\\\\n    \\texttt{\\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The handover word}\n\n    \\centering\n    \\includegraphics[width=\\textwidth]{10 handover word.png} \\\\\n    \\texttt{\\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The TOW count}\n\n    \\begin{itemize}\n        \\item GPS started operating at midnight UTC on the night of Saturday January 5, 1980\n        \n        \\item<2-> The number of weeks that have passed since that night is called the GPS week number\n        \n        \\item<3-> The time-of-week count (TOW count) is the number of 1.5 s periods that have elapsed since the start of the current GPS week (since midnight UTC Saturday night)\n        \n        \\item<4-> The TOW count is a 19 bit number\n        \n        \\item<5-> The truncated TOW count is the 17 most significant bits of the TOW count as it will appear at the time the next subframe begins transmission\n        \n        \\item<6-> This corresponds to a $\\qty{6}{s}$ period — how long it takes to transmit a subframe — so the truncated TOW count will increment by 1 with each subframe we receive\n        \n        \\item<7-> We can use this to calculate the signal's transmission time\n    \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{The handover word}\n\n    \\centering\n    \\includegraphics[width=\\textwidth]{10 handover word.png} \\\\\n    \\texttt{\\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Subframes}\n\n    \\begin{itemize}\n        \\item<2-> Subframe 1\n        \n        \\begin{itemize}\n            \\item<3-> Clock parameters\n            \n            \\begin{itemize}\n                \\item<4-> Calculate when signals were transmitted\n                \n                \\item<5-> Correct for atomic clock drift\n            \\end{itemize}\n            \n            \\item<6-> Health information\n        \\end{itemize}\n\n        \\item<7-> Subframes 2 and 3\n        \n        \\begin{itemize}\n            \\item<8-> Orbital parameters\n            \n            \\begin{itemize}\n                \\item<9-> Calculate a satellite's position\n            \\end{itemize}\n        \\end{itemize}\n\n        \\item<10-> Subframes 4 and 5\n        \n        \\begin{itemize}\n            \\item<11-> Parameters change every frame over 25 frames (12.5 minutes)\n            \n            \\begin{itemize}\n                \\item<12-> Other satellites\n                \n                \\item<13-> Earth's atmospheric conditions\n                \n                \\item<14-> Etc.\n            \\end{itemize}\n        \\end{itemize}\n    \\end{itemize}\n\\end{frame}\n\n\\section{Decoding subframe parameters}\n\n\\begin{frame}\n    \\frametitle{Decoding numbers}\n\n    \\centering\n    \\only<1>{\\includegraphics[width=\\textwidth * 11 / 20]{11 numbers.png}}%\n    \\only<2>{\\includegraphics[width=\\textwidth * 11 / 20]{12 numbers bits.png}}%\n    \\only<3>{\\includegraphics[width=\\textwidth * 11 / 20]{13 numbers twos complement.png}}%\n    \\only<4>{\\includegraphics[width=\\textwidth * 11 / 20]{14 numbers scale factor.png}}%\n    \\\\ \\texttt{\\tiny{Source: Table 20-III, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Decoding numbers}\n\n    \\begin{enumerate}\n        \\item<2-> Parse the bits as if they were a normal integer\n\n        \\item<3-> Convert the integer from two's complement representation (if necessary)\n\n        \\item<4-> Multiply by the scale factor\n    \\end{enumerate}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Decoding numbers}\n\n    \\centering\n    \\includegraphics[width=\\textwidth * 11 / 20]{15 numbers semi-circles.png} \\\\\n    \\texttt{\\tiny{Source: Table 20-III, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}\n\\end{frame}\n\n\\begin{frame}\n    \\frametitle{Recap}\n\n    \\begin{itemize}\n        \\item<2-> Pseudosymbols $\\rightarrow$ bits $\\rightarrow$ words $\\rightarrow$ subframes $\\rightarrow$ frames\n        \n        \\item<3-> Group pseudosymbols into pseudobits\n\n        \\begin{enumerate}\n            \\item<4-> Collect several bits worth of pseudosymbols\n\n            \\item<5-> Calculate a score for each possible grouping\n\n            \\item<6-> Choose the one with the greatest score\n        \\end{enumerate}\n\n        \\item<7-> Map pseudobits to bits\n\n        \\begin{enumerate}\n            \\item<8-> Collect several subframes worth of pseudobits\n\n            \\item<9-> Search for the telemetry word preamble (or its inverse)\n        \\end{enumerate}\n\n        \\item<10-> Obtain subframes' data bits by applying the parity algorithm\n        \n        \\item<11-> TOW count in handover word tells when the next subframe begins transmission\n\n        \\item<12-> Different subframes contain different parameters — we need 1, 2, and 3\n    \\end{itemize}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "presentation/8 solving/presentation.tex",
    "content": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\usepackage{siunitx}\n\n\\graphicspath{{./images}}\n\\setbeamertemplate{navigation symbols}{}\n\n\\author{Chris Doble}\n\\date{}\n\\subtitle{Building a GPS receiver from scratch}\n\\title{Part 8: Solving}\n\\usetheme{Madrid}\n\n% Show the topics frame at the start of each section\n\\AtBeginSection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n% Show the topics frame at the start of each subsection\n\\AtBeginSubsection[]\n{\n  \\begin{frame}\n    \\frametitle{Topics}\n    \\tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]\n  \\end{frame}\n}\n\n\\begin{document}\n\n\\frame{\\titlepage}\n\n\\section{The pseudorange equation}\n\n\\begin{frame}\n  \\frametitle{Coordinate system}\n\n  \\centering\n  \\only<1>{\\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\\textwidth * 35 / 100]{1 no coordinate system.pdf}}%\n  \\only<2>{\\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\\textwidth * 35 / 100]{2 geodetic.pdf}}%\n  \\only<3-5>{\\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\\textwidth * 35 / 100]{3 ecef.pdf}}%\n  \\only<6>{\\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\\textwidth * 35 / 100]{4 signal intersection.pdf}}%\n\n  \\onslide<4->{\\vspace{0.25cm} Transit time $T$}\n  \\onslide<5->{$\\Rightarrow$ distance $c T$}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{The pseudorange equation}\n\n  \\begin{center}\n    \\only<1>{\\[\\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} = c T\\]}%\n    \\only<2>{\\[\\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c T = 0\\]}%\n    \\only<3>{\\[\\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c (T - t) = 0\\]}%\n    \\only<4>{\\[\\sqrt{(X - \\textcolor{red}{x})^2 + (Y - \\textcolor{red}{y})^2 + (Z - \\textcolor{red}{z})^2} - c (T - \\textcolor{red}{t}) = 0\\]}%\n    \\only<5>{\\[\\sqrt{(\\textcolor{red}{X} - x)^2 + (\\textcolor{red}{Y} - y)^2 + (\\textcolor{red}{Z} - z)^2} - c (\\textcolor{red}{T} - t) = 0\\]}%\n  \\end{center}\n\n  \\only<1-2>{where $(x, y, z)$ is our unknown location.}%\n  \\only<3->{where $(x, y, z)$ is our unknown location and $t$ is our clock bias.}\n\\end{frame}\n\n\\section{Satellite location and transit time}\n\n\\begin{frame}\n  \\frametitle{Transmission time}\n\n  \\centering\n  \\only<2,5>{\\includegraphics[width=\\textwidth * 3 / 4]{5 transmission time.png}}%\n  \\only<3,6>{\\includegraphics[width=\\textwidth * 3 / 4]{7 tsv.png}}%\n  \\only<4>{\\includegraphics[width=\\textwidth * 3 / 4]{8 delta tsv.png}}%\n  \\only<2->{\\\\ $\\vdots$ \\vspace{0.25cm} \\\\}\n  \\only<2-4,6>{\\includegraphics[width=\\textwidth * 3 / 4]{6 l1 correction.png}}%\n  \\only<5>{\\includegraphics[width=\\textwidth * 3 / 4]{9 l1 correction.png}}%\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Transmission time}\n\n  \\centering\n  \\Large\n  \\[t_{sv} = \\text{TOW} \\times \\qty{6}{s} \\onslide<2->{+ \\text{PRN count} \\times \\qty{1}{ms}}\\]\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Reception time}\n\n  \\begin{enumerate}\n    \\item<2-> Record the time at which we finish receiving the PRN code (our clock)\n    \n    \\item<3-> Add 18 leap seconds\n    \n    \\item<4-> Calculate the number of seconds since GPS started operating\n    \n    \\item<5-> Calculate the remainder when divided by the number of seconds in a GPS week\n  \\end{enumerate}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Location}\n\n  \\centering\n  \\only<1>{\\includegraphics[width=\\textwidth/6]{10 equations.png}}%\n  \\only<2>{\\includegraphics[width=\\textwidth/6]{12 equations t.png}}%\n  \\hspace{0.5cm}%\n  \\raisebox{0.55\\height}{\\includegraphics[width=\\textwidth/6]{11 equations 2.png}}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{The pseudorange equation}\n\n  \\[\\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c (T - t) = 0\\]\n\\end{frame}\n\n\\section{System of equations}\n\n\\subsection{Definition}\n\n\\begin{frame}\n  \\frametitle{Definition}\n\n  \\begin{align*}\n    \\sqrt{(X_1 - x)^2 + (Y_1 - y)^2 + (Z_1 - z)^2} - c (T_1 - t) &= 0 \\\\\n    \\sqrt{(X_2 - x)^2 + (Y_2 - y)^2 + (Z_2 - z)^2} - c (T_2 - t) &= 0 \\\\\n    \\sqrt{(X_3 - x)^2 + (Y_3 - y)^2 + (Z_3 - z)^2} - c (T_3 - t) &= 0 \\\\\n    \\sqrt{(X_4 - x)^2 + (Y_4 - y)^2 + (Z_4 - z)^2} - c (T_4 - t) &= 0\n  \\end{align*}\n\\end{frame}\n\n\\subsection{Solving}\n\n\\begin{frame}\n  \\frametitle{The Newton-Raphson method}\n\n  \\centering\n  \\only<2>{\\includegraphics[width=\\textwidth * 2 / 3]{13 newton raphson 1.pdf}}%\n  \\only<3>{\\includegraphics[width=\\textwidth * 2 / 3]{14 newton raphson 2.pdf}}%\n  \\only<4>{\\includegraphics[width=\\textwidth * 2 / 3]{15 newton raphson 3.pdf}}%\n  \\only<5>{\\includegraphics[width=\\textwidth * 2 / 3]{16 newton raphson 4.pdf}}%\n  \\only<6>{\\includegraphics[width=\\textwidth * 2 / 3]{17 newton raphson 5.pdf}}%\n  \\only<7>{\\includegraphics[width=\\textwidth * 2 / 3]{18 newton raphson 6.pdf}}%\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{The Gauss-Newton algorithm}\n\n  \\begin{itemize}\n    \\item<2-> Tries to make our four (or more) pseudorange equations of four variables all equal zero\n    \n    \\item<3-> Initial guess $\\beta_0 = (x_0, y_0, z_0, t_0)$\n\n    \\item<4-> The Jacobian matrix $J$ is the multi-dimensional equivalent of the derivative\n    \n    \\item<5-> Cell $i, j$ tells us how the result of equation $i$ would change if we changed variable $j$\n    \n    \\item<6-> We iteratively improve the guess $\\beta_{n + 1} = \\beta_n - (J^T J)^{-1} J^T r(\\beta_n)$ where $r(\\beta)$ is the residual vector (a column vector containing the results of evaluating the pseudorange equations)\n    \n    \\item<7-> Convert from ECEF to geodetic coordinates using Bowring's method\n  \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Hooray!}\n\n  \\centering\n  \\includegraphics[width=\\textwidth * 2 / 5]{19 party.png}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{Recap}\n\n  \\begin{itemize}\n    \\item<2-> We express locations using ECEF coordinates\n\n    \\item<3-> The pseudorange equation relates our distance to the satellite and the signal transit time \\[\\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c (T - t) = 0\\]\n    \n    \\item<4-> The signal transit time is defined as $T = t_\\text{received} - t_\\text{transmitted}$\n    \n    \\item<5-> The satellite's location can be calculated using equations in the GPS spec\n    \n    \\item<6-> We need a system of at least four pseudorange equations to solve for $x, y, z,$ and $t$\n    \n    \\item<7-> The Gauss-Newton algorithm is used to estimate $x, y, z,$ and $t$\n  \\end{itemize}\n\\end{frame}\n\n\\begin{frame}\n  \\frametitle{}\n\n  \\centering\n  \\vspace{1cm}\n  {\\Huge Thank you!} \\\\\n  \\vspace{1cm}\n  \\texttt{https://github.com/chrisdoble/gps-receiver}\n\\end{frame}\n\n\\end{document}"
  },
  {
    "path": "rtl_sdr_gps_sampler.grc",
    "content": "options:\n  parameters:\n    author: ''\n    catch_exceptions: 'True'\n    category: '[GRC Hier Blocks]'\n    cmake_opt: ''\n    comment: ''\n    copyright: ''\n    description: ''\n    gen_cmake: 'On'\n    gen_linking: dynamic\n    generate_options: qt_gui\n    hier_block_src_path: '.:'\n    id: rtl_sdr_gps_sampler\n    max_nouts: '0'\n    output_language: python\n    placement: (0,0)\n    qt_qss_theme: ''\n    realtime_scheduling: ''\n    run: 'True'\n    run_command: '{python} -u {filename}'\n    run_options: prompt\n    sizing_mode: fixed\n    thread_safe_setters: ''\n    title: RTL-SDR GPS sampler\n    window_size: (1000,1000)\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [8, 8]\n    rotation: 0\n    state: enabled\n\nblocks:\n- name: timestamp\n  id: variable\n  parameters:\n    comment: ''\n    value: str(datetime.now(timezone.utc).timestamp())\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [480, 168.0]\n    rotation: 0\n    state: enabled\n- name: blocks_file_sink_0\n  id: blocks_file_sink\n  parameters:\n    affinity: ''\n    alias: ''\n    append: 'False'\n    comment: ''\n    file: '''./samples-'' + timestamp'\n    type: complex\n    unbuffered: 'False'\n    vlen: '1'\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [480, 228.0]\n    rotation: 0\n    state: enabled\n- name: import_0\n  id: import\n  parameters:\n    alias: ''\n    comment: ''\n    imports: from datetime import datetime, timezone\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [480, 128.0]\n    rotation: 0\n    state: enabled\n- name: import_1\n  id: import\n  parameters:\n    alias: ''\n    comment: ''\n    imports: import re\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [592, 128.0]\n    rotation: 0\n    state: enabled\n- name: soapy_rtlsdr_source_0\n  id: soapy_rtlsdr_source\n  parameters:\n    affinity: ''\n    agc: 'False'\n    alias: ''\n    bias: 'True'\n    bufflen: '16384'\n    center_freq: 1575.42e6\n    comment: ''\n    dev_args: ''\n    freq_correction: '0'\n    gain: '20'\n    maxoutbuf: '0'\n    minoutbuf: '0'\n    samp_rate: 2.046e6\n    type: fc32\n  states:\n    bus_sink: false\n    bus_source: false\n    bus_structure: null\n    coordinate: [272, 232.0]\n    rotation: 0\n    state: enabled\n\nconnections:\n- [soapy_rtlsdr_source_0, '0', blocks_file_sink_0, '0']\n\nmetadata:\n  file_format: 1\n  grc_version: 3.10.11.0\n"
  }
]