Full Code of chrisdoble/gps-receiver for AI

master e52dee68625e cached
55 files
184.7 KB
52.3k tokens
142 symbols
1 requests
Download .txt
Repository: chrisdoble/gps-receiver
Branch: master
Commit: e52dee68625e
Files: 55
Total size: 184.7 KB

Directory structure:
gitextract_6zztte4y/

├── .gitignore
├── .vscode/
│   ├── launch.json
│   └── settings.json
├── LICENSE
├── README.md
├── bin/
│   └── generate_dashboard_types.sh
├── dashboard/
│   ├── .gitignore
│   ├── .prettierrc.json
│   ├── eslint.config.js
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── Dashboard.css
│   │   ├── Dashboard.tsx
│   │   ├── TrackedSatelliteInformation.css
│   │   ├── TrackedSatelliteInformation.tsx
│   │   ├── http_types.ts
│   │   ├── main.tsx
│   │   └── vite-env.d.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.common.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── gpsreceiver/
│   ├── .gitignore
│   ├── gpsreceiver/
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   ├── acquirer.py
│   │   ├── antenna.py
│   │   ├── config.py
│   │   ├── constants.py
│   │   ├── http_types.py
│   │   ├── pipeline.py
│   │   ├── prn_codes.py
│   │   ├── pseudobit_integrator.py
│   │   ├── pseudosymbol_integrator.py
│   │   ├── receiver.py
│   │   ├── subframe_decoder.py
│   │   ├── subframes.py
│   │   ├── tracker.py
│   │   ├── types.py
│   │   ├── utils.py
│   │   └── world.py
│   ├── makefile
│   ├── mypy.ini
│   └── requirements.txt
├── presentation/
│   ├── .gitignore
│   ├── 1 introduction/
│   │   └── presentation.tex
│   ├── 2 correlation/
│   │   └── presentation.tex
│   ├── 3 GPS signals/
│   │   └── presentation.tex
│   ├── 4 sampling/
│   │   └── presentation.tex
│   ├── 5 acquisition/
│   │   └── presentation.tex
│   ├── 6 tracking/
│   │   └── presentation.tex
│   ├── 7 decoding/
│   │   └── presentation.tex
│   └── 8 solving/
│       └── presentation.tex
└── rtl_sdr_gps_sampler.grc

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
# Mac
.DS_Store

# Project
data
rtl_sdr_gps_sampler.py
samples-*


================================================
FILE: .vscode/launch.json
================================================
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug",
            "type": "debugpy",
            "request": "launch",
            "module": "gpsreceiver"
        }
    ]
}

================================================
FILE: .vscode/settings.json
================================================
{
    "[python]": {
        "editor.codeActionsOnSave": {
            "source.organizeImports": "explicit"
        },
        "editor.defaultFormatter": "ms-python.black-formatter",
        "editor.formatOnSave": true
    },
    "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
    },
    "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
    },
    "isort.args": ["--profile", "black"]
}

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Chris Doble

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
This repository contains my software-defined GPS receiver project.

<p align="center">
  <img src="./presentation/1 introduction/images/10 dashboard.png" width="500"/>
</p>

I also made a video series about the process that you can watch [on YouTube](https://www.youtube.com/playlist?list=PLmlXFuUXRl5BnKM9PM_tT9uIzlwUxGzLb).

<p align="center">
  <a href="https://www.youtube.com/playlist?list=PLmlXFuUXRl5BnKM9PM_tT9uIzlwUxGzLb">
    <img src="./thumbnail.png" width="500"/>
  </a>
</p>

# Features

- Uses the legacy coarse/acquisition (C/A) code to produce clock bias and location estimates.
- Produces estimates in as little as ~24 s from cold start (depending on environmental factors).
- Location estimates tend to be within a few hundred metres of the true location.
- Runs from pre-recorded sample files or a connected RTL-SDR.
- Has an accompanying web-based dashboard to show location estimates and satellite information.
- Written in Python with no runtime dependencies other than aiohttp (for the dashboard), NumPy, Pydantic (for data serialisation), and pyrtlsdr.

# Receiver

The `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).

> [!NOTE]
> All commands in this section should be run from the `gpsreceiver` directory.

## Setup

### Hardware

If 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.

### Software

```bash
# Make sure you're running Python >=3.12
python -m venv .env
source .env/bin/activate
pip install -r requirements.txt
```

## Running

### From a file

The 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.

```
[32-bit float][32-bit float][32-bit float][32-bit float]...
      ^             ^             ^             ^
  Sample 0 I    Sample 0 Q    Sample 1 I    Sample 1 Q
```

You can pass the file to the GPS receiver by running the following from within the `gpsreceiver` directory

```bash
python -m gpsreceiver -f $FILE_PATH -t $START_TIMESTAMP
```

where `$FILE_PATH` is the path to the file and `$START_TIMESTAMP` is the Unix time when the samples began being recorded.

Phillip 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:

1. Download `nov_3_time_18_48_st_ives.zip` from [here](https://github.com/codyd51/gypsum/releases/tag/1.0)
2. Unzip it.
3. 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.
4. Run `python -m gpsreceiver -f nov_3_time_18_48_st_ives -t 1699037280`.

If you'd like to record your own file:

1. Connect your antenna to your RTL-SDR and your RTL-SDR to your computer.
2. Install [GNU Radio](https://www.gnuradio.org/).
3. Open the GNU Radio Companion (GNURC) by running `gnuradio-companion`.
4. Open `rtl_srd_gps_sampler.grc` in GNURC.
5. Click play in GNURC. A window will open.
6. Record data for as long as you'd like.
7. Close the window that opened in step 5.
8. There will be a new file called `samples-TIMESTAMP`.
9. Run `python -m gpsreceiver -f samples-TIMESTAMP -t TIMESTAMP`.

### From an RTL-SDR

```bash
python -m gpsreceiver --rtl-sdr
```

## Development

```bash
# Autoformat
make format

# Type check
make type_check
```

# Dashboard

The dashboard takes information from the receiver's HTTP server and renders it in a web-based interface.

> [!NOTE]
> All commands in this section should be run from the `dashboard` directory unless otherwise noted.

> [!NOTE]
> The receiver only exposes an HTTP server when running from a file.
>
> Doing so when running from real-time RTL-SDR data causes the receiver to miss data and lose lock on satellites.

## Setup

```bash
pnpm install

# A Google Maps API key is required to show location estimates on a map. Replace
# the ellipses (...) with your API key. See here[1] for more instructions.
#
# 1: https://developers.google.com/maps/documentation/javascript/cloud-setup
echo "VITE_GOOGLE_MAPS_API_KEY=..." >> .env.local

# If you know the receiver's actual location and want to show it on the map to
# compare it with the estimated location, set this environment variable. Replace
# LAT and LNG with the receiver's actual latitude and longitude.
echo "VITE_ACTUAL_LOCATION=LAT,LNG" >> .env.local
```

## Running

```bash
pnpm start
```

Note that the GPS receiver must be running in order for data to be available to the dashboard.

## Development

```bash
# Autoformat
pnpm format

# Generate dashboard/src/http_types.ts from gpsreceiver/gpsreceiver/http_types.py.
#
# Run from the root of the repository.
./bin/generate_dashboard_types.sh

# Lint
pnpm lint

# Type check
pnpm type_check
```

================================================
FILE: bin/generate_dashboard_types.sh
================================================
#!/bin/bash
set -eu

DIR="$(cd $(dirname "$0") && pwd)"

main() {
    if [[ "$@" == "--help" ]]; then
        echo "Generates dashboard/src/http_types.ts from gpsreceiver/gpsreceiver/http_types.py"
        exit
    fi
    
    # Create a temporary file to store the JSON schema.
    local schema_path
    schema_path="$(mktemp)"
    trap 'rm -f "'"${schema_path}"'"' EXIT

    # Generate a JSON schema from the Python types.
    #
    # We want to suppress the titles of fields so json-schema-to-typescript
    # doesn't generate a bunch of random types that are just used for fields.
    cd "${DIR}/../gpsreceiver"
    source .env/bin/activate
    python - <<EOF > "${schema_path}"
from gpsreceiver.http_types import HttpData
from json import dumps
from pydantic.json_schema import GenerateJsonSchema

class TitleSuppressingGenerateJsonSchema(GenerateJsonSchema):
    def field_title_should_be_set(self, schema):
        return False

print(dumps(HttpData.model_json_schema(schema_generator=TitleSuppressingGenerateJsonSchema)))
EOF

    # Generate the TypeScript types from the JSON schema.
    cd "${DIR}/../dashboard"
    node - <<EOF > src/http_types.ts
import { compileFromFile } from "json-schema-to-typescript";

compileFromFile(
    "${schema_path}",
    {
        additionalProperties: false,
        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 */"
    }
).then(console.log)
EOF
}

main "$@"

================================================
FILE: dashboard/.gitignore
================================================
*.local
node_modules


================================================
FILE: dashboard/.prettierrc.json
================================================
{
    "importOrder": ["^\\."],
    "importOrderCaseInsensitive": true,
    "importOrderSeparation": true,
    "importOrderSpecifiers": true,
    "plugins": ["@trivago/prettier-plugin-sort-imports"]
}

================================================
FILE: dashboard/eslint.config.js
================================================
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config({
  extends: [js.configs.recommended, ...tseslint.configs.recommended],
  files: ["**/*.{ts,tsx}"],
  languageOptions: {
    ecmaVersion: 2020,
    globals: globals.browser,
  },
  plugins: {
    "react-hooks": reactHooks,
    "react-refresh": reactRefresh,
  },
  rules: {
    ...reactHooks.configs.recommended.rules,
    "react-refresh/only-export-components": [
      "warn",
      { allowConstantExport: true },
    ],
  },
});


================================================
FILE: dashboard/index.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GPS Receiver</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>


================================================
FILE: dashboard/package.json
================================================
{
  "dependencies": {
    "@vis.gl/react-google-maps": "^1.5.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "recharts": "^2.15.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.17.0",
    "@trivago/prettier-plugin-sort-imports": "^5.2.1",
    "@types/google.maps": "^3.58.1",
    "@types/react": "^18.3.18",
    "@types/react-dom": "^18.3.5",
    "@vitejs/plugin-react": "^4.3.4",
    "eslint": "^9.17.0",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.16",
    "globals": "^15.14.0",
    "json-schema-to-typescript": "^15.0.4",
    "prettier": "^3.4.2",
    "typescript": "~5.6.2",
    "typescript-eslint": "^8.18.2",
    "vite": "^6.0.5"
  },
  "name": "dashboard",
  "private": true,
  "type": "module",
  "version": "0.0.0",
  "scripts": {
    "format": "prettier src --write",
    "lint": "eslint .",
    "start": "vite --open --port 8081",
    "type_check": "tsc -b"
  }
}


================================================
FILE: dashboard/src/Dashboard.css
================================================
body {
    font-family: sans-serif;
    margin: 0;
}

.message {
    left: 50%;
    margin: 0;
    position: absolute;
    top: 50%;
    transform: translate(-50%, -50%);
}

.map-container {
    height: 400px;
}

.tracked-satellites-container {
    display: grid;
    grid-gap: 10px;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    padding: 10px;
}


================================================
FILE: dashboard/src/Dashboard.tsx
================================================
import {
  AdvancedMarker,
  Map,
  Pin,
  RenderingType,
  useMap,
  useMapsLibrary,
} from "@vis.gl/react-google-maps";
import { useEffect, useState } from "react";

import "./Dashboard.css";
import { HttpData } from "./http_types";
import TrackedSatelliteInformation from "./TrackedSatelliteInformation";

export default function Dashboard() {
  const actualLocation = getActualLocation();

  // Periodically fetch data from the server.
  //
  // undefined means we haven't received a response from the receiver yet, null
  // means we've received a response but it contained no data (the receiver is
  // probably running from a file and hasn't finished acquisition yet).
  const [data, setData] = useState<HttpData | null | undefined>();
  useEffect(() => {
    const intervalId = setInterval(async () => {
      try {
        const response = await fetch("http://localhost:8080/");
        setData(await response.json());
      } catch {
        setData(undefined);
      }
    }, 2000);
    return () => clearInterval(intervalId);
  }, []);

  // Update the map's viewport to contain the location estimate.
  const core = useMapsLibrary("core");
  const map = useMap();
  useEffect(() => {
    if (core !== null && data != null && map !== null) {
      const bounds = new core.LatLngBounds();
      if (actualLocation !== null) {
        bounds.extend(actualLocation);
      }
      if (data.latest_solution !== null) {
        const {
          position: { latitude: lat, longitude: lng },
        } = data.latest_solution;
        bounds.extend({ lat, lng });

        // Extend the bounds a little so we can see the area around the location
        const buffer = 0.001;
        bounds.extend({ lat: lat - buffer, lng: lng - buffer });
        bounds.extend({ lat: lat + buffer, lng: lng + buffer });
      }
      map.fitBounds(bounds, 0);
    }
  }, [actualLocation, core, data, map]);

  if (data === undefined) {
    return <p className="message">Waiting for the server to start.</p>;
  }

  if (data === null) {
    return <p className="message">Waiting for the server to collect data.</p>;
  }

  return (
    <>
      {/* Map */}
      <div className="map-container">
        <Map
          clickableIcons={false}
          defaultCenter={{ lat: 0, lng: 0 }}
          defaultZoom={0}
          disableDefaultUI
          gestureHandling="none"
          mapId="DEMO_MAP_ID"
          renderingType={RenderingType.VECTOR}
        >
          {actualLocation && (
            <AdvancedMarker key="actual" position={actualLocation}>
              <Pin
                background="#4285F4"
                borderColor="#174EA6"
                glyphColor="#174EA6"
              />
            </AdvancedMarker>
          )}
          {data.latest_solution && (
            <AdvancedMarker
              key="estimated"
              position={{
                lat: data.latest_solution.position.latitude,
                lng: data.latest_solution.position.longitude,
              }}
            />
          )}
        </Map>
      </div>

      {/* Tracked satellites */}
      {data.tracked_satellites.length === 0 ? (
        <p>No satellites have been acquired yet.</p>
      ) : (
        <div className="tracked-satellites-container">
          {data.tracked_satellites
            .toSorted(({ satellite_id: a }, { satellite_id: b }) => a - b)
            .map((trackedSatellite) => (
              <TrackedSatelliteInformation
                key={trackedSatellite.satellite_id}
                trackedSatellite={trackedSatellite}
              />
            ))}
        </div>
      )}
    </>
  );
}

function getActualLocation(): google.maps.LatLngLiteral | null {
  const s = import.meta.env.VITE_ACTUAL_LOCATION;
  if (s === undefined) {
    return null;
  }

  const ss = s.split(",");
  if (ss.length !== 2) {
    throw Error(`Invalid actual location: ${s}`);
  }

  const ns = ss.map(parseFloat);
  if (ns.some(isNaN)) {
    throw Error(`Invalid actual location: ${s}`);
  }

  return { lat: ns[0], lng: ns[1] };
}


================================================
FILE: dashboard/src/TrackedSatelliteInformation.css
================================================
.tracked-satellite-container {
    background: #fafafa;
    border-radius: 10px;
    padding: 10px;
}

.tracked-satellite-container h1 {
    font-size: 1.5em;
    margin: 0 0 10px 0;
}

.tracked-satellite-container ol {
    list-style: none;
    margin: 0 0 10px 0;
    padding: 0;
}

.tracked-satellite-container dl {
    display: grid;
    grid-template-columns: 90px 1fr;
    margin: 0 0 10px 0;
}

.tracked-satellite-container dt {
    margin-right: 5px;
}

.tracked-satellite-container dd {
    margin: 0;
}

.tracked-satellite-container .line-charts-container {
    display: flex;
    margin: 0 0 10px 0;
}

.line-chart-container {
    display: flex;
    flex-basis: 50%;
    flex-direction: column;
}

.chart-title {
    font-size: 0.8em;
    margin: 0 0 5px 0;
    text-align: center;
}


================================================
FILE: dashboard/src/TrackedSatelliteInformation.tsx
================================================
import { useCallback, useMemo } from "react";
import {
  Dot,
  DotProps,
  Line,
  LineChart,
  ResponsiveContainer,
  Scatter,
  ScatterChart,
  XAxis,
  XAxisProps,
  YAxis,
  YAxisProps,
} from "recharts";

import { TrackedSatellite } from "./http_types";
import "./TrackedSatelliteInformation.css";

export default function TrackedSatelliteInformation({
  trackedSatellite: {
    bit_boundary_found,
    bit_phase,
    carrier_frequency_shifts,
    correlations,
    duration,
    prn_code_phase_shifts,
    required_subframes_received,
    satellite_id,
    subframe_count,
  },
}: {
  trackedSatellite: TrackedSatellite;
}) {
  return (
    <div className="tracked-satellite-container">
      <h1>#{satellite_id}</h1>
      <ol>
        <li>{toEmoji(bit_boundary_found)} Bit boundary</li>
        <li>{toEmoji(bit_phase !== null)} Bit phase</li>
        <li>{toEmoji(required_subframes_received)} Required subframes</li>
      </ol>
      <dl>
        <dt>Duration:</dt>
        <dd>{toHoursMinutesSeconds(duration)}</dd>
        <dt>Subframes:</dt>
        <dd>{subframe_count}</dd>
      </dl>
      <div className="line-charts-container">
        <LineChart_
          data={carrier_frequency_shifts}
          title="Carrier frequency shift"
        />
        <LineChart_ data={prn_code_phase_shifts} title="PRN code phase shift" />
      </div>
      <CorrelationChart data={correlations} />
    </div>
  );
}

/** Converts a boolean value to an appropriate emoji. */
function toEmoji(value: boolean): string {
  return value ? "✅" : "❌";
}

/**
 * Converts a duration in seconds to a string.
 *
 *     toHoursMinutesSeconds(6020) === "1h 40m 20s"
 */
function toHoursMinutesSeconds(duration: number): string {
  const hours = Math.floor(duration / 3600);
  const minutes = Math.floor((duration % 3600) / 60);
  const seconds = Math.floor(duration % 60);
  return [
    hours ? `${hours}h` : "",
    minutes ? `${minutes}m` : "",
    seconds ? `${seconds}s` : "",
  ]
    .filter(Boolean)
    .join(" ");
}

function LineChart_({ data, title }: { data: number[]; title: string }) {
  const identity = useCallback((x: unknown) => x, []);
  const xTickFormatter = useCallback(
    (n: number) => (n === 0 ? "-1s" : "0s"),
    [],
  );
  const yTickFormatter = useCallback((n: number) => `${Math.floor(n)}`, []);

  return (
    <div className="line-chart-container">
      <p className="chart-title">{title}</p>
      <ResponsiveContainer height={100} width="100%">
        <LineChart data={data}>
          <Line animationDuration={0} dataKey={identity} dot={false} />
          <XAxis height={15} tickFormatter={xTickFormatter} ticks={[0, 999]} />
          <YAxis
            domain={["dataMin", "dataMax"]}
            tickFormatter={yTickFormatter}
            type="number"
            width={40}
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

function CorrelationChart({ data }: { data: number[][] }) {
  // For some reason domain={["dataMin", "dataMax"]} doesn't seem to work for
  // scatter plots. Calculate each axes' domain manually.
  const xDomain = useMemo<NonNullable<XAxisProps["domain"]>>(() => {
    const xs = data.map(([x]) => x);
    return [Math.floor(Math.min(...xs)), Math.ceil(Math.max(...xs))];
  }, [data]);
  const yDomain = useMemo<NonNullable<YAxisProps["domain"]>>(() => {
    const ys = data.map(([y]) => y);
    return [Math.floor(Math.min(...ys)), Math.ceil(Math.max(...ys))];
  }, [data]);

  return (
    <div className="correlation-chart-container">
      <p className="chart-title">Correlations</p>
      <ResponsiveContainer height={200} width="100%">
        <ScatterChart>
          <Scatter
            animationDuration={0}
            data={data}
            shape={<CorrelationDot />}
          />
          <XAxis dataKey={0} domain={xDomain} height={15} type="number" />
          <YAxis dataKey={1} domain={yDomain} type="number" width={20} />
        </ScatterChart>
      </ResponsiveContainer>
    </div>
  );
}

function CorrelationDot({ cx, cy }: DotProps) {
  return <Dot cx={cx} cy={cy} fill="#3182bd80" r={2} />;
}


================================================
FILE: dashboard/src/http_types.ts
================================================
/**
 * This file was automatically generated. Don't edit it by hand. Instead, change
 * gpsreceiver/gpsreceiver/http_types.py and run bin/generate_dashboard_types.sh.
 */

/**
 * Data sent to the HTTP server subprocess to be served to clients.
 */
export interface HttpData {
  latest_solution: GeodeticSolution | null;
  tracked_satellites: TrackedSatellite[];
  untracked_satellites: UntrackedSatellite[];
}
/**
 * A computed solution with the position in geodetic coordinates.
 */
export interface GeodeticSolution {
  clock_bias: number;
  position: GeodeticCoordinates;
}
/**
 * A location expressed in geodetic coordinates.
 */
export interface GeodeticCoordinates {
  height: number;
  latitude: number;
  longitude: number;
}
/**
 * Data regarding a tracked satellite.
 */
export interface TrackedSatellite {
  bit_boundary_found: boolean;
  bit_phase: (-1 | 1) | null;
  carrier_frequency_shifts: number[];
  correlations: number[][];
  duration: number;
  prn_code_phase_shifts: number[];
  required_subframes_received: boolean;
  satellite_id: number;
  subframe_count: number;
}
/**
 * Data regarding an untracked satellite.
 */
export interface UntrackedSatellite {
  next_acquisition_at: string;
  satellite_id: number;
}



================================================
FILE: dashboard/src/main.tsx
================================================
import { APIProvider } from "@vis.gl/react-google-maps";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import Dashboard from "./Dashboard";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}>
      <Dashboard />
    </APIProvider>
  </StrictMode>,
);


================================================
FILE: dashboard/src/vite-env.d.ts
================================================
/// <reference types="vite/client" />


================================================
FILE: dashboard/tsconfig.app.json
================================================
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "target": "ES2020",
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "useDefineForClassFields": true,
  },
  "extends": "./tsconfig.common.json",
  "include": ["src"]
}


================================================
FILE: dashboard/tsconfig.common.json
================================================
{
    "compilerOptions": {
        "allowImportingTsExtensions": true,
        "isolatedModules": true,
        "module": "ESNext",
        "moduleDetection": "force",
        "moduleResolution": "bundler",
        "noEmit": true,
        "noFallthroughCasesInSwitch": true,
        "noUncheckedSideEffectImports": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "skipLibCheck": true,
        "strict": true,
    }
}

================================================
FILE: dashboard/tsconfig.json
================================================
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}


================================================
FILE: dashboard/tsconfig.node.json
================================================
{
  "compilerOptions": {
    "target": "ES2022",
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "lib": ["ES2023"],
  },
  "extends": "./tsconfig.common.json",
  "include": ["vite.config.ts"],
}


================================================
FILE: dashboard/vite.config.ts
================================================
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
});


================================================
FILE: gpsreceiver/.gitignore
================================================
__pycache__
.env
.mypy_cache

================================================
FILE: gpsreceiver/gpsreceiver/__init__.py
================================================


================================================
FILE: gpsreceiver/gpsreceiver/__main__.py
================================================
import logging
from argparse import ArgumentParser
from datetime import datetime, timezone
from pathlib import Path

from .acquirer import MainProcessAcquirer, SubprocessAcquirer
from .antenna import FileAntenna, RtlSdrAntenna
from .receiver import Receiver

logging.basicConfig(
    format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)

argument_parser = ArgumentParser()
argument_parser.add_argument("-f", "--file", help="the path to the input file to use")
argument_parser.add_argument(
    "-t", "--time", help="the start time of the input file, in Unix time"
)
argument_parser.add_argument(
    "--rtl-sdr", action="store_true", help="run in real time from an RTL-SDR"
)
argument_parser.add_argument(
    "-g", "--gain", type=int, default=20, help="front-end gain for RTL-SDR (default 20)"
)

args = argument_parser.parse_args()

try:
    if args.file and args.time:
        FileAntenna(
            Path(args.file),
            Receiver(MainProcessAcquirer(), run_http_server=True),
            datetime.fromtimestamp(float(args.time), tz=timezone.utc),
        ).start()
    elif args.rtl_sdr:
        RtlSdrAntenna(
            Receiver(SubprocessAcquirer(), run_http_server=False), gain=args.gain
        ).start()
    else:
        argument_parser.print_help()
except KeyboardInterrupt:
    pass


================================================
FILE: gpsreceiver/gpsreceiver/acquirer.py
================================================
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass
from datetime import MINYEAR, datetime, timedelta, timezone
from multiprocessing import Pipe, Process
from multiprocessing.connection import Connection
from typing import cast

import numpy as np

from .config import (
    ACQUISITION_INTERVAL,
    ACQUISITION_STRENGTH_THRESHOLD,
    ALL_SATELLITE_IDS,
    MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION,
    SAMPLES_PER_MILLISECOND,
)
from .constants import SAMPLE_TIMES, SAMPLES_PER_SECOND
from .http_types import UntrackedSatellite
from .prn_codes import COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID
from .types import OneMsOfSamples, SatelliteId, UtcTimestamp
from .utils import InvariantError, invariant


@dataclass(kw_only=True)
class Acquisition:
    """The parameters resulting from acquisition of a GPS satellite signal.

    Note that the frequency/phase shift parameters are the observed shifts, i.e.
    we must negate them on a received signal to perform carrier wipeoff.
    """

    # An estimate of the carrier signal's frequency shift in Hz.
    carrier_frequency_shift: float

    # An estimate of the carrier signal's phase shift in radians.
    #
    # This can be inaccurate if we encountered a navigation bit change during
    # acquisition, but the Costas loop in the tracking phase should fix it.
    carrier_phase_shift: float

    # An estimate of the C/A PRN code's phase shift in half-chips.
    #
    # This will be in the range [0, 2045].
    prn_code_phase_shift: int

    # The ID of the GPS satellite whose signal was acquired.
    satellite_id: SatelliteId

    # The strength of the acquisition.
    #
    # This is the peak-to-mean ratio of the signal correlations for this
    # particular Doppler shift and all possible C/A PRN code phase shifts.
    strength: float

    # When the acquisition occurred.
    timestamp: UtcTimestamp


class Acquirer(ABC):
    """Detects GPS satellite signals and determines their parameters.

    This is abstract so subclasses can decide how to schedule computations, e.g.
    whether they should block the main process or occur in a subprocess.
    """

    def __init__(self) -> None:
        # When to next attempt acquisition for each satellite, in receiver time.
        #
        # Set to the minimum value so we perform acquisition on startup.
        self._next_acquisition_at_by_satellite_id: dict[SatelliteId, UtcTimestamp] = {
            i: datetime(MINYEAR, 1, 1, tzinfo=timezone.utc) for i in ALL_SATELLITE_IDS
        }

        # The most recently received samples.
        self._samples = deque[OneMsOfSamples](
            maxlen=MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION
        )

        # The most recently received set of tracked satellite IDs.
        self._tracked_satellite_ids: set[SatelliteId] = set()

    def handle_1ms_of_samples(
        self, samples: OneMsOfSamples, tracked_satellite_ids: set[SatelliteId]
    ) -> Acquisition | None:
        """Handles 1 ms of samples.

        Returns an ``Acquisition`` if a satellite's signal has been acquired.
        """

        self._samples.append(samples)
        self._tracked_satellite_ids = tracked_satellite_ids

        # Check if we have enough samples to perform acquisition.
        if len(self._samples) < MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION:
            return None

        acquisition = self._get_acquisition()
        if acquisition is not None:
            self._next_acquisition_at_by_satellite_id[acquisition.satellite_id] = (
                samples.end_timestamp + ACQUISITION_INTERVAL
            )

            if acquisition.strength >= ACQUISITION_STRENGTH_THRESHOLD:
                return acquisition

        return None

    @property
    def untracked_satellites(self) -> list[UntrackedSatellite]:
        return [
            UntrackedSatellite(
                next_acquisition_at=next_acquisition_at,
                satellite_id=satellite_id,
            )
            for satellite_id, next_acquisition_at in self._next_acquisition_at_by_satellite_id.items()
            if satellite_id not in self._tracked_satellite_ids
        ]

    @abstractmethod
    def _get_acquisition(self) -> Acquisition | None:
        """Returns an ``Acquisition``, if one is ready."""

        pass

    def _get_next_acquisition_target(self) -> SatelliteId | None:
        """Determines which satellite we should attempt to acquire next."""

        now = self._samples[-1].end_timestamp
        untracked_satellite_ids = ALL_SATELLITE_IDS - self._tracked_satellite_ids
        candidates = [
            (si, t)
            for si, t in self._next_acquisition_at_by_satellite_id.items()
            if si in untracked_satellite_ids and t <= now
        ]
        candidates.sort(key=lambda c: c[1])

        if len(candidates) > 0:
            return candidates[0][0]

        return None


class MainProcessAcquirer(Acquirer):
    """An ``Acquirer`` that performs computations in the main process.

    To be used when sampling a recorded signal, otherwise the receiver churns
    through all of the recorded samples before acquisition is complete.
    """

    def _get_acquisition(self) -> Acquisition | None:
        satellite_id = self._get_next_acquisition_target()
        if satellite_id is not None:
            return _acquire_satellite(list(self._samples), satellite_id)

        return None


class SubprocessAcquirer(Acquirer):
    """An ``Acquirer`` that performs computations in a subprocess.

    To be used when sampling a real-time signal, otherwise there will be periods
    where we don't sample because the computations block the main process.
    """

    def __init__(self) -> None:
        super().__init__()

        # The connections through which the processes communicate.
        self._connection, connection = Pipe()

        # The subprocess.
        #
        # Marked as a daemon so it's killed alongside the main process.
        self._subprocess = Process(
            args=(connection,),
            daemon=True,
            target=_run_subprocess,
        )
        self._subprocess.start()

        # Whether we're waiting for the subprocess to return an ``Acquisition``.
        self._waiting = False

    def _get_acquisition(self) -> Acquisition | None:
        invariant(self._subprocess.is_alive(), "Acquisition subprocess has terminated")

        if self._waiting:
            if self._connection.poll():
                result = self._connection.recv()
                invariant(
                    isinstance(result, Acquisition),
                    f"Invalid value received from acquisition subprocess: {result}",
                )
                self._waiting = False
                return result
        else:
            satellite_id = self._get_next_acquisition_target()
            if satellite_id is not None:
                self._connection.send((list(self._samples), satellite_id))
                self._waiting = True

        return None


def _run_subprocess(connection: Connection) -> None:
    while True:
        # If we haven't received any arguments from the main process, sleep.
        if not connection.poll():
            time.sleep(0.001)
            continue

        args = connection.recv()
        invariant(
            isinstance(args, tuple) and len(args) == 2,
            f"Invalid arguments sent to acquisition subprocess: {args}",
        )

        samples, satellite_id = args
        invariant(
            isinstance(samples, list)
            and all([isinstance(s, OneMsOfSamples) for s in samples]),
            f"Invalid samples sent to acquisition subprocess: {samples}",
        )
        invariant(
            isinstance(satellite_id, int),
            f"Invalid satellite ID send to acquisition subprocess: {satellite_id}",
        )

        connection.send(_acquire_satellite(samples, satellite_id))


def _acquire_satellite(
    samples: list[OneMsOfSamples], satellite_id: SatelliteId
) -> Acquisition:
    """Attempts to acquire the signal of a particular GPS satellite."""

    # The following attempts to acquire the satellite's signal at a fixed
    # number of frequency shifts in a range around a central value. The
    # central value is updated to be the frequency shift of the strongest
    # candidate, the range is reduced, and the process is repeated until
    # we're searching a continuous range. This means we start by searching a
    # large range and gradually narrow down on the most promising regions.
    #
    # The initial central value is 0 and the initial frequency shift range
    # is ±7.680 kHz. This range was chosen to accommodate all reasonable
    # receiver and satellite motion, receiver oscillation variance, etc. On
    # each iteration the range is split into 31 equally-spaced values
    # (including endpoints) and is reduced by a factor of two. This means on
    # the first iteration the step size is 512 Hz, the second it's 256 Hz,
    # etc., until the tenth iteration where it's 1 Hz and we're searching a
    # continuous range. At that point we've found the strongest candidate.
    best_acquisition: Acquisition | None = None
    centre_frequency_shift: float = 0
    half_frequency_shift_range: float = 7_680

    while half_frequency_shift_range >= 15:
        new_acquisition = _acquire_satellite_at_frequency_shifts(
            np.linspace(
                centre_frequency_shift - half_frequency_shift_range,
                centre_frequency_shift + half_frequency_shift_range,
                31,
            ),
            samples,
            satellite_id,
        )

        if (
            best_acquisition is None
            or new_acquisition.strength > best_acquisition.strength
        ):
            best_acquisition = new_acquisition

        centre_frequency_shift = best_acquisition.carrier_frequency_shift
        half_frequency_shift_range /= 2

    if best_acquisition is None:
        raise InvariantError("Missing acquisition result")

    return best_acquisition


def _acquire_satellite_at_frequency_shifts(
    frequency_shifts: np.ndarray,
    samples: list[OneMsOfSamples],
    satellite_id: SatelliteId,
) -> Acquisition:
    """Attempts to acquire the signal of a particular GPS satellite at
    particular frequency shifts.

    Returns the best acquisition result regardless of whether its strength
    exceeds the acquisition strength threshold.
    """

    # For each frequency shift we perform both coherent and non-coherent
    # integration for every 1 ms period of samples and add the results. This
    # strengthens weak signals as if the 1 ms period were extended, but
    # minimises the issue of navigation bit changes affecting the magnitude
    # of the correlation. We then find the frequency shift and PRN code
    # phase that give the greatest non-coherent sum - this is the strongest
    # signal. The argument of the corresponding coherent sum is an estimate
    # of the phase of the carrier wave. Finally, the peak-to-mean ratio of
    # all correlations for the strongest frequency shift gives the strength.

    prn_code = COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID[satellite_id]
    prn_code_fft_conj = np.conj(np.fft.fft(prn_code))

    coherent_sums = np.zeros((len(frequency_shifts), len(prn_code)), dtype=complex)
    magnitude_sums = np.zeros((len(frequency_shifts), len(prn_code)))

    for i, f in enumerate(frequency_shifts):
        for j, samples_i in enumerate(samples):
            # Perform carrier wipeoff.
            shifted_samples = samples_i.samples * np.exp(
                -2j * np.pi * f * (SAMPLE_TIMES + j * 0.001)
            )

            correlation = np.fft.ifft(np.fft.fft(shifted_samples) * prn_code_fft_conj)

            coherent_sums[i] += correlation
            magnitude_sums[i] += np.abs(correlation)

    frequency_shift_index, prn_code_phase = np.unravel_index(
        np.argmax(magnitude_sums), magnitude_sums.shape
    )

    peak_correlation = magnitude_sums[frequency_shift_index, prn_code_phase]
    mean_correlation = np.mean(
        magnitude_sums[
            frequency_shift_index,
            magnitude_sums[frequency_shift_index] != peak_correlation,
        ]
    )

    return Acquisition(
        carrier_frequency_shift=frequency_shifts[frequency_shift_index],
        carrier_phase_shift=np.angle(
            coherent_sums[frequency_shift_index, prn_code_phase]
        ),
        prn_code_phase_shift=int(prn_code_phase),
        satellite_id=satellite_id,
        strength=peak_correlation / mean_correlation,
        timestamp=samples[-1].end_timestamp,
    )


================================================
FILE: gpsreceiver/gpsreceiver/antenna.py
================================================
import signal
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone
from pathlib import Path

import numpy as np
from rtlsdr import RtlSdr

from .config import SAMPLES_PER_MILLISECOND
from .constants import L1_FREQUENCY, SAMPLES_PER_SECOND, SECONDS_PER_SAMPLE
from .receiver import Receiver
from .types import OneMsOfSamples, Samples, UtcTimestamp


class Antenna(ABC):
    """An antenna that samples signals as I/Q data.

    All antennas sample at a rate of ``gpsreceiver.config.SAMPLE_RATE``.
    """

    def __init__(self, receiver: Receiver) -> None:
        self._receiver = receiver

    @abstractmethod
    def start(self) -> None:
        """Start sampling and passing the samples to the ``Receiver``.

        This method blocks.
        """

        pass


class FileAntenna(Antenna):
    """An antenna backed by a file containing I/Q data.

    It's assumed that the file contains a list of 32-bit floating point numbers
    with each pair representing a single complex value (an I/Q sample).
    """

    def __init__(
        self, path: Path, receiver: Receiver, start_timestamp: UtcTimestamp
    ) -> None:
        super().__init__(receiver)

        self._dtype = np.dtype(np.float32)
        self._file_size_in_samples = path.stat().st_size // self._dtype.itemsize // 2
        self._offset_in_samples: int = 0
        self._path = path
        self._start_timestamp = start_timestamp

    def start(self) -> None:
        while True:
            self._receiver.handle_1ms_of_samples(self._sample_1ms())

    def _sample_1ms(self) -> OneMsOfSamples:
        if (
            self._offset_in_samples + SAMPLES_PER_MILLISECOND
            >= self._file_size_in_samples
        ):
            raise EOFError("No more samples")

        data = np.fromfile(
            self._path,
            count=SAMPLES_PER_MILLISECOND * 2,
            dtype=self._dtype,
            offset=self._offset_in_samples * 2 * self._dtype.itemsize,
        )

        old_offset_in_samples = self._offset_in_samples
        self._offset_in_samples += SAMPLES_PER_MILLISECOND

        return OneMsOfSamples(
            end_timestamp=self._start_timestamp
            + timedelta(seconds=self._offset_in_samples / SAMPLES_PER_SECOND),
            samples=data[0::2] + (1j * data[1::2]),
            start_timestamp=self._start_timestamp
            + timedelta(seconds=old_offset_in_samples / SAMPLES_PER_SECOND),
        )


class RtlSdrAntenna(Antenna):
    """An antenna backed by an RTL-SDR receiver.

    It's assumed that a single RTL-SDR is connected to the computer.
    """

    def __init__(self, receiver: Receiver, gain: int) -> None:
        super().__init__(receiver)

        # pyrtlsdr requires that you receive a multiple of 512 samples at a
        # time. The number of samples we take per millisecond may not be a
        # multiple of 512, in which case there will be some "leftover" samples.
        #
        # This attribute is used to store the leftover samples which are
        # prepended to the next chunk of samples and forwarded to the receiver.
        self._samples: Samples | None = None
        self._gain: int = gain

    def start(self) -> None:
        rtl_sdr = RtlSdr()
        rtl_sdr.set_bandwidth(SAMPLES_PER_SECOND)
        rtl_sdr.set_bias_tee(True)
        rtl_sdr.set_center_freq(L1_FREQUENCY)
        rtl_sdr.set_gain(self._gain)
        rtl_sdr.set_sample_rate(SAMPLES_PER_SECOND)

        signal.signal(signal.SIGINT, lambda signal, frame: rtl_sdr.cancel_read_async())

        # The sample count must be a multiple of 512.
        rtl_sdr.read_samples_async(self._on_samples, 2048)

    def _on_samples(self, samples: np.ndarray, _: RtlSdr) -> None:
        # Concatenate the leftover samples (if any) and the new samples.
        now = datetime.now(timezone.utc)
        samples_ = Samples(
            end_timestamp=now,
            samples=samples,
            start_timestamp=now - timedelta(seconds=len(samples) * SECONDS_PER_SAMPLE),
        )
        self._samples = samples_ if self._samples is None else self._samples + samples_

        # While we have enough samples, forward them to the receiver.
        while len(self._samples.samples) > SAMPLES_PER_MILLISECOND:
            self._receiver.handle_1ms_of_samples(
                self._samples[0:SAMPLES_PER_MILLISECOND]
            )
            self._samples = self._samples[SAMPLES_PER_MILLISECOND:]


================================================
FILE: gpsreceiver/gpsreceiver/config.py
================================================
"""This module contains values that, at least in theory, could be changed to
alter the receiver's behaviour. In practice, parts of the receiver may have been
written in such a way that they won't work with other values, e.g.
``SAMPLES_PER_MILLISECOND``. That's not to say it would never be possible to
change them, just that it hasn't been tested and may not work at the moment.

Values that are derived from these (e.g. ``SAMPLES_PER_SECOND`` is derived from
``SAMPLES_PER_MILLISECOND``) should be defined in ``constants.py`` instead.
"""

from datetime import timedelta

import numpy as np

# Sampling

# A GPS satellite's navigation message (50 bps) is XORed with its C/A PRN code
# (1.023 Mbps) and the result is BPSK modulated onto a carrier wave whose
# frequency is 1575.42 MHz. BPSK modulation results in a main lobe with a
# bandwidth equal to twice the data rate - in this case that's 2.046 MHz. That
# means we can capture the majority of the signal power by sampling between
# 1573.374 MHz and 1577.466 MHz.
#
# The Shannon-Nyquist sampling theorem says that, to avoid aliasing, the
# sampling rate must be at least double the highest frequency. In this case that
# would be 3154.932 MHz which is prohibitively high. Instead we can take
# advantage of aliasing and undersample the signal to effectively shift its
# central frequency to 0 Hz. If we sample at 2.046 MHz there will be an alias
# with a central frequency at 0 Hz, effectively removing the carrier frequency.
#
# This sampling rate has the added benefit that the number of samples in 1 ms of
# data is equal to twice the number of chips in a C/A PRN code (1023). So, when
# we're trying to correlate 1 ms of a received signal with a local replica of a
# C/A PRN code we can simply repeat each code chip twice to get a signal of the
# same length. This avoids needing to e.g. pad the local replica with zeroes.
SAMPLES_PER_MILLISECOND: int = 2046

# Acquisition

# The interval between acquisition attempts.
#
# This value was chosen experimentally to balance the frequency of attempting
# acquisition and the computational cost of doing so.
ACQUISITION_INTERVAL: timedelta = timedelta(seconds=10)

# An acquisition result must have a strength above this threshold in order to be
# considered successful. Its strength is measured as the peak-to-mean ratio of
# the correlation between the received signal and the local C/A PRN code for a
# particular Doppler shift and all possible C/A PRN code phase shifts.
#
# This value was chosen experimentally.
ACQUISITION_STRENGTH_THRESHOLD: float = 3

# The IDs of all GPS satellites that we may track.
#
# ID 1 isn't included because it's not currently in use[1].
#
# 1: https://en.wikipedia.org/wiki/List_of_GPS_satellites#PRN_status_by_satellite_block
ALL_SATELLITE_IDS: set[int] = set(range(2, 33))

# During acquisition we perform both coherent and non-coherent integration over
# multiple 1 ms periods of samples and add the results. This strengthens weak
# signals as would happen if we simply extended the 1 ms period, but minimises
# the issue of navigation bit changes affecting correlation magnitude.
#
# This constant controls how many ms of data to use. Increasing it increases the
# correlation strength, but also makes it more likely we'll see a navigation bit
# change which can negatively affect the carrier wave phase estimate. That's not
# too big of an issue as the tracking loop will eventually find the right value.
MS_OF_SAMPLES_REQUIRED_TO_PERFORM_ACQUISITION: int = 10

# Tracking

# The gain to use in the PRN code phase shift tracking loop.
#
# This determines how much noise affects the loop and how quickly it can respond
# to changes in the phase shift. I don't have a deep understanding of this value
# but this is what Claude suggests and is what Gypsum uses[1].
#
# 1: https://github.com/codyd51/gypsum/blob/b9a5b4ec98557cf107f589dbffa0ad522851c14c/gypsum/tracker.py#L298
PRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN = 0.002

# The gains to use in the carrier frequency/phase shift tracking loop.
#
# These constants are also called beta and alpha in some implementations of
# Costas loops. However, unlike other Costas loops I've seen, these values are
# multiplied by the tracker update interval (currently 0.001s) when used so they
# are quite a bit larger than other loops' constants. This has the benefit that
# they don't need to be updated if the tracker update interval changes.
#
# I tried to find definitions of these values in terms of the loop's bandwidth
# and damping factor but there didn't seem to be consensus. My DSP theory isn't
# strong enough to derive them myself so the values were found experiementally.
CARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN = 20
CARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN = 500

# Navigation data demodulation

# How many bits worth of pseudosymbols must ``PseudosymbolIntegrator`` collect
# before it can determine the boundaries between navigation bits.
#
# Before ``PseudosymbolIntegrator`` can group pseudosymbols into bits it needs
# to know where one bit ends and the next begins. To do this it collects many
# pseudosymbols into an array, then it finds the offset into that array that
# best splits the pseudosymbols into like groups of 20. Now they can be grouped
# into bits. This constant determines how many "bits worth" of pseudosymbols
# (i.e. multiples of 20) must be collected before this process can occur.
BITS_REQUIRED_TO_DETECT_BOUNDARIES = 20

# How many preambles ``PseudobitIntegrator`` must detect in order to determine
# the boundaries between subframes and the overall bit phase.
PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE = 3

# HTTP server payload

# The interval at which data is sent to the HTTP server subprocess, in ms.
#
# The data can be around 1 MB in size, so we don't want to send it too often
# (otherwise the inter-process queue could become full or its feeder thread
# could take up too much CPU time and affect the receiver). On the other hand
# we don't want it to be too infrequent or the dashboard will become stale.
#
# 2 s was chosen arbitrarily.
HTTP_UPDATE_INTERVAL_MS = 2000

# The number of values to store in each tracking history buffer.
#
# This includes carrier frequency shifts, carrier phase shifts, correlations,
# and PRN code phase shifts. Divide by 1000 to get the number of seconds.
TRACKING_HISTORY_SIZE = 1000


================================================
FILE: gpsreceiver/gpsreceiver/constants.py
================================================
"""This module contains commonly used values whose definitions shouldn't change,
either because they're defined in the GPS spec (e.g. ``BITS_PER_SUBFRAME``) or
because they're derived from other values (e.g. ``SAMPLES_PER_SECOND``)."""

import numpy as np

from .config import SAMPLES_PER_MILLISECOND

# Sampling

L1_FREQUENCY = 1575.42e6

SAMPLES_PER_SECOND = SAMPLES_PER_MILLISECOND * 1000

# The time of each sample within a 1 ms sampling period (in seconds).
SAMPLE_TIMES = np.arange(SAMPLES_PER_MILLISECOND) / SAMPLES_PER_SECOND

SECONDS_PER_SAMPLE = 1 / SAMPLES_PER_SECOND

# Navigation data demodulation

# The number of bits contained within a subframe of the navigation message.
# Defined in section 20.3.2 of IS-GPS-200.
BITS_PER_SUBFRAME = 300


================================================
FILE: gpsreceiver/gpsreceiver/http_types.py
================================================
"""This module contains types that are used to send data to the HTTP server
    subprocess and are then served by that subprocess to clients."""

from typing import Annotated

from pydantic import BaseModel, Field, WithJsonSchema, field_serializer

from .types import BitPhase, SatelliteId, UtcTimestamp


class GeodeticCoordinates(BaseModel):
    """A location expressed in geodetic coordinates."""

    height: float  # meters
    latitude: float  # degrees
    longitude: float  # degrees


class GeodeticSolution(BaseModel):
    """A computed solution with the position in geodetic coordinates."""

    # See EcefSolution.clock_bias.
    clock_bias: float

    # An estimate of the receiver's position, in geodetic coordinates.
    position: GeodeticCoordinates


# Pydantic serialises complex values as strings by default but it's more
# convenient if they're two-tuples. This type tells Pydantic to treat them as
# such when generating a JSON schema (as done in generate_dashboard_types.sh).
Complex = Annotated[
    complex, WithJsonSchema({"items": {"type": "number"}, "type": "array"})
]


class TrackedSatellite(BaseModel):
    """Data regarding a tracked satellite."""

    # Whether the boundary between different bits' pseudosymbols has been found.
    bit_boundary_found: bool

    # The signal's bit phase.
    #
    # ``None`` means we haven't determined it yet.
    bit_phase: BitPhase | None

    # The most recent carrier frequency shift values.
    #
    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.
    carrier_frequency_shifts: list[float]

    # The most recent correlations of 1 ms of received signal and the prompt
    # local replica. These are used to plot a constellation diagram.
    #
    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.
    correlations: list[Complex]

    # The duration for which the satellite has been tracked, in seconds.
    duration: float

    # The most recent PRN code phase shift values.
    #
    # The size of this list is determined by ``TRACKING_HISTORY_SIZE``.
    prn_code_phase_shifts: list[float]

    # Whether the subframes required to use this satellite in solution
    # calculations (1, 2, and 3) have been received yet.
    required_subframes_received: bool

    # The satellite's ID.
    satellite_id: SatelliteId

    # The number of subframes that have been decoded from this satellite.
    subframe_count: int

    # Pydantic serialises complex values as strings by default but it's more
    # convenient if they're two-tuples. This field serialiser does that.
    @field_serializer("correlations")
    def serialize_correlations(self, correlations: list[complex]) -> list[list[float]]:
        return [[correlation.real, correlation.imag] for correlation in correlations]


class UntrackedSatellite(BaseModel):
    """Data regarding an untracked satellite."""

    # The time after which the receiver will next try to acquire this satellite.
    next_acquisition_at: UtcTimestamp

    # The satellite's ID.
    satellite_id: SatelliteId


class HttpData(BaseModel):
    """Data sent to the HTTP server subprocess to be served to clients."""

    # The most recently calculated solution (if any).
    latest_solution: GeodeticSolution | None

    # Satellites that are currently being tracked by the receiver.
    tracked_satellites: list[TrackedSatellite]

    # Satellites that aren't currently tracked by the receiver.
    untracked_satellites: list[UntrackedSatellite]


================================================
FILE: gpsreceiver/gpsreceiver/pipeline.py
================================================
from .acquirer import Acquisition
from .http_types import TrackedSatellite
from .pseudobit_integrator import PseudobitIntegrator
from .pseudosymbol_integrator import PseudosymbolIntegrator
from .subframe_decoder import SubframeDecoder
from .tracker import Tracker
from .types import OneMsOfSamples, UtcTimestamp
from .world import World


class Pipeline:
    """Processes antenna samples to update the world model of a satellite.

    The pipeline is initialised with acquisition parameters and subsequent
    samples pass through: a ``Tracker``, a ``PseudosymbolIntegrator``, a
    ``PseudobitIntegrator``, a ``SubframeDecoder``, and finally to a ``World``.
    """

    def __init__(self, acquisition: Acquisition, world: World) -> None:
        self._acquired_at = acquisition.timestamp
        self._satellite_id = acquisition.satellite_id
        self._subframe_decoder = SubframeDecoder(self._satellite_id, world)
        self._pseudobit_integrator = PseudobitIntegrator(
            acquisition.satellite_id, self._subframe_decoder
        )
        self._pseudosymbol_integrator = PseudosymbolIntegrator(
            self._pseudobit_integrator, acquisition.satellite_id
        )
        self._tracker = Tracker(acquisition, self._pseudosymbol_integrator, world)
        self._world = world

    def get_tracked_satellite(self, time: UtcTimestamp) -> TrackedSatellite:
        return TrackedSatellite(
            bit_boundary_found=self._pseudosymbol_integrator.bit_boundary_found,
            bit_phase=self._pseudobit_integrator.bit_phase,
            carrier_frequency_shifts=self._tracker.carrier_frequency_shifts,
            correlations=self._tracker.correlations,
            duration=(time - self._acquired_at).total_seconds(),
            prn_code_phase_shifts=self._tracker.prn_code_phase_shifts,
            required_subframes_received=self._world.has_required_subframes(
                self._satellite_id
            ),
            satellite_id=self._satellite_id,
            subframe_count=self._subframe_decoder.count,
        )

    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:
        self._tracker.handle_1ms_of_samples(samples)


================================================
FILE: gpsreceiver/gpsreceiver/prn_codes.py
================================================
"""This module generates the GPS satellites' C/A PRN codes.

The PRN codes are generated by XORing the output of two linear-feedback shift
registers (LFSRs). Some documents say the second LFSR is delayed by a certain
number of chips while others say the output of the second LFSR is the XOR of
different stages per satellite. It turns out these approaches are equivalent.
Here we use the latter approach because it requires generating fewer outputs.
"""

import json
import math
from typing import Iterator

import numpy as np

from .config import SAMPLES_PER_MILLISECOND
from .types import SatelliteId
from .utils import invariant


def _lfsr(outputs: list[int], taps: list[int]) -> Iterator[int]:
    """Generates the output of a 10-stage linear-feedback shift register.

    ``outputs`` contains the (one-based) indices of the bits that are used to
    calculate the LFSR's output on each iteration, e.g. if ``outputs = [1, 2]``
    the output would be ``bits[0] ^ bits[1]``.

    Similarly, ``taps`` contains the (one-based) indices of the bits that are
    used to calculate the LFSR's leftmost bit on each iteration.

    The LFSR is seeded with ones.

    One-based indices are used to better match the GPS spec.
    """
    bits = [1 for _ in range(10)]

    while True:
        output = sum([bits[i - 1] for i in outputs]) % 2
        yield output

        feedback = sum(bits[i - 1] for i in taps) % 2

        for i in range(9, 0, -1):
            bits[i] = bits[i - 1]

        bits[0] = feedback


# The (one-based) output indices used to generate each satellite's C/A PRN code,
# indexed by satellite ID.
#
# Taken from Table 3-Ia in the GPS spec[1].
#
# 1: https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf
_prn_code_outputs: dict[SatelliteId, list[int]] = {
    1: [2, 6],
    2: [3, 7],
    3: [4, 8],
    4: [5, 9],
    5: [1, 9],
    6: [2, 10],
    7: [1, 8],
    8: [2, 9],
    9: [3, 10],
    10: [2, 3],
    11: [3, 4],
    12: [5, 6],
    13: [6, 7],
    14: [7, 8],
    15: [8, 9],
    16: [9, 10],
    17: [1, 4],
    18: [2, 5],
    19: [3, 6],
    20: [4, 7],
    21: [5, 8],
    22: [6, 9],
    23: [1, 3],
    24: [4, 6],
    25: [5, 7],
    26: [6, 8],
    27: [7, 9],
    28: [8, 10],
    29: [1, 6],
    30: [2, 7],
    31: [3, 8],
    32: [4, 9],
}

# The C/A PRN codes of all GPS satellites, indexed by satellite ID.
PRN_CODES_BY_SATELLITE_ID: dict[SatelliteId, np.ndarray] = {}

for satellite_id, outputs in _prn_code_outputs.items():
    g1 = _lfsr([10], [3, 10])
    g2 = _lfsr(outputs, [2, 3, 6, 8, 9, 10])
    prn_code = np.empty(1023, np.float32)
    for i in range(1023):
        prn_code[i] = next(g1) ^ next(g2)
    PRN_CODES_BY_SATELLITE_ID[satellite_id] = prn_code

# The same C/A PRN codes as above, but upsampled so the number of chips in each
# is equal to the number of samples present in 1 ms of a received signal.
#
# This requires that SAMPLES_PER_MILLISECOND is an integer multiple of the
# length of a C/A PRN code (1023). Raises an exception if that's not the case.
invariant(
    SAMPLES_PER_MILLISECOND % len(PRN_CODES_BY_SATELLITE_ID[1]) == 0,
    "SAMPLES_PER_MILLISECOND isn't an integer multiple of the number of chips in a C/A PRN code (1023)",
)
_repeat_count = SAMPLES_PER_MILLISECOND // len(PRN_CODES_BY_SATELLITE_ID[1])
UPSAMPLED_PRN_CODES_BY_SATELLITE_ID = {
    satellite_id: np.repeat(prn_code, int(_repeat_count))
    for satellite_id, prn_code in PRN_CODES_BY_SATELLITE_ID.items()
}

# The same upsampled C/A PRN codes as above, but with 0 mapped to 1 and 1 to -1.
#
# As the data transmitted by a GPS satellite is modulated onto the carrier wave
# via BPSK, 0s and 1s result in signals that are 180 degrees out of phase. Thus,
# when we attempt to correlate a received signal with a local replica of a C/A
# PRN code we want the code chips to also be 180 degrees out of phase. Whether
# 0 is mapped to 1 and 1 to -1 or vice versa is arbitrary, but this mapping has
# the benefit that the XOR operation becomes equivalent to multiplication.
#
# This is also called polar non-return-to-zero encoding.
COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID = {
    satellite_id: np.array([-1 if b == 1 else 1 for b in prn_code])
    for satellite_id, prn_code in UPSAMPLED_PRN_CODES_BY_SATELLITE_ID.items()
}


================================================
FILE: gpsreceiver/gpsreceiver/pseudobit_integrator.py
================================================
import logging

import numpy as np

from .config import PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE
from .constants import BITS_PER_SUBFRAME
from .subframe_decoder import SubframeDecoder
from .types import Bit, BitPhase, Pseudobit, SatelliteId
from .utils import invariant

# How many pseudobits we must collect before we may attempt to determine the
# boundaries between subframes and the overall bit phase.
#
# We'll likely start collecting them part way through a subframe. This means
# that even after we've collected ``PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE``
# subframes' worth of pseudobits, the number of preambles we find will likely be one
# fewer than that. Add one to the constant to avoid this issue.
_PSEUDOBITS_REQUIRED_TO_DETERMINE_BIT_PHASE = (
    PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE + 1
) * BITS_PER_SUBFRAME

# The fixed TLM word preamble and its inverse.
#
# These are used to determine the boundaries between subframes and the overall
# bit phase. They're defined as ``Pseudobit``s rather than ``Bit``s so they
# can be matched against the collected array of ``Pseudobit``s.
_TLM_PREAMBLE: list[Pseudobit] = [1, -1, -1, -1, 1, -1, 1, 1]
_INVERSE_TLM_PREAMBLE: list[Pseudobit] = [-1, 1, 1, 1, -1, 1, -1, -1]

logger = logging.getLogger(__name__)


class PseudobitIntegrator:
    """Integrates ``Pseudobits`` into subframes.

    Each subframe is 300 bits long and starts with a telemetry word which in
    turn starts with a preamble that's the same for every subframe. We can use
    this to find the boundaries between subframes and the overall bit phase.

    This class takes ``Pseudobit``s from a ``PseudosymbolIntegrator``,
    determines which groups of 300 bits should be considered a subframe, and
    forwards the results to a ``SubframeDecoder``.
    """

    def __init__(
        self, satellite_id: SatelliteId, subframe_decoder: SubframeDecoder
    ) -> None:
        # The overall bit phase.
        #
        # ``None`` means we haven't determined it yet.
        self._bit_phase: BitPhase | None = None

        self._pseudobits: list[Pseudobit] = []
        self._satellite_id = satellite_id
        self._subframe_decoder = subframe_decoder

    @property
    def bit_phase(self) -> BitPhase | None:
        return self._bit_phase

    def handle_pseudobit(self, pseudobit: Pseudobit) -> None:
        self._pseudobits.append(pseudobit)

        # Determine the bit phase.
        if (
            len(self._pseudobits) >= _PSEUDOBITS_REQUIRED_TO_DETERMINE_BIT_PHASE
            and self._bit_phase is None
        ):
            self._determine_bit_phase()

        # Group bits into subframes as long as we have enough data.
        while (
            len(self._pseudobits) >= BITS_PER_SUBFRAME and self._bit_phase is not None
        ):
            pseudobits = self._pseudobits[:BITS_PER_SUBFRAME]
            del self._pseudobits[:BITS_PER_SUBFRAME]

            bits = [self._resolve_bit(ub) for ub in pseudobits]
            self._subframe_decoder.handle_bits(bits)

    def _determine_bit_phase(self) -> None:
        invariant(self._bit_phase is None, "The bit phase has already been determined")

        for offset in range(BITS_PER_SUBFRAME):
            pseudobits = self._pseudobits[offset:]
            determined = False

            # If each subframe in ``pseudobits`` starts with the TLM preamble
            # (or its inverse) then we've found the boundaries between subframes
            # and can determine the overall bit phase.
            #
            # If ``offset`` is non-zero then there's a partial subframe at the
            # start of ``self._pseudobits`` which can be discarded.
            if self._all_subframes_start_with_preamble(_TLM_PREAMBLE, pseudobits):
                determined = True
                self._bit_phase = 1
            elif self._all_subframes_start_with_preamble(
                _INVERSE_TLM_PREAMBLE, pseudobits
            ):
                determined = True
                self._bit_phase = -1

            if determined:
                del self._pseudobits[:offset]
                logger.info(
                    f"[{self._satellite_id}] Determined bit phase: {self._bit_phase}"
                )
                return

        raise UnknownBitPhaseError()

    def _all_subframes_start_with_preamble(
        self, preamble: list[Pseudobit], pseudobits: list[Pseudobit]
    ) -> bool:
        """Determines if all subframes in ``pseudobits`` start with ``preamble``.

        Assumes the first subframe starts at index 0, the second at 300, etc.
        If the length of ``pseudobits`` isn't an integer multiple of
        ``BITS_PER_SUBFRAME`` the leftover bits at the end are ignored.
        """
        invariant(
            len(preamble) <= len(pseudobits),
            "The preamble must be equal or shorter in length than the pseudobits",
        )
        invariant(
            len(pseudobits) >= BITS_PER_SUBFRAME,
            "Not enough pseudobits for a subframe",
        )

        for i in range(0, len(pseudobits) - (BITS_PER_SUBFRAME - 1), BITS_PER_SUBFRAME):
            if not np.array_equal(preamble, pseudobits[i : i + len(preamble)]):
                return False

        return True

    def _resolve_bit(self, pseudobit: Pseudobit) -> Bit:
        invariant(
            self._bit_phase is not None,
            "A bit can't be resolved until the bit phase is determined",
        )

        if self._bit_phase == -1:
            return 1 if pseudobit == -1 else 0
        else:
            return 0 if pseudobit == -1 else 1


class UnknownBitPhaseError(Exception):
    """Indicates that we weren't able to determine a satellite's bit phase.

    This suggests we're not tracking the satellite correctly.
    """

    pass


================================================
FILE: gpsreceiver/gpsreceiver/pseudosymbol_integrator.py
================================================
import logging
from collections import Counter

import numpy as np

from .config import BITS_REQUIRED_TO_DETECT_BOUNDARIES
from .pseudobit_integrator import PseudobitIntegrator
from .types import Pseudobit, Pseudosymbol, SatelliteId
from .utils import invariant

_PSEUDOSYMBOLS_PER_BIT = 20

# How many pseudosymbols we must collect before we may attempt to determine
# the boundaries between navigation bits.
_PSEUDOSYMBOLS_REQUIRED_TO_DETECT_BOUNDARIES = (
    BITS_REQUIRED_TO_DETECT_BOUNDARIES * _PSEUDOSYMBOLS_PER_BIT
)

# How many pseudosymbols of each phase we must collect.
#
# If we're unlucky all of the pseudosymbols will be equal and it won't be
# possible to find the best offset. To avoid this we collect a minimum number of
# pseudosymbols of each phase (-1 and +1) before attempting to calculate the
# offset. This constant determines how many of each phase must be collected.
_PSEUDOSYMBOLS_REQUIRED_PER_PHASE = _PSEUDOSYMBOLS_REQUIRED_TO_DETECT_BOUNDARIES / 2

logger = logging.getLogger(__name__)


class PseudosymbolIntegrator:
    """Integrates pseudosymbols into bits.

    When a ``Tracker`` computes the correlation between a received signal and
    the prompt local replica, the sign of the real part of the result tells us
    the phase of the navigation bit at that time. This is called a pseudosymbol.

    Pseudosymbols are computed 1000 times per second and the navigation message
    is transmitted at 50 bps, so there are 20 pseudosymbols per navigation bit.
    In theory all 20 should have the same phase. In practice, noise, samples
    taken during bit transitions, and weak signals cause some to be incorrect.

    This class takes pseudosymbols from a ``Tracker``, determines which groups
    of 20 pseudosymbols should be considered a single navigation bit, and
    forwards the results to a ``PseudobitIntegrator``.
    """

    def __init__(
        self, pseudobit_integrator: PseudobitIntegrator, satellite_id: SatelliteId
    ) -> None:
        self._bit_boundary_found = False
        self._pseudobit_integrator = pseudobit_integrator
        self._pseudosymbols: list[Pseudosymbol] = []
        self._satellite_id = satellite_id

    @property
    def bit_boundary_found(self) -> bool:
        return self._bit_boundary_found

    def handle_pseudosymbol(self, pseudosymbol: Pseudosymbol) -> None:
        self._pseudosymbols.append(pseudosymbol)

        # Find the bit boundary if necessary…
        if not self._bit_boundary_found:
            # …but only if we have enough data.
            counter = Counter(self._pseudosymbols)
            if (
                counter[-1] >= _PSEUDOSYMBOLS_REQUIRED_PER_PHASE
                and counter[1] >= _PSEUDOSYMBOLS_REQUIRED_PER_PHASE
            ):
                self._find_bit_boundary()

        # Group pseudosymbols into bits as long as we have enough data.
        while (
            len(self._pseudosymbols) >= _PSEUDOSYMBOLS_PER_BIT
            and self._bit_boundary_found
        ):
            # Extract the pseudosymbols comprising the next bit.
            pseudosymbols = self._pseudosymbols[:_PSEUDOSYMBOLS_PER_BIT]
            del self._pseudosymbols[:_PSEUDOSYMBOLS_PER_BIT]

            # Determine the (phase ambiguous) bit.
            counter = Counter(pseudosymbols)
            pseudobit: Pseudobit = counter.most_common(1)[0][0]
            self._pseudobit_integrator.handle_pseudobit(pseudobit)

    def _find_bit_boundary(self) -> None:
        invariant(not self._bit_boundary_found, "Bit boundary already found")

        # Calculate a score for each possible offset.
        #
        # If an offset is good most of the pseudosymbols within each chunk will
        # be the same and the magnitude of their sum will be large. If an offset
        # is bad the pseudosymbols within each chunk will be mixed, they will
        # cancel each other, and the magnitude of their sum will be smaller.
        #
        # An offset's score is the mean of its chunks' sums.
        offset_scores: list[float] = []
        for offset in range(_PSEUDOSYMBOLS_PER_BIT):
            chunks = _chunks(self._pseudosymbols[offset:], _PSEUDOSYMBOLS_PER_BIT)
            offset_scores.append(np.mean(np.abs(np.sum(chunks, axis=1))))

        # Find the offset with the best score. If it is non-zero then there are
        # some pseudosymbols at the start of ``self._pseudosymbols`` that we
        # won't be able to group into a bit so they must be discarded.
        best_offset = np.argmax(offset_scores)
        self._pseudosymbols = self._pseudosymbols[best_offset:]

        logger.info(f"[{self._satellite_id}] Found the bit boundary")
        self._bit_boundary_found = True


def _chunks[T](elements: list[T], chunk_size: int) -> list[list[T]]:
    """Splits ``elements`` into sub-lists of length ``chunk_size``.

    If the length of ``elements`` isn't an integer multiple of ``chunk_size``
    the leftover elements at the end of the array aren't included in the output.
    """

    return [
        elements[i : i + chunk_size]
        for i in range(0, len(elements) - (chunk_size - 1), chunk_size)
    ]


================================================
FILE: gpsreceiver/gpsreceiver/receiver.py
================================================
import asyncio
import logging
import math
from collections import deque
from dataclasses import dataclass
from multiprocessing import Process, Queue
from queue import Empty
from typing import AsyncGenerator

from aiohttp import web
from pydantic import BaseModel

from .acquirer import Acquirer
from .config import HTTP_UPDATE_INTERVAL_MS
from .http_types import (
    GeodeticCoordinates,
    GeodeticSolution,
    HttpData,
    TrackedSatellite,
    UntrackedSatellite,
)
from .pipeline import Pipeline
from .pseudobit_integrator import UnknownBitPhaseError
from .subframe_decoder import ParityError
from .types import OneMsOfSamples, SatelliteId, UtcTimestamp
from .utils import invariant
from .world import EcefCoordinates, EcefSolution, World

logger = logging.getLogger(__name__)


class Receiver:
    def __init__(self, acquirer: Acquirer, *, run_http_server: bool) -> None:
        self._acquirer = acquirer

        # Start an HTTP server in a subprocess.
        #
        # The receiver's data is periodically sent to the server via a queue
        # and the server makes it available to clients, e.g. the dashboard.
        self._http_queue: Queue = Queue()
        self._http_subprocess = Process(
            args=(self._http_queue,), daemon=True, target=_run_http_subprocess
        )

        if run_http_server:
            self._http_subprocess.start()

        # The number of ms since data was last sent to the HTTP subprocess.
        self._ms_since_sending_http_data = 0

        self._latest_solution: GeodeticSolution | None = None
        self._pipelines_by_satellite_id: dict[SatelliteId, Pipeline] = {}
        self._run_http_server = run_http_server
        self._world = World()

    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:
        acquisition = self._acquirer.handle_1ms_of_samples(
            samples, set(self._pipelines_by_satellite_id.keys())
        )

        if acquisition is not None:
            invariant(
                acquisition.satellite_id not in self._pipelines_by_satellite_id,
                f"Received acquisition for already tracked satellite {acquisition.satellite_id}",
            )

            logger.info(
                f"[{acquisition.satellite_id}] Acquired:"
                f" carrier_frequency_shift={acquisition.carrier_frequency_shift},"
                f" carrier_phase_shift={acquisition.carrier_phase_shift},"
                f" prn_code_phase_shift={acquisition.prn_code_phase_shift},"
                f" strength={acquisition.strength}"
            )

            self._pipelines_by_satellite_id[acquisition.satellite_id] = Pipeline(
                acquisition, self._world
            )

        for satellite_id, pipeline in list(self._pipelines_by_satellite_id.items()):
            try:
                pipeline.handle_1ms_of_samples(samples)
            except ParityError:
                logger.info(
                    f"[{satellite_id}] Observed parity error, dropping satellite"
                )
                self._drop_satellite(satellite_id)
            except UnknownBitPhaseError:
                logger.info(
                    f"[{satellite_id}] Unable to determine bit phase, dropping satellite"
                )
                self._drop_satellite(satellite_id)

        solution = self._world.compute_solution()
        if solution is not None:
            position = _ecef_to_geodetic(solution.position)
            logger.info(f"Found solution: {solution.clock_bias}, {position}")
            self._latest_solution = GeodeticSolution(
                clock_bias=solution.clock_bias, position=position
            )

        # Periodically send updated data to the HTTP subprocess.
        self._ms_since_sending_http_data += 1
        if self._ms_since_sending_http_data == HTTP_UPDATE_INTERVAL_MS:
            if self._run_http_server:
                self._http_queue.put(self._get_http_data(samples.end_timestamp))
            self._ms_since_sending_http_data = 0

    def _drop_satellite(self, satellite_id: SatelliteId) -> None:
        """Stop tracking a satellite and remove it from the world model.

        This is called when we lose lock on a satellite.
        """

        del self._pipelines_by_satellite_id[satellite_id]
        self._world.drop_satellite(satellite_id)

    def _get_http_data(self, time: UtcTimestamp) -> HttpData:
        return HttpData(
            latest_solution=self._latest_solution,
            tracked_satellites=[
                pipeline.get_tracked_satellite(time)
                for pipeline in self._pipelines_by_satellite_id.values()
            ],
            untracked_satellites=self._acquirer.untracked_satellites,
        )


def _run_http_subprocess(queue: Queue) -> None:
    data: HttpData | None = None

    async def handler(request: web.Request) -> web.Response:
        return web.Response(
            content_type="application/json",
            headers={"Access-Control-Allow-Origin": "*"},
            text="null" if data is None else data.model_dump_json(),
        )

    async def check_for_data() -> None:
        while True:
            try:
                arg = queue.get(False)
                invariant(
                    isinstance(arg, HttpData),
                    f"Invalid argument sent to HTTP server subprocess: {arg}",
                )

                nonlocal data
                data = arg
            except Empty:
                pass

            await asyncio.sleep(0.001)

    async def data_checker_ctx(app: web.Application) -> AsyncGenerator[None, None]:
        data_checker = asyncio.create_task(check_for_data())

        yield

        data_checker.cancel()
        await data_checker

    app = web.Application()
    app.add_routes([web.get("/", handler)])
    app.cleanup_ctx.append(data_checker_ctx)
    web.run_app(app, print=lambda x: None)


def _ecef_to_geodetic(ecef: EcefCoordinates) -> GeodeticCoordinates:
    """Converts ECEF coordinates to geodetic coordinates.

    Uses Bowring's method[1].

    1: https://en.wikipedia.org/wiki/Geographic_coordinate_conversion#Simple_iterative_conversion_for_latitude_and_height
    """

    # WGS 84 constants.
    a = 6378137.0
    b = 6356752.314245
    e = math.sqrt(1 - (b / a) ** 2)

    # Set h = 0 to get an initial latitude estimate.
    p = math.sqrt(ecef.x**2 + ecef.y**2)
    latitude = math.atan2(ecef.z, p * (1 - e**2))

    # Iteratively calculate latitude.
    for _ in range(5):
        n = a / math.sqrt(1 - (e * math.sin(latitude)) ** 2)
        height = p / math.cos(latitude) - n
        latitude = math.atan2(ecef.z, p * (1 - e**2 * n / (n + height)))

    longitude = math.atan2(ecef.y, ecef.x)

    # Calculate height using the final latitude.
    n = a / math.sqrt(1 - (e * math.sin(latitude)) ** 2)
    height = p / math.cos(latitude) - n

    return GeodeticCoordinates(
        height=height,
        # Convert to degrees.
        latitude=latitude / math.pi * 180,
        longitude=longitude / math.pi * 180,
    )


================================================
FILE: gpsreceiver/gpsreceiver/subframe_decoder.py
================================================
import logging
from typing import cast

from .constants import BITS_PER_SUBFRAME
from .subframes import (
    Handover,
    Subframe,
    Subframe1,
    Subframe2,
    Subframe3,
    Subframe4,
    Subframe5,
    SubframeId,
)
from .types import Bit, SatelliteId
from .utils import InvariantError, invariant, parse_int_from_bits
from .world import World

_BITS_PER_WORD = 30

# The first 24 bits in a word are data bits, the following 6 are parity bits.
_DATA_BITS_PER_WORD = 24


logger = logging.getLogger(__name__)


class SubframeDecoder:
    """Decodes subframes.

    This class takes subframe ``Bit``s from a ``PseudobitIntegrator``, decodes
    them into instances of data classes, and forwards them to the ``World``.
    """

    def __init__(self, satellite_id: SatelliteId, world: World) -> None:
        # The number of subframes that have been decoded.
        self._count = 0

        self._satellite_id = satellite_id
        self._world = world

    @property
    def count(self) -> int:
        return self._count

    def handle_bits(self, bits: list[Bit]) -> None:
        subframe = _SubframeDecoder(bits).decode()
        logger.info(
            f"[{self._satellite_id}] Decoded subframe {subframe.handover.subframe_id}"
        )
        self._count += 1
        self._world.handle_subframe(self._satellite_id, subframe)


class _SubframeDecoder:
    """Implements the decoding logic.

    This is separate from ``SubframeDecoder`` because decoding requires some
    state and it's easier to create and discard an instance of this class for
    each subframe than ensure we reset state appropriately for each subframe.
    """

    def __init__(self, transmitted: list[Bit]) -> None:
        self._cursor = 0
        self._data = _decode_subframe_data(transmitted)

    def decode(self) -> Subframe:
        self._decode_telemetry()

        handover = self._decode_handover()
        match handover.subframe_id:
            case 1:
                return self._decode_subframe_1(handover)

            case 2:
                return self._decode_subframe_2(handover)

            case 3:
                return self._decode_subframe_3(handover)

            case 4:
                return self._decode_subframe_4(handover)

            case 5:
                return self._decode_subframe_5(handover)

            case _:
                raise InvariantError(f"Invalid subframe ID: {handover.subframe_id}")

    def _decode_telemetry(self) -> None:
        # We don't need anything from the TLM word so nothing is returned from
        # this method, but we still need to parse it to move the cursor past.

        # The preamble is fixed.
        preamble = self._get_bits(8)
        invariant(preamble == [1, 0, 0, 0, 1, 0, 1, 1], "Invalid TLM preamble")

        # The TLM message contains information needed for the precise
        # positioning service. We can't use that, so ignore it.
        self._skip_bits(14)

        # Integrity status flag.
        self._get_bit()

        # The last data bit is reserved.
        self._skip_bits(1)

    def _decode_handover(self) -> Handover:
        tow_count_msbs = self._get_bits(17)

        # Alert flag.
        self._get_bit()

        # Anti-spoof flag.
        self._get_bit()

        # A subframe ID may only be 1 through 5, inclusive.
        subframe_id = self._get_int(3)
        invariant(subframe_id in [1, 2, 3, 4, 5], f"Invalid subframe ID: {subframe_id}")

        # Parity bits.
        self._skip_bits(2)

        return Handover(tow_count_msbs, cast(SubframeId, subframe_id))

    def _decode_subframe_1(self, handover: Handover) -> Subframe1:
        # GPS week number mod 1024.
        self._get_int(10)

        # Code(s) on L2 channel.
        self._get_bits(2)

        # URA index.
        self._get_bits(4)

        sv_health = self._get_bits(6)

        # Issue of data, clock (IODC) MSBs.
        self._get_bits(2)

        # Data flag for L2 P-Code.
        self._get_bit()

        # Reserved.
        self._skip_bits(87)

        t_gd = self._get_float(8, -31, True)

        # IODC LSBs.
        self._get_bits(8)

        t_oc = self._get_float(16, 4, False)
        a_f2 = self._get_float(8, -55, True)
        a_f1 = self._get_float(16, -43, True)
        a_f0 = self._get_float(22, -31, True)

        # Parity bits.
        self._skip_bits(2)

        return Subframe1(
            handover,
            sv_health,
            t_gd,
            t_oc,
            a_f2,
            a_f1,
            a_f0,
        )

    def _decode_subframe_2(self, handover: Handover) -> Subframe2:
        # Issue of data (ephemeris).
        self._get_bits(8)

        c_rs = self._get_float(16, -5, True)
        delta_n = self._get_float(16, -43, True)
        m_0 = self._get_float(32, -31, True)
        c_uc = self._get_float(16, -29, True)
        e = self._get_float(32, -33, False)
        c_us = self._get_float(16, -29, True)
        sqrt_a = self._get_float(32, -19, False)
        t_oe = self._get_float(16, 4, False)

        # Fit interval flag.
        self._get_bit()

        # Age of data offset.
        self._get_bits(5)

        # Parity bits.
        self._skip_bits(2)

        return Subframe2(
            handover,
            c_rs,
            delta_n,
            m_0,
            c_uc,
            e,
            c_us,
            sqrt_a,
            t_oe,
        )

    def _decode_subframe_3(self, handover: Handover) -> Subframe3:
        c_ic = self._get_float(16, -29, True)
        omega_0 = self._get_float(32, -31, True)
        c_is = self._get_float(16, -29, True)
        i_0 = self._get_float(32, -31, True)
        c_rc = self._get_float(16, -5, True)
        omega = self._get_float(32, -31, True)
        omega_dot = self._get_float(24, -43, True)

        # Issue of data (ephemeris).
        self._get_bits(8)

        i_dot = self._get_float(14, -43, True)

        # Parity bits.
        self._skip_bits(2)

        return Subframe3(
            handover,
            c_ic,
            omega_0,
            c_is,
            i_0,
            c_rc,
            omega,
            omega_dot,
            i_dot,
        )

    def _decode_subframe_4(self, handover: Handover) -> Subframe4:
        # We don't need anything from subframe 4 other than the TOW count.
        return Subframe4(handover)

    def _decode_subframe_5(self, handover: Handover) -> Subframe5:
        # We don't need anything from subframe 5 other than the TOW count.
        return Subframe5(handover)

    def _get_bit(self) -> Bit:
        [bit] = self._get_bits(1)
        return bit

    def _get_bits(self, bit_count: int) -> list[Bit]:
        invariant(
            self._cursor + bit_count <= len(self._data),
            "Can't read past end of subframe",
        )

        bits = self._data[self._cursor : self._cursor + bit_count]
        self._cursor += bit_count
        return bits

    def _get_bool(self) -> bool:
        return self._get_bit() == 1

    def _get_float(
        self, bit_count: int, scale_factor_exponent: int, twos_complement: bool
    ) -> float:
        """Reads ``bit_count`` bits as an integer, optionally interprets it in
        two's complement representation, multiplies it by 2 to the power of
        ``scale_factor_exponent``, and returns the result as a ``float``.
        """
        number = self._get_int(bit_count)

        # If we're to interpret the number in two's complement representation
        # and the most significant bit is 1, convert it to a negative number.
        if twos_complement and number & (1 << (bit_count - 1)):
            number -= 1 << bit_count

        return number * 2**scale_factor_exponent

    def _get_int(self, bit_count: int) -> int:
        return parse_int_from_bits(self._get_bits(bit_count))

    def _skip_bits(self, bit_count: int) -> None:
        self._get_bits(bit_count)


def _decode_subframe_data(subframe_transmitted: list[Bit]) -> list[Bit]:
    """Decodes a subframe's data bits from its transmitted bits.

    As per section 20.3.5 of IS-GPS-200, a subframe's source data bits are
    transformed before transmission. This function takes the transmitted bits
    and attempts to decode the source data bits, checking parity in the process.
    Raises a ``ParityError`` if any parity checks fail.

    Parity bits aren't included in the returned value. This means that the input
    list has a length of 300 but the output list has a length of 240.
    """

    # Decoding the data bits is quite simple. A subframe contains 10 words and
    # each word contains 30 bits. The first 24 bits of each word are data bits
    # and the following 6 bits are parity bits. Each data bit has been XORed
    # with bit 30 of the previous word. To undo this we just XOR it again.
    #
    # Checking the parity bits is also reasonably straightforward. Table 20-XIV
    # of IS-GPS-200 lists how each parity bit is computed using either bit 29 or
    # 30 from the previous word and a subset of the data bits. We perform the
    # same computations and ensure they equal what was transmitted.

    invariant(
        len(subframe_transmitted) == BITS_PER_SUBFRAME,
        f"Invalid number of bits to decode subframe. Expected 300, got: {len(subframe_transmitted)}",
    )

    subframe_data: list[Bit] = []

    # For the first word we assume bits 29 and 30 of the "previous word" are 0.
    last_word_bit_29: Bit = 0
    last_word_bit_30: Bit = 0

    for i in range(0, BITS_PER_SUBFRAME, _BITS_PER_WORD):
        word_transmitted = subframe_transmitted[i : i + _BITS_PER_WORD]
        word_data: list[Bit] = []

        for j in range(_DATA_BITS_PER_WORD):
            word_data.append(cast(Bit, word_transmitted[j] ^ last_word_bit_30))

        _verify_parity(
            word_transmitted[24],
            last_word_bit_29,
            word_data,
            [1, 2, 3, 5, 6, 10, 11, 12, 13, 14, 17, 18, 20, 23],
        )

        _verify_parity(
            word_transmitted[25],
            last_word_bit_30,
            word_data,
            [2, 3, 4, 6, 7, 11, 12, 13, 14, 15, 18, 19, 21, 24],
        )

        _verify_parity(
            word_transmitted[26],
            last_word_bit_29,
            word_data,
            [1, 3, 4, 5, 7, 8, 12, 13, 14, 15, 16, 19, 20, 22],
        )

        _verify_parity(
            word_transmitted[27],
            last_word_bit_30,
            word_data,
            [2, 4, 5, 6, 8, 9, 13, 14, 15, 16, 17, 20, 21, 23],
        )

        _verify_parity(
            word_transmitted[28],
            last_word_bit_30,
            word_data,
            [1, 3, 5, 6, 7, 9, 10, 14, 15, 16, 17, 18, 21, 22, 24],
        )

        _verify_parity(
            word_transmitted[29],
            last_word_bit_29,
            word_data,
            [3, 5, 6, 8, 9, 10, 11, 13, 15, 19, 22, 23, 24],
        )

        subframe_data += word_data
        last_word_bit_29 = word_transmitted[28]
        last_word_bit_30 = word_transmitted[29]

    return subframe_data


class ParityError(Exception):
    """Indicates that one or more parity bits in a subframe were invalid."""

    pass


def _verify_parity(
    transmitted_parity: Bit,
    previous_word_parity: Bit,
    word_data: list[Bit],
    word_data_indices: list[int],
) -> None:
    """Uses an equation from table 20-XIV of IS-GPS-200 to compute a parity bit
    and asserts that the computed value equals the transmitted value.

    Raises a ``ParityError`` if the values aren't equal.

    ``word_data_indices`` are 1-based to match the definitions in the table.
    """

    computed_parity: Bit = cast(
        Bit,
        (previous_word_parity + sum([word_data[i - 1] for i in word_data_indices])) % 2,
    )

    if computed_parity != transmitted_parity:
        raise ParityError()


================================================
FILE: gpsreceiver/gpsreceiver/subframes.py
================================================
from dataclasses import dataclass
from typing import Literal

from .types import Bit

SubframeId = Literal[1, 2, 3, 4, 5]


@dataclass
class Handover:
    """A handover word (HOW).

    See section 20.3.3.2 of IS-GPS-200 for more information.
    """

    # The time-of-week (TOW) count at the leading edge of the next subframe.
    #
    # The TOW count is a 19 bit value representing the number of X1 epochs
    # (1.5 s periods) that have occurred since the start of the week. This field
    # contains the 17 most significant bits (MSBs) of the TOW count as it will
    # be at the leading edge of the next subframe. With 17 bits we have a
    # granularity of 1.5 s * 2^2 = 6 s which is exactly how long it takes to
    # transmit a subframe. Thus, the two LSBs aren't even necessary!
    tow_count_msbs: list[Bit]

    subframe_id: SubframeId


@dataclass
class Subframe:
    handover: Handover


@dataclass
class Subframe1(Subframe):
    """Subframe 1.

    See section 20.3.3.3 of IS-GPS-200 for more information.
    """

    # A 6 bit field indicating the health of the satellite's navigation data.
    #
    # If the MSB is 0 the data is healthy, if it's 1 the data is unhealthy in
    # some way. The next 5 bits indicate the health of different components.
    sv_health: list[Bit]

    t_gd: float  # seconds
    t_oc: float  # seconds
    a_f2: float  # seconds/second^2
    a_f1: float  # seconds/second
    a_f0: float  # seconds


@dataclass
class Subframe2(Subframe):
    """Subframe 2.

    See section 20.3.3.4 of IS-GPS-200 for more information.
    """

    c_rs: float  # meters
    delta_n: float  # semi-circles/second
    m_0: float  # semi-circles
    c_uc: float  # radians
    e: float  # dimensionless
    c_us: float  # radians
    sqrt_a: float  # √meters
    t_oe: float  # seconds


@dataclass
class Subframe3(Subframe):
    """Subframe 3.

    See section 20.3.3.4 of IS-GPS-200 for more information.
    """

    c_ic: float  # radians
    omega_0: float  # semi-circles
    c_is: float  # radians
    i_0: float  # semi-circles
    c_rc: float  # meters
    omega: float  # semi-circles
    omega_dot: float  # semi-circles/second
    i_dot: float  # semi-circles/second


@dataclass
class Subframe4(Subframe):
    """Subframe 4.

    See section 20.3.3.5 of IS-GPS-200 for more information.
    """

    # We don't need anything from subframe 4 other than the TOW count.
    pass


@dataclass
class Subframe5(Subframe):
    """Subframe 5.

    See section 20.3.3.5 of IS-GPS-200 for more information.
    """

    # We don't need anything from subframe 5 other than the TOW count.
    pass


================================================
FILE: gpsreceiver/gpsreceiver/tracker.py
================================================
import math
from collections import deque
from datetime import timedelta

import numpy as np
from typing_extensions import assert_never

from .acquirer import Acquisition
from .config import (
    CARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN,
    CARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN,
    PRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN,
    TRACKING_HISTORY_SIZE,
)
from .constants import L1_FREQUENCY, SAMPLE_TIMES
from .prn_codes import COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID
from .pseudosymbol_integrator import PseudosymbolIntegrator
from .types import OneMsOfSamples, Side
from .utils import invariant
from .world import World


class Tracker:
    """Tracks a satellite's signal and decodes pseudosymbols."""

    def __init__(
        self,
        acquisition: Acquisition,
        pseudosymbol_integrator: PseudosymbolIntegrator,
        world: World,
    ) -> None:
        # The most recent estimates of the carrier's frequency shift in Hz.
        self._carrier_frequency_shifts = deque[float](
            [acquisition.carrier_frequency_shift], maxlen=TRACKING_HISTORY_SIZE
        )

        # The most recent estimates of the carrier's phase shift in radians.
        self._carrier_phase_shifts = deque[float](
            [acquisition.carrier_phase_shift], maxlen=TRACKING_HISTORY_SIZE
        )

        # The most recent correlations of 1 ms of received signal and the prompt
        # local replica. These can be used to plot a constellation diagram.
        self._correlations = deque[complex]([], maxlen=TRACKING_HISTORY_SIZE)

        # The satellite's complex, upsampled PRN code.
        self._prn_code = COMPLEX_UPSAMPLED_PRN_CODES_BY_SATELLITE_ID[
            acquisition.satellite_id
        ]

        self._prn_code_length = len(self._prn_code)

        # The most recent estimates of the PRN code's phase shift in half-chips.
        #
        # The values are floats because the delay-locked-loop that tracks the
        # PRN code phase shift gradually changes it by adding floating-point
        # values. When we actually need to use them we cast them to integers.
        self._prn_code_phase_shifts = deque[float](
            [acquisition.prn_code_phase_shift], maxlen=TRACKING_HISTORY_SIZE
        )

        self._pseudosymbol_integrator = pseudosymbol_integrator
        self._satellite_id = acquisition.satellite_id

        # The PRN code phase shift is typically non-zero, i.e. PRN codes in the
        # signal aren't aligned with the receiver's sample chunks. This means
        # that each chunk contains the end of one PRN code (the "left" side of
        # the chunk) followed by the start of another (the "right" side). The
        # larger of the two determines the chunk's correlation with the local
        # replica. Their sizes are determined by the PRN code phase shift.
        #
        # For example, in this diagram PRN code n + 1 is larger so it determines
        # the chunk's correlation, which determines the pseudosymbol, etc.
        #
        #   Start of 1 ms chunk          End of 1 ms chunk
        #                     ▼          ▼
        #                     +---+------+
        # End of PRN code n ▶ |   |      | ◀ Start of PRN code n + 1
        #                     +---+------+
        #                         ▲
        #                         PRN code phase shift
        #
        # This attribute stores which side was larger on initialisation or after
        # the last PRN code phase shift wrap (whichever happened last). We must
        # track this because it affects PRN code counting, which affects time
        # calculation. For example, if the right side is dominant at the end of
        # a subframe we haven't seen the trailing edge of its last PRN code and
        # thus we're not at the next subframe's TOW yet. If we increment the PRN
        # count on receiving the next sample chunk (when we actually see the end
        # of the previous subframe) we'll introduce a 1 ms (~300 km) error.
        self._side = (
            Side.LEFT
            if self._prn_code_phase_shift > self._prn_code_length / 2
            else Side.RIGHT
        )

        self._world = world

    @property
    def carrier_frequency_shifts(self) -> list[float]:
        return list(self._carrier_frequency_shifts)

    @property
    def correlations(self) -> list[complex]:
        return list(self._correlations)

    def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:
        """Uses 1 ms of samples to determine the transmitted pseudosymbol and
        update tracking parameters."""

        # Perform carrier wipeoff.
        shifted_samples = samples.samples * np.exp(
            -1j
            * (
                2 * np.pi * self._carrier_frequency_shift * SAMPLE_TIMES
                + self._carrier_phase_shift
            )
        )

        # Update the PRN code phase shift.
        #
        # PRN code phase shift wrapping may impact ``PseudoSymbolIntegrator``
        # synchronisation but I haven't though about it too much. If so, it will
        # eventually produce rubbish bits, they will cause parity errors, the
        # satellite will be dropped, then re-acquired, and all will be well.
        wrap_side = self._track_prn_code_phase_shift(shifted_samples)

        # Determine how many trailing edges of PRN codes we've observed in this
        # 1 ms period (if any) and handle wrapping of the PRN code phase shift.
        if wrap_side is None:
            prn_count = 1
        elif wrap_side == Side.LEFT:
            # Wrapping past the left side means we've observed one additional
            # trailing edge of a PRN code and the left side is now dominant.
            prn_count = 2
            self._side = Side.LEFT
        elif wrap_side == Side.RIGHT:
            # Wrapping past the right side means we've observed one fewer
            # trailing edge of a PRN code and the right side is now dominant.
            prn_count = 0
            self._side = Side.RIGHT
        else:
            assert_never(wrap_side)

        # Report the current side and the number of PRN codes that were observed
        # to the ``World`` instance for use in its time calculations.
        self._world.handle_prns_tracked(
            prn_count,
            self._satellite_id,
            self._side,
            # Calculate the time of the trailing edge of the last PRN code.
            samples.start_timestamp
            + timedelta(
                seconds=self._prn_code_phase_shift / self._prn_code_length / 1000
            ),
        )

        # Calculate the correlation of the shifted samples and the prompt local
        # replica. If our estimates are good, multiplying the shifted samples by
        # the prompt local replica removes the PRN code, leaving only the
        # navigation bit. The correlation is thus the navigation by times the
        # number of samples per millisecond - hopefully a (mostly) real number.
        #
        # No need to take the conjucate of the replica - it only contains ±1.
        prn_code = np.roll(self._prn_code, int(self._prn_code_phase_shift))
        correlation = np.sum(shifted_samples * prn_code)
        self._correlations.append(correlation)

        # Decode and handle the pseudosymbol.
        self._pseudosymbol_integrator.handle_pseudosymbol(
            -1 if correlation.real < 0 else 1
        )

        # Update the carrier wave frequency/phase shift.
        self._track_carrier(correlation)

    @property
    def prn_code_phase_shifts(self) -> list[float]:
        return list(self._prn_code_phase_shifts)

    @property
    def _carrier_frequency_shift(self) -> float:
        """Returns the most recent estimate of the carrier wave's frequency
        shift in Hz."""

        invariant(len(self._carrier_frequency_shifts) > 0)
        return self._carrier_frequency_shifts[-1]

    @property
    def _carrier_phase_shift(self) -> float:
        """Returns the most recent estimate of the carrier wave's phase shift in
        radians."""

        invariant(len(self._carrier_phase_shifts) > 0)
        return self._carrier_phase_shifts[-1]

    def _track_prn_code_phase_shift(self, shifted_samples: np.ndarray) -> Side | None:
        """Tracks the C/A PRN code's phase shift using a delay-locked loop.

        Returns the side over which the phase shift wrapped (if any). For
        example, if it becomes negative (moves past the "left" side) the PRN
        length is added to wrap it (to the "right" side). This is required
        because wrapping from the left to the right means we've seen one extra
        PRN code and the opposite means we've seen one fewer.
        """

        # Generate replicas that are early and late by a half-chip.
        early = np.roll(self._prn_code, int(self._prn_code_phase_shift - 1))
        late = np.roll(self._prn_code, int(self._prn_code_phase_shift + 1))

        # Calculate the correlation of the shifted samples and the replicas.
        #
        # No need to take the conjugate of the replicas - they only contain ±1.
        early_correlation = np.sum(shifted_samples * early)
        late_correlation = np.sum(shifted_samples * late)

        # Calculate the discriminator.
        #
        # This the non-coherent early minus late power discriminator. It is
        # defined here[1], is used by Gypsum[2], and was suggested by Claude.
        #
        # 1: https://gssc.esa.int/navipedia/index.php/Delay_Lock_Loop_(DLL)#Discriminators
        # 2: https://github.com/codyd51/gypsum/blob/b9a5b4ec98557cf107f589dbffa0ad522851c14c/gypsum/tracker.py#L297
        discriminator = (
            (early_correlation.real**2 + early_correlation.imag**2)
            - (late_correlation.real**2 + late_correlation.imag**2)
        ) / 2

        # Calculate the number of additional (or fewer) half chips that will be
        # present in 1 ms of samples due to Doppler shift of the carrier wave.
        #
        # If a satellite's carrier wave has been Doppler shifted, so too will
        # the PRN code within. It will stretch or shrink in time and it will no
        # longer be the case that 1 ms of samples contains exactly one cycle.
        # We need to account for this when updating the phase shift, otherwise
        # the differences will accumulate over time and we'll lose lock.
        half_chips_due_to_doppler_effect = (
            len(self._prn_code) * self._carrier_frequency_shift / L1_FREQUENCY
        )

        # Update the PRN code phase shift.
        prn_code_phase_shift = (
            self._prn_code_phase_shift
            - discriminator * PRN_CODE_PHASE_SHIFT_TRACKING_LOOP_GAIN
            - half_chips_due_to_doppler_effect
        )

        # If it wraps, record over which side.
        wrap_side: Side | None = None

        if prn_code_phase_shift < 0:
            prn_code_phase_shift += self._prn_code_length
            prn_count_adjustment = 1
            wrap_side = Side.LEFT
        elif prn_code_phase_shift >= self._prn_code_length:
            prn_code_phase_shift -= self._prn_code_length
            wrap_side = Side.RIGHT

        self._prn_code_phase_shifts.append(prn_code_phase_shift)
        return wrap_side

    @property
    def _prn_code_phase_shift(self) -> float:
        """Returns the most recent estimate of the C/A PRN code's phase shift in
        half-chips."""

        invariant(len(self._prn_code_phase_shifts) > 0)
        return self._prn_code_phase_shifts[-1]

    def _track_carrier(self, correlation: complex) -> None:
        """Tracks the carrier wave's frequency and phase shifts using a Costas
        loop.

        ``correlation`` is the correlation between the (post wipeoff) received
        signal and the prompt local replica of the PRN code.
        """

        # The received signal can be expressed as
        # n(t) * prn_code(t) * exp(2 π ((f + Δf) t + θ)) where n(t) = ±1 is the
        # navigation bit at time t, prn_code(t) = ±1 is the PRN code chip at
        # time t, exp(...) is the exponential function, f is the L1 frequency
        # 1.56542 GHz, Δf is the signal's frequency shift due to the Doppler
        # effect, and θ is the phase shift of the carrier wave.
        #
        # If we undersample the antenna such that the L1 frequency is aliased at
        # 0 Hz, f disappears from this expression leaving
        # n(t) * prn_code(t) * exp(2 π (Δf t + θ)).
        #
        # If we're tracking the carrier wave well (i.e. our estimates of Δf and
        # θ are good), carrier wipeoff removes the exponential term leaving
        # n(t) * prn_code(t).
        #
        # If we're tracking the PRN code phase shift well (i.e. our local
        # replica is equal to prn_code(t)) then multiplying the (post wipeoff)
        # signal by the local replica leaves
        #
        #     n(t) * prn_code(t) * prn_code(t)
        #     = n(t) * prn_code(t) ** 2
        #     = n(t) * (±1) ** 2
        #     = n(t).
        #
        # In other words, if we're tracking the signal well, the correlation of
        # the (post wipeoff) signal and the local replica of the PRN code will
        # be the value of the navigation bit during that period multiplied by
        # the sampling rate - a real value. If we find that the correlation
        # isn't (at least mostly) real, our estimates of Δf, θ, and/or the PRN
        # code phase shift are wrong. For this reason we use the complex
        # argument of the correlation as the error signal in this loop.

        # Normalise the correlation so the loop is independent of signal
        # amplitude. A small epsilon value is added to avoid numerical
        # instability when the correlation itself has a small magnitude.
        correlation /= abs(correlation) + 1e-8

        # Calculate the error signal.
        #
        # We want each correlation to be a real value, i.e. have no imaginary
        # component. Some correlations correspond to binary 0s and will lay on
        # one side of the Q axis while others correspond to binary 1s and will
        # lay on the other side. This 180° phase shift between 0s and 1s means
        # we can't simply use the complex argument of the correlation as the
        # error signal as it would try to move both to the positive I axis.
        # Instead we use the complex argument as calculated by ``math.atan``
        # which is restricted to the range [-π/2, π/2]. This means the error
        # signal will tend to move correlations towards their closest I axis.
        #
        # Later on we determine the overall phase, i.e. are correlations on the
        # negative I axis 0s and those on the positive I axis 1s, or vice versa?
        error = (
            0
            if correlation.real == 0
            else math.atan(correlation.imag / correlation.real)
        )

        # The interval between successive Tracker updates in seconds.
        tracker_update_interval = 0.001

        # Update the carrier wave's frequency shift.
        self._carrier_frequency_shifts.append(
            self._carrier_frequency_shift
            + CARRIER_FREQUENCY_SHIFT_TRACKING_LOOP_GAIN
            * error
            * tracker_update_interval
        )

        # Update the carrier wave's phase shift.
        #
        # It's important that this include the current estimate of the carrier
        # frequency shift to account for the change in phase it will cause in
        # between Tracker updates. This is why we update the estimate of the
        # carrier frequency shift first - to ensure we're using the latest data.
        carrier_phase_shift = (
            self._carrier_phase_shift
            + (
                CARRIER_PHASE_SHIFT_TRACKING_LOOP_GAIN * error
                + 2 * np.pi * self._carrier_frequency_shift
            )
            * tracker_update_interval
        )
        carrier_phase_shift %= 2 * np.pi
        self._carrier_phase_shifts.append(carrier_phase_shift)


================================================
FILE: gpsreceiver/gpsreceiver/types.py
================================================
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from typing import Annotated, Literal

import numpy as np
from pydantic import Field

from .constants import SAMPLES_PER_SECOND, SECONDS_PER_SAMPLE

# A bit.
#
# This is the result of ``PseudobitIntegrator`` determining the overall bit
# phase and applying it to an ``Pseudobit``. There's no phase ambiguity here.
Bit = Literal[0, 1]

# A signal's bit phase.
#
# -1 means -1 maps to 1 and 1 maps to 0. 1 means the opposite.
BitPhase = Literal[-1, 1]

# A pseudosymbol emitted by a ``Tracker``.
#
# Can also be considered one twentieth of a navigation bit.
#
# Defined as -1 or 1 rather than 0 or 1 because the latter suggests we know how
# pseudosymbols map to bits. However, due to the phase ambiguity of BPSK, we
# don't know how they map until the overall phase of the signal is determined.
Pseudosymbol = Literal[-1, 1]


@dataclass(kw_only=True)
class Samples:
    """A set of samples taken at a rate of ``constants.SAMPLES_PER_SECOND``."""

    # The time just after the last sample was taken.
    end_timestamp: UtcTimestamp

    # The samples.
    #
    # Has shape ``(n,)`` where ``n`` is the number of samples that were taken
    # and contains ``np.complex64`` values.
    samples: np.ndarray

    # The time just before the first sample was taken.
    start_timestamp: UtcTimestamp

    def __add__(self, other: Samples) -> Samples:
        """Concatenates two sets of samples.

        When concatenating two sets of samples ``x + y``, it is assumed that
        ``y`` immediately follows ``x`` in time. This means it makes sense to:

        - use the start timestamp of ``x`` as the start timestamp of the result,
        - use the end timestamp of ``y`` as the end timestamp of the result, and
        - use the concatentation of ``x``'s samples followed by ``y``'s samples
          as the samples of the result.
        """

        return Samples(
            end_timestamp=other.end_timestamp,
            samples=np.concatenate((self.samples, other.samples)),
            start_timestamp=self.start_timestamp,
        )

    def __getitem__(self, key: slice) -> Samples:
        """Returns a subset of the samples.

        For example, if ``x`` contains 2046 samples then ``x[0 : 1023]``
        contains the first 1023 samples with appropriate timestamps.

        Empty slices and negative indices aren't supported.
        """

        # Check that the slice bounds are of the correct type.
        if not (
            (isinstance(key.start, int) or key.start is None)
            and (isinstance(key.stop, int) or key.stop is None)
            and key.step is None
        ):
            raise TypeError("Invalid slice")

        start = 0 if key.start is None else key.start
        stop = len(self.samples) if key.stop is None else key.stop

        # Check that the slice bounds are valid indices.
        if not (
            start >= 0
            and start < len(self.samples)
            and stop >= 0
            and stop <= len(self.samples)
            and stop > start
        ):
            raise IndexError("Invalid slice")

        return Samples(
            end_timestamp=self.start_timestamp
            + timedelta(seconds=stop * SECONDS_PER_SAMPLE),
            samples=self.samples[start:stop],
            start_timestamp=self.start_timestamp
            + timedelta(seconds=start * SECONDS_PER_SAMPLE),
        )


# 1 ms of samples.
#
# This type primarily exists for documentation purposes.
OneMsOfSamples = Samples


# The ID of a GPS satellite based on its PRN number. This can be an integer
# between 1 and 32 inclusive, but PRN number 1 is not currently in use[1].
#
# 1: https://en.wikipedia.org/wiki/List_of_GPS_satellites#PRN_status_by_satellite_block
SatelliteId = Annotated[int, Field(ge=1, le=32)]


class Side(Enum):
    """A side of a chunk of samples."""

    # The left side (earlier in time).
    LEFT = 0

    # The right side (later in time).
    RIGHT = 1


# A phase ambiguous bit emitted by a ``PseudosymbolIntegrator``.
#
# ``PseudosymbolIntegrator`` identifies groups of pseudosymbols that correspond
# to the same underlying navigation bit, determines the predominant phase within
# that group, and emits the result. We can't call these navigation bits yet
# because we haven't applied the bit phase. This is one of those values.
Pseudobit = Literal[-1, 1]

# A datetime in the UTC time zone.
#
# The time zone isn't enforced by this type, but the name is a helpful reminder.
UtcTimestamp = datetime


================================================
FILE: gpsreceiver/gpsreceiver/utils.py
================================================
from .types import Bit


class InvariantError(Exception):
    """An exception raised when an invariant condition is violated."""

    pass


def invariant(condition: bool, message: str = "") -> None:
    """Checks an invariant condition.

    This is similar to the built-in ``assert`` keyword, but remains present even
    if the code is run with the ``-O`` option and ``__debug__`` is ``False``.
    """
    if not condition:
        raise InvariantError(message)


def parse_int_from_bits(bits: list[Bit]) -> int:
    """Parses the given bits as an unsigned integer."""

    return int("".join([str(b) for b in bits]), 2)


================================================
FILE: gpsreceiver/gpsreceiver/world.py
================================================
from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

import numpy as np

from .subframes import Subframe, Subframe1, Subframe2, Subframe3, Subframe4, Subframe5
from .types import Bit, SatelliteId, Side, UtcTimestamp
from .utils import InvariantError, invariant, parse_int_from_bits

# Section 3.3.4.
_SECONDS_PER_WEEK: int = 60 * 60 * 24 * 7  # 604,800

# Section 20.3.4.3.
_SPEED_OF_LIGHT: float = 2.99792458e8

logger = logging.getLogger(__name__)


@dataclass(kw_only=True)
class PendingSatelliteParameters:
    """A subset of the information required for satellite calculations.

    After we start tracking a satellite, it takes some time to receive all
    of the information we need to calculate its position and pseudorange. This
    class exists to collect that information until we have it all, at which
    point it can be promoted into the (more type safe) ``SatelliteParameters``.
    """

    prn_code_trailing_edge_timestamp: UtcTimestamp | None = None
    side: Side | None = None
    subframe_1: Subframe1 | None = None
    subframe_2: Subframe2 | None = None
    subframe_3: Subframe3 | None = None
    tow_count: int | None = None

    def handle_subframe(self, subframe: Subframe) -> None:
        self.tow_count = parse_int_from_bits(subframe.handover.tow_count_msbs)

        if isinstance(subframe, Subframe1):
            self.subframe_1 = subframe
        elif isinstance(subframe, Subframe2):
            self.subframe_2 = subframe
        elif isinstance(subframe, Subframe3):
            self.subframe_3 = subframe
        elif isinstance(subframe, Subframe4) or isinstance(subframe, Subframe5):
            # We don't need subframes 4 or 5.
            pass
        else:
            raise InvariantError(f"Unexpected subframe: {subframe}")

    def to_satellite_parameters(self) -> SatelliteParameters | None:
        """Attempt to construct a ``SatelliteParameters`` instance.

        Returns ``None`` if we don't have all the required information yet.
        """

        if (
            self.prn_code_trailing_edge_timestamp is None
            or self.side is None
            or self.subframe_1 is None
            or self.subframe_2 is None
            or self.subframe_3 is None
            or self.tow_count is None
        ):
            return None

        # Multiplications by pi are converting semi-circles to radians.
        return SatelliteParameters(
            a_f0=self.subframe_1.a_f0,
            a_f1=self.subframe_1.a_f1,
            a_f2=self.subframe_1.a_f2,
            c_ic=self.subframe_3.c_ic,
            c_is=self.subframe_3.c_is,
            c_rc=self.subframe_3.c_rc,
            c_rs=self.subframe_2.c_rs,
            c_uc=self.subframe_2.c_uc,
            c_us=self.subframe_2.c_us,
            delta_n=self.subframe_2.delta_n * np.pi,
            e=self.subframe_2.e,
            i_0=self.subframe_3.i_0 * np.pi,
            i_dot=self.subframe_3.i_dot * np.pi,
            m_0=self.subframe_2.m_0 * np.pi,
            omega=self.subframe_3.omega * np.pi,
            omega_0=self.subframe_3.omega_0 * np.pi,
            omega_dot=self.subframe_3.omega_dot * np.pi,
            prn_code_trailing_edge_timestamp=self.prn_code_trailing_edge_timestamp,
            # If the right side is dominant we haven't seen the trailing edge of
            # the previous subframe and we're not yet at the TOW of the next.
            # Setting the PRN count to -1 means that when we increment it to 0
            # in the next millisecond we'll be aligned with the subframe's TOW.
            prn_count=-1 if self.side == Side.RIGHT else 0,
            sqrt_a=self.subframe_2.sqrt_a,
            sv_health=self.subframe_1.sv_health,
            t_gd=self.subframe_1.t_gd,
            t_oc=self.subframe_1.t_oc,
            t_oe=self.subframe_2.t_oe,
            tow_count=self.tow_count,
        )


@dataclass(kw_only=True)
class SatelliteParameters:
    """The information required for satellite calculations.

    These parameters are updated as we receive PRN codes and subframes from the
    satellite. All properties are required which simplifies type checking.
    """

    a_f0: float  # seconds
    a_f1: float  # seconds/second
    a_f2: float  # seconds/second^2
    c_ic: float  # radians
    c_is: float  # radians
    c_rc: float  # meters
    c_rs: float  # meters
    c_uc: float  # radians
    c_us: float  # radians
    delta_n: float  # radians/second
    e: float  # dimensionless
    i_0: float  # radians
    i_dot: float  # radians/second

    m_0: float  # radians
    omega: float  # radians
    omega_0: float  # radians
    omega_dot: float  # radians/second

    # The time at which the last PRN code trailing edge was observed.
    prn_code_trailing_edge_timestamp: UtcTimestamp

    # The number of PRN code trailing edges that have been observed since the
    # start of the current subframe. Note that this may be negative.
    prn_count: int

    sqrt_a: float  # √meters

    # A 6 bit field indicating the health of the satellite's navigation data.
    #
    # See ``Subframe1.sv_health`` for more information.
    sv_health: list[Bit]

    t_gd: float  # seconds
    t_oc: float  # seconds since the start of the GPS week
    t_oe: float  # seconds since the start of the GPS week

    # The time-of-week (TOW) count at the leading edge of the current subframe.
    #
    # The transmitted value is the TOW count at the leading edge of the next
    # subframe. However, this isn't updated until we've parsed a full subframe,
    # so by the time this is set it actually applies to the current subframe.
    #
    # See ``Handover.tow_count_msbs`` for more information.
    tow_count: int

    def handle_subframe(self, subframe: Subframe) -> None:
        # Reset the number of PRN code trailing edges we've observed in the
        # current subframe. It's intentional that we subtract the number of PRN
        # codes per subframe rather than setting it to 0 to handle the cases
        # where, due to Doppler shift, we observe 0 or 2 PRN code trailing edges
        # in a 1 ms period. If we set it to 0 the PRN count would be off by 1
        # which results in an error of ~300 km in the pseudorange.
        self.prn_count -= 6000

        self.tow_count = parse_int_from_bits(subframe.handover.tow_count_msbs)

        # Multiplications by pi are converting semi-circles to radians.
        if isinstance(subframe, Subframe1):
            self.a_f0 = subframe.a_f0
            self.a_f1 = subframe.a_f1
            self.a_f2 = subframe.a_f2
            self.sv_health = subframe.sv_health
            self.t_gd = subframe.t_gd
            self.t_oc = subframe.t_oc
        elif isinstance(subframe, Subframe2):
            self.c_rs = subframe.c_rs
            self.c_uc = subframe.c_uc
            self.c_us = subframe.c_us
            self.delta_n = subframe.delta_n * np.pi
            self.e = subframe.e
            self.m_0 = subframe.m_0 * np.pi
            self.sqrt_a = subframe.sqrt_a
            self.t_oe = subframe.t_oe
        elif isinstance(subframe, Subframe3):
            self.c_ic = subframe.c_ic
            self.c_is = subframe.c_is
            self.c_rc = subframe.c_rc
            self.i_0 = subframe.i_0 * np.pi
            self.i_dot = subframe.i_dot * np.pi
            self.omega = subframe.omega * np.pi
            self.omega_0 = subframe.omega_0 * np.pi
            self.omega_dot = subframe.omega_dot * np.pi
        elif isinstance(subframe, Subframe4) or isinstance(subframe, Subframe5):
            # We don't need anything else from subframes 4 or 5.
            pass
        else:
            raise InvariantError(f"Unexpected subframe: {subframe}")


@dataclass
class EcefCoordinates:
    """A location expressed in Earth-centred, Earth-fixed (ECEF) coordinates."""

    x: float  # meters
    y: float  # meters
    z: float  # meters


@dataclass
class EcefSolution:
    """A computed solution with the position in ECEF coordinates."""

    # An estimate of the receiver's clock bias, in seconds.
    #
    # Note that this is the amount by which the receiver's clock differs from
    # GPS time. For example if it was 1.5 s behind GPS time this would be -1.5.
    clock_bias: float

    # An estimate of the receiver's position, in ECEF coordinates.
    position: EcefCoordinates


class World:
    """Stores satellite parameters and implements solution computation."""

    def __init__(self) -> None:
        # Information about satellites we started tracking recently. Once we
        # have enough information they're promoted to ``_satellite_parameters``
        # and can be used to calculate the receiver's position and pseudorange.
        self._pending_satellite_parameters: dict[
            SatelliteId, PendingSatelliteParameters
        ] = {}

        # Information about satellite's we're tracking.
        self._satellite_parameters: dict[SatelliteId, SatelliteParameters] = {}

    def compute_solution(self) -> EcefSolution | None:
        """Compute the receiver's clock bias and position.

        If that's not possible, returns ``None``.
        """

        # Determine which satellites can be used.
        #
        # In order to be used, a satellite's parameters must be present in
        # ``self._satellite_parameters`` (i.e. we have all of the parameters we
        # need to compute its position and pseudorange), and it must be healthy.
        satellite_ids: list[SatelliteId] = []
        for satellite_id, satellite_parameters in self._satellite_parameters.items():
            if satellite_parameters.sv_health[0] == 0:
                satellite_ids.append(satellite_id)

        # We need at least four satellites to determine the receiver's location.
        if len(satellite_ids) < 4:
            return None

        # Compute the solution using the Gauss-Newton algorithm[1].
        #
        # 1: https://en.wikipedia.org/wiki/Gauss%E2%80%93Newton_algorithm#Description

        # x, y, z, t
        guess = np.zeros(4, dtype=float)

        satellite_positions_and_signal_transit_times = [
            self._compute_satellite_position_and_signal_transit_time(satellite_id)
            for satellite_id in satellite_ids
        ]

        for _ in range(10):
            j = self._compute_jacobian(
                guess, satellite_positions_and_signal_transit_times
            )
            r = self._compute_residuals(
                guess, satellite_positions_and_signal_transit_times
            )
            guess -= np.linalg.inv(j.T @ j) @ j.T @ r

        return EcefSolution(guess[3], EcefCoordinates(*guess[:3]))

    def has_required_subframes(self, satellite_id: SatelliteId) -> bool:
        """Returns whether we have received all the subframes required to use a
        particular satellite in solution calculation (subframes 1, 2, and 3)."""

        return satellite_id in self._satellite_parameters

    def _compute_satellite_position_and_signal_transit_time(
        self, satellite_id: SatelliteId
    ) -> tuple[EcefCoordinates, float]:
        """Computes a satellite's position and the time taken for its signal to
        transit to the receiver."""

        # Table 20-IV.
        t = self._compute_satellite_t(satellite_id)
        sp = self._get_satellite_parameters_or_error(satellite_id)
        t_k = self._wrap_time_delta(t - sp.t_oe)
        e_k = self._compute_satellite_e_k(satellite_id, t_k)
        v_k = 2 * math.atan(math.sqrt((1 + sp.e) / (1 - sp.e)) * math.tan(e_k / 2))
        phi_k = v_k + sp.omega
        delta_u_k = sp.c_us * math.sin(2 * phi_k) + sp.c_uc * math.cos(2 * phi_k)
        delta_r_k = sp.c_rs * math.sin(2 * phi_k) + sp.c_rc * math.cos(2 * phi_k)
        delta_i_k = sp.c_is * math.sin(2 * phi_k) + sp.c_ic * math.cos(2 * phi_k)
        u_k = phi_k + delta_u_k
        r_k = sp.sqrt_a**2 * (1 - sp.e * math.cos(e_k)) + delta_r_k
        i_k = sp.i_0 + delta_i_k + sp.i_dot * t_k
        x_k_prime = r_k * math.cos(u_k)
        y_k_prime = r_k * math.sin(u_k)
        omega_e_dot = 7.2921151467e-5
        omega_k = (
            sp.omega_0 + (sp.omega_dot - omega_e_dot) * t_k - omega_e_dot * sp.t_oe
        )
        x_k = x_k_prime * math.cos(omega_k) - y_k_prime * math.cos(i_k) * math.sin(
            omega_k
        )
        y_k = x_k_prime * math.sin(omega_k) + y_k_prime * math.cos(i_k) * math.cos(
            omega_k
        )
        z_k = y_k_prime * math.sin(i_k)
        position = EcefCoordinates(x_k, y_k, z_k)

        # Calculate the signal transit time.
        #
        # As both are GPS time of week values, we must handle the case where
        # they're in different weeks due week boundaries and wrap the difference
        # appropriately. Note that, due to the receiver's clock bias, it's not
        # always the case that t_rcv > t, i.e. the transit time may be negative!
        t_rcv = self._to_time_of_week(sp.prn_code_trailing_edge_timestamp)
        signal_transit_time = self._wrap_time_delta(t_rcv - t)

        return (position, signal_transit_time)

    def _compute_satellite_t(self, satellite_id: SatelliteId) -> float:
        """Computes the GPS time at which a satellite transmitted the trailing
        edge of its most recently received PRN code (t)."""

        # Section 20.3.3.3.3.1.
        #
        # Page 98 notes that equations 1 and 2 are coupled (to calculate t you
        # need to know delta_t_sv which itself is defined in terms of t). To
        # break this it suggests using t_sv in place of t in equation 2.
        sp = self._get_satellite_parameters_or_error(satellite_id)
        t_sv = sp.tow_count * 6 + sp.prn_count * 0.001
        delta_t = self._wrap_time_delta(t_sv - sp.t_oc)
        f = -4.442807633e-10
        e_k = self._compute_satellite_e_k(
            satellite_id, self._wrap_time_delta(t_sv - sp.t_oe)
        )
        delta_t_r = f * sp.e * sp.sqrt_a * math.sin(e_k)
        delta_t_sv = sp.a_f0 + sp.a_f1 * delta_t + sp.a_f2 * delta_t**2 + delta_t_r

        # Section 20.3.3.3.3.2.
        return t_sv - (delta_t_sv - sp.t_gd)

    def _get_satellite_parameters_or_error(
        self, satellite_id: SatelliteId
    ) -> SatelliteParameters:
        try:
            return self._satellite_parameters[satellite_id]
        except KeyError:
            raise InvariantError(
                f"SatelliteParameters not present for ID: {satellite_id}"
            )

    def _wrap_time_delta(self, t: float) -> float:
        """Accounts for week crossovers by wrapping time deltas.

        ``t`` is the difference between two GPS time of week values, e.g.
        ``t_1 - t_2``. If the difference has a large magnitude that suggests
        one value was at the end of a week and the other at the start. We
        wrap the difference to accurately represent the time between them.
        """

        if t > _SECONDS_PER_WEEK / 2:
            return t - _SECONDS_PER_WEEK
        elif t < -_SECONDS_PER_WEEK / 2:
            return t + _SECONDS_PER_WEEK
        else:
            return t

    def _compute_satellite_e_k(self, satellite_id: SatelliteId, t_k: float) -> float:
        """Computes a satellite's eccentric anomaly (E_k) at a specified number
        of seconds from the ephemeris reference epoch (t_k), in radians.

        Assumes that ``t_k`` has been run through ``_wrap_time_delta``.
        """

        # Table 20-IV.
        mu = 3.986005e14
        sp = self._get_satellite_parameters_or_error(satellite_id)
        a = sp.sqrt_a**2
        n_0 = math.sqrt(mu / a**3)
        n = n_0 + sp.delta_n
        m_k = sp.m_0 + n * t_k
        e = m_k

        # The specification states a minimum of 3 iterations.
        for _ in range(3):
            e += (m_k - e + sp.e * math.sin(e)) / (1 - sp.e * math.cos(e))

        return e

    def _to_time_of_week(self, timestamp: UtcTimestamp) -> float:
        """Converts a UTC timestamp to a GPS time of week.

        This is the number of seconds since the start of the GPS week.
        """

        # GPS doesn't track leap seconds, but UTC does. Thus, we must undo all
        # 18 leap seconds that have occurred since the GPS zero time-point
        # (midnight on the morning of January 6, 1980). Apparently leap seconds
        # will be abandoned by 2035[1] so I'm just going to hard code this.
        #
        # 1: https://en.wikipedia.org/wiki/Leap_second#International_proposals_for_elimination_of_leap_seconds
        leap_seconds = 18
        timestamp += timedelta(seconds=leap_seconds)

        # The GPS time of week is the number of seconds that have occurred since
        # the GPS zero time-point mod the number of seconds in a week.
        #
        # The datetime module doesn't support leap seconds, so this expression
        # is safe, i.e. it doesn't result in leap seconds being counted twice.
        zero = datetime(1980, 1, 6, tzinfo=timezone.utc)
        return (timestamp - zero).total_seconds() % _SECONDS_PER_WEEK

    def _compute_jacobian(
        self,
        guess: np.ndarray,
        satellite_positions_and_signal_transit_times: list[
            tuple[EcefCoordinates, float]
        ],
    ) -> np.ndarray:
        """Computes the Jacobian matrix for the navigation equations.

        Each satellite's pseudorange equation has the form

            sqrt((X - x)^2 + (Y - y)^2 + (Z - z)^2) - c * (T - t) = 0

        where X, Y, and Z are the satellite's coordinates, x, y, and z are an
        estimate of the receiver's coordinates, T is the satellite's signal
        transit time, and t is the receiver's clock bias.
        """
        rows = []
        x, y, z, _ = guess

        for p, _ in satellite_positions_and_signal_transit_times:
            distance = math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2)
            rows.append(
                [
                    -(p.x - x) / distance,
                    -(p.y - y) / distance,
                    -(p.z - z) / distance,
                    _SPEED_OF_LIGHT,
                ]
            )

        return np.array(rows)

    def _compute_residuals(
        self,
        guess: np.ndarray,
        satellite_positions_and_signal_transit_times: list[
            tuple[EcefCoordinates, float]
        ],
    ) -> np.ndarray:
        """Computes the residual vector for the navigation equations.

        These are the values to be minimised by the Gauss-Newton algorithm.

        See ``_compute_jacobian`` for the pseudorange equation.
        """
        x, y, z, t = guess
        return np.array(
            [
                # The LHS of the pseudorange equation.
                math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2)
                - _SPEED_OF_LIGHT * (stt - t)
                for p, stt in satellite_positions_and_signal_transit_times
            ]
        )

    def drop_satellite(self, satellite_id: SatelliteId) -> None:
        """Remove a satellite from the world model.

        This is called when we lose lock on a satellite.
        """

        if satellite_id in self._pending_satellite_parameters:
            del self._pending_satellite_parameters[satellite_id]

        if satellite_id in self._satellite_parameters:
            del self._satellite_parameters[satellite_id]

    def handle_prns_tracked(
        self,
        count: int,
        satellite_id: SatelliteId,
        side: Side,
        trailing_edge_timestamp: UtcTimestamp,
    ) -> None:
        """Handle the tracking of one or more PRN codes.

        This required to accurately track time between subframes.

        ``count`` is the number of trailing edges of PRN codes that were
        observed. This will typically be 1, but may also be 0 or 2 if a
        satellite's signal is Doppler shifted as this causes its PRN codes to
        stretch or shrink in time, changing the number of chips per millisecond.

        ``side`` designates which PRN code within the 1 ms chunk of samples
        is dominant. This is required to determine if we've seen the subframe's
        trailing edge when initialising ``SatelliteParameters`` as that affects
        the initial ``prn_count`` and thus the timing. See ``Tracker._side``.

        ``trailing_edge_timestamp`` is the timestamp of the trailing edge of the
        most recently observed PRN code, in receiver time.
        """

        if satellite_id in self._satellite_parameters:
            sp = self._satellite_parameters[satellite_id]
            sp.prn_code_trailing_edge_timestamp = trailing_edge_timestamp
            sp.prn_count += count
        elif satellite_id not in self._pending_satellite_parameters:
            self._pending_satellite_parameters[satellite_id] = (
                PendingSatelliteParameters()
            )

        if satellite_id in self._pending_satellite_parameters:
            # We don't need to track PRN counts for pending parameters because
            # they're always promoted on or 1 ms before the trailing edge of a
            # subframe, i.e. the PRN count is set to either 0 or -1. These two
            # cases are distinguished by the ``side`` parameter.
            #
            # There may be a rare scenario where a satellite has a positive
            # Doppler shift and we happen to see two PRN code trailing edges in
            # the 1 ms period when we receive the trailing edge of the last
            # subframe we need. If that happens the PRN count will permanently
            # be off by 1, i.e. the pseudorange will be off by ~300 km. That
            # seems pretty unlikely though so I'm not going to bother with it.
            psp = self._pending_satellite_parameters[satellite_id]
            psp.prn_code_trailing_edge_timestamp = trailing_edge_timestamp
            psp.side = side
            self._maybe_promote_pending_satellite_parameters(satellite_id)

    def handle_subframe(self, satellite_id: SatelliteId, subframe: Subframe) -> None:
        """Handle a subframe decoded from a satellite's signal."""

        if satellite_id in self._satellite_parameters:
            self._satellite_parameters[satellite_id].handle_subframe(subframe)
        elif satellite_id not in self._pending_satellite_parameters:
            logger.info(f"[{satellite_id}] Created pending parameters")
            self._pending_satellite_parameters[satellite_id] = (
                PendingSatelliteParameters()
            )

        if satellite_id in self._pending_satellite_parameters:
            self._pending_satellite_parameters[satellite_id].handle_subframe(subframe)
            self._maybe_promote_pending_satellite_parameters(satellite_id)

    def _maybe_promote_pending_satellite_parameters(
        self, satellite_id: SatelliteId
    ) -> None:
        """Promote ``PendingSatelliteParameters`` to ``SatelliteParameters`` if
        all the required information is present."""

        invariant(
            satellite_id in self._pending_satellite_parameters,
            "PendingSatelliteParameters not present",
        )
        invariant(
            satellite_id not in self._satellite_parameters,
            "SatelliteParameters already present",
        )

        sp = self._pending_satellite_parameters[satellite_id].to_satellite_parameters()
        if sp is not None:
            logger.info(f"[{satellite_id}] Promoted pending parameters")
            self._satellite_parameters[satellite_id] = sp
            del self._pending_satellite_parameters[satellite_id]


================================================
FILE: gpsreceiver/makefile
================================================
SHELL := bash -eu

.PHONY: default
default:
	@echo "Specify a target"

.PHONY: clean
clean:
	rm -fr .mypy_cache
	find gpsreceiver -name __pycache__ -exec rm -fr {} \; -prune

.PHONY: format
format:
	isort --profile black gpsreceiver
	black gpsreceiver

.PHONY: type_check
type_check:
	mypy gpsreceiver


================================================
FILE: gpsreceiver/mypy.ini
================================================
[mypy]
plugins = pydantic.mypy

disallow_any_explicit = True
disallow_subclassing_any = True
disallow_incomplete_defs = True
disallow_untyped_calls = True
disallow_untyped_decorators = True
disallow_untyped_defs = True
enable_incomplete_feature = NewGenericSyntax
strict_equality = True
warn_no_return = True
warn_redundant_casts = True
warn_unreachable = True
warn_unused_ignores = True

[mypy-rtlsdr.*]
ignore_missing_imports = True

[pydantic-mypy]
init_forbid_extra = True
init_typed = True

================================================
FILE: gpsreceiver/requirements.txt
================================================
# Development
black==24.10.0
isort==5.13.2
mypy==1.13.0

# Runtime
aiohttp==3.11.11
numpy==2.1.2
pydantic==2.10.5
pyrtlsdr==0.3.0
pyrtlsdrlib==0.0.3
setuptools==75.6.0 # Required by pyrtlsdr

================================================
FILE: presentation/.gitignore
================================================
*.aux
*.fdb_latexmk
*.fls
*.log
*.nav
*.out
*.snm
*.synctex.gz
*.toc
*.vrb

================================================
FILE: presentation/1 introduction/presentation.tex
================================================
\documentclass[aspectratio=169, xcolor=table]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{mathtools}
\usepackage{siunitx}
\usepackage{tikz}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 1: Introduction}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

{
    \usebackgroundtemplate{\includegraphics[width=\paperwidth]{1 bartosz.png}}
    \begin{frame}[b,plain]
        \colorbox{white}{https://ciechanow.ski/gps/}
        \vspace{0.3cm}
    \end{frame}
}

{
    \usebackgroundtemplate{\includegraphics[width=\paperwidth]{2 phillip.png}}
    \begin{frame}[b,plain]
        \colorbox{black}{\color{white}{https://axleos.com/building-a-gps-receiver-part-1-hearing-whispers/}}
        \vspace{0.3cm}
    \end{frame}
}

\begin{frame}
    \frametitle{GPS receiver}

    \centering
    \includegraphics[width=\textwidth * 3 / 5]{9 setup.jpg}
\end{frame}

\begin{frame}
    \frametitle{Dashboard}

    \centering
    \includegraphics[width=\textwidth * 11 / 20]{10 dashboard.png}
\end{frame}

\begin{frame}
    \frametitle{Overview of GPS}

    \centering
    \only<1>{\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\textwidth * 35 / 100]{3 constellation.pdf}}%
    \only<2>{\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\textwidth * 35 / 100]{4 one satellite.pdf}}%
    \only<3-5>{\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\textwidth * 35 / 100]{5 one satellite moved.pdf}}%
    \only<6>{\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\textwidth * 35 / 100]{6 one satellite signal sphere.pdf}}%
    \only<7->{\includegraphics[clip, trim={13cm 15cm 8.5cm 11cm}, width=\textwidth * 35 / 100]{7 four satellites signal sphere.pdf}}%
    \onslide<4->{
        \begin{align*}
            T &= t_\text{received} - t_\text{transmitted}
            \onslide<5->{\\ d &= c T}
        \end{align*}
    }
    \onslide<8>{
        \begin{tikzpicture}[remember picture, overlay]
            \node[shift={(0cm, 1.85cm)}] at (current page.center) {
                \includegraphics[width=1cm]{8 pin.png}
            };
        \end{tikzpicture}
    }
\end{frame}

\begin{frame}
    \frametitle{Overview of series}

    \begin{itemize}
        \item<2-> Theory
        
        \begin{enumerate}
            \setcounter{enumi}{1}

            \item<3-> Correlation
           
            \item<4-> GPS signals
        \end{enumerate}

        \item<5-> Stages
        
        \begin{enumerate}
            \setcounter{enumi}{3}

            \item<6-> Sampling
            
            \item<7-> Acquisition
            
            \item<8-> Tracking
            
            \item<9-> Decoding
            
            \item<10-> Solving
        \end{enumerate}
    \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/2 correlation/presentation.tex
================================================
\documentclass[aspectratio=169]{beamer}

\usepackage{calc}
\usepackage{graphicx}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 2: Correlation}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents
\end{frame}

\section{Correlation}

\begin{frame}
    \frametitle{Positive correlation}

    \centering
    \includegraphics[width=\textwidth * 3 / 4]{1 positive.pdf}
\end{frame}

\begin{frame}
    \frametitle{Negative correlation}

    \centering
    \includegraphics[width=\textwidth * 3 / 4]{2 negative.pdf}
\end{frame}

\begin{frame}
    \frametitle{Near-zero correlation}

    \centering
    \includegraphics[width=\textwidth * 3 / 4]{3 zero.pdf}
\end{frame}

\begin{frame}
  \frametitle{Definition}

  The correlation of two signals $f(t)$ and $g(t)$ is defined as \[\int_{-\infty}^{+\infty} f(t) g(t) \,dt.\]
\end{frame}

\begin{frame}
  \frametitle{$f(t) \rightarrow 0$ as $|t| \rightarrow \infty$}

  \centering
  \includegraphics[width=\textwidth * 3 / 4]{4 square integrable.pdf}
\end{frame}

\begin{frame}
  \frametitle{Periodic signals}

  \centering
  \only<1>{\includegraphics[width=\textwidth * 3 / 4]{5 periodic.pdf}}%
  \only<2>{\includegraphics[width=\textwidth * 3 / 4]{6 periodic with bounds.pdf}}
\end{frame}

\begin{frame}
  \frametitle{Definition}
  
  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.
\end{frame}

\begin{frame}
  \frametitle{Multiple periods}

  \centering
  \includegraphics[width=\textwidth / 2]{7 multiple periods.pdf}

  \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\]}
\end{frame}

\begin{frame}
  \frametitle{Definition vs. intuition}

  \[\int_{-\infty}^{+\infty} f(t) g(t) \,dt\]

  \begin{itemize}
    \item<2-> Same $\Rightarrow$ same signs $\Rightarrow$ positive products $\Rightarrow$ positive sum
    \item<3-> Opposite $\Rightarrow$ opposite signs $\Rightarrow$ negative products $\Rightarrow$ negative sum
    \item<4-> Not similar at all $\Rightarrow$ positive and negative products $\Rightarrow$ sums cancel
  \end{itemize}
\end{frame}

\section{Cross-correlation}

\section{Autocorrelation}

\begin{frame}
  \frametitle{Conclusion}

  \begin{itemize}
    \item<2-> Intuition of correlation
    
      \begin{itemize}
        \item Almost the same $\Rightarrow$ positive correlation
        
        \item Almost opposites $\Rightarrow$ negative correlation
        
        \item Not similar at all $\Rightarrow$ near-zero correlation
      \end{itemize}
        
    \item<3-> Definition of correlation
    
      \begin{itemize}
        \item $\int_{-\infty}^{+\infty} f(t) g(t) \,dt$
        
        \item $\int_{t_0}^{t_0 + T} f(t) g(t) \,dt$
      \end{itemize}
    
    \item<4-> Cross-correlation
    
      \begin{itemize}
        \item The correlation of two signals at different shifts
      \end{itemize}
    
    \item<5-> Autocorrelation
    
      \begin{itemize}
        \item The cross-correlation of a signal with itself
      \end{itemize}
  \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/3 GPS signals/presentation.tex
================================================
\documentclass[aspectratio=169]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{siunitx}
\usepackage{xcolor}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 3: GPS signals}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents
\end{frame}

\section{The C/A signal}

\begin{frame}
    \frametitle{GPS frequencies}

    \centering
    \only<1>{\includegraphics[width=\textwidth / 2]{1 gps frequencies.png}}%
    \only<2>{\includegraphics[width=\textwidth / 2]{2 gps frequencies.png}}%
    \only<3>{\includegraphics[width=\textwidth / 2]{3 gps frequencies.png}}%
    \only<4>{\includegraphics[width=\textwidth / 2]{4 gps frequencies.png}}%
    \only<5>{\includegraphics[width=\textwidth / 2]{5 gps frequencies.png}}%
    \only<6>{\includegraphics[width=\textwidth / 2]{6 gps frequencies.png}}%
    \\
    \texttt{\tiny{Source: "GPS signals" from Wikipedia, CC BY-SA 4.0, https://en.wikipedia.org/wiki/GPS\_signals}}
\end{frame}

\begin{frame}
    \frametitle{The navigation message}

    \begin{itemize}
        \item<2-> Binary
        
        \item<3-> Transmitted at 50 bps
        
        \item<4-> Contains the information required to calculate satellite location and time
        
        \item<5-> $D_i(t)$ represents the navigation message bit of satellite number $i$ at time $t$
    \end{itemize}
\end{frame}

\section{Modulation}

\begin{frame}
  \frametitle{Modulation}

  \centering
  \begin{tabular}{c c}
    \onslide<2->{\includegraphics[width=\textwidth / 3]{7 carrier.pdf}} & \onslide<3->{\includegraphics[width=\textwidth / 3]{8 modulation.pdf}} \\
    \onslide<4->{\includegraphics[width=\textwidth / 3]{9 am.pdf}}      & \onslide<5->{\includegraphics[width=\textwidth / 3]{10 fm.pdf}}
  \end{tabular}
\end{frame}

\begin{frame}
  \frametitle{Modulation of the C/A signal}

  \begin{itemize}
    \item<2-> The carrier signal is a radio wave at the L1 frequency, $\qty{1575.42}{MHz}$
    
    \item<3-> $f_i(t)$ is the amplitude of satellite number $i$'s carrier wave at time $t$
    
    \item<4-> The modulation signal is the navigation message $D_i(t)$, transmitted at $\qty{50}{bps}$
  \end{itemize}

  \leavevmode \\

  \centering
  \onslide<5->{\includegraphics[width=\textwidth / 3]{11 phase shift.pdf}}
\end{frame}

\begin{frame}
  \frametitle{Modulation of the C/A signal}

  \begin{center}
    \huge
    \begin{tabular}{c c c c c c c}
      \onslide<1->{$D_i =$ & 0 & 1 & 0 & 0 & 1 & 1 \\}
      & \onslide<2>{$\downarrow$} & \onslide<3>{$\downarrow$} & \onslide<2>{$\downarrow$} & \onslide<2>{$\downarrow$} & \onslide<3>{$\downarrow$} & \onslide<3>{$\downarrow$} \\
      \onslide<4->{$\hat{D}_i =$} & \onslide<2->{1} & \onslide<3->{-1} & \onslide<2->{1} & \onslide<2->{1} & \onslide<3->{-1} & \onslide<3->{-1} \\
    \end{tabular}

    \leavevmode \newline

    \onslide<5->{$\hat{D}_i(t) f_i(t)$}
  \end{center}
\end{frame}

\section{CDMA}

\begin{frame}
  \frametitle{PRN codes}

  \begin{itemize}
    \item<2-> Each satellite is assigned a pseudorandom noise code (PRN code)
    
    \begin{itemize}
      \item<3-> Binary
      
      \item<4-> 1023 bits long
    \end{itemize}
  \end{itemize}

  \leavevmode \newline

  \onslide<5->{\includegraphics[width=\textwidth]{12 prn code.pdf}}
\end{frame}

\begin{frame}
  \frametitle{PRN codes}

  \begin{itemize}
    \item<2-> Satellites modulate $D_i(t) \oplus PRN_i(t)$
    
    \item<3-> $PRN_i(t)$ is the bit of satellite number $i$'s PRN code at time $t$
    
    \item<4-> Transmitted at $\qty{1.023}{Mbps}$
      
    \item<4-> Full code repeats once per millisecond, 20 times per navigation message bit
  \end{itemize}

  \onslide<5->{
    \begin{center}
      $0 \rightarrow 1$, $1 \rightarrow -1$

      \vspace{1em}

      \begin{tabular}{c c}
        \begin{tabular}{|c|c|c|}
          \hline
          $\oplus$ & 0 & 1 \\
          \hline
          0 & 0 & 1 \\
          \hline
          1 & 1 & 0 \\
          \hline
        \end{tabular}

        \begin{tabular}{|c|c|c|}
          \hline
          $\times$ & 1 & -1 \\
          \hline
          1 & 1 & -1 \\
          \hline
          -1 & -1 & 1 \\
          \hline
        \end{tabular}
      \end{tabular}
    \end{center}
  }

  \begin{itemize}
    \item<6-> The signal transmitted by a satellite is $\hat{D}_i(t) \hat{PRN}_i(t) f_i(t)$
  \end{itemize}
\end{frame}

\begin{frame}
  \frametitle{The properties of PRN codes}

  \begin{itemize}
    \item<2-> Strong, positive correlation with an aligned version of itself
    
    \item<3-> Near-zero correlation with misaligned versions of itself
  \end{itemize}

  \vspace{0.5em}

  \centering
  \onslide<4->{\includegraphics[width=\textwidth * 3 / 4]{13 autocorrelation.pdf}}
\end{frame}

\begin{frame}
  \frametitle{The properties of PRN codes}

  \begin{itemize}
    \item<2-> The correlation of two different PRN codes is always near zero
  \end{itemize}

  \vspace{0.5em}

  \centering
  \onslide<3->{\includegraphics[width=\textwidth * 3 / 4]{14 cross-correlation.pdf}}
\end{frame}

\begin{frame}
  \frametitle{Decoding a bit from a satellite}

  \centering
  \Large

  \only<2>{\[\hat{D}_i(t) \hat{PRN}_i(t) f_i(t)\]}
  \only<3>{\[\hat{D}_1(t) \hat{PRN}_1(t) f_1(t) + \hat{D}_2(t) \hat{PRN}_2(t) f_2(t)\]}
  \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)}\]}
  \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)\]}
  \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)}\]}
  \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}\]}
  \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\]}
  \only<9>{
    \begin{align*}
      & \int_0^T c_1 \hat{D}_1(t) \hat{PRN}_1(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + \int_0^T c_2 \hat{D}_2(t) \hat{PRN}_2(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + \int_0^T N_2(t) \hat{PRN}_1(t - \tau) \,d t
    \end{align*}
  }
  \only<10>{
    \begin{align*}
      & \textcolor{red}{c_1} \int_0^T \hat{D}_1(t) \hat{PRN}_1(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + \textcolor{red}{c_2} \int_0^T \hat{D}_2(t) \hat{PRN}_2(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + \int_0^T N_2(t) \hat{PRN}_1(t - \tau) \,d t
    \end{align*}
  }
  \only<11>{
    \begin{align*}
      & c_1 \textcolor{red}{\hat{D}_1(0)} \int_0^T \hat{PRN}_1(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + c_2 \textcolor{red}{\hat{D}_2(0)} \int_0^T \hat{PRN}_2(t) \hat{PRN}_1(t - \tau) \,d t \\
      & \qquad + \int_0^T N_2(t) \hat{PRN}_1(t - \tau) \,d t
    \end{align*}
  }
  \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\]}
  \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\]}
  \only<14>{\[\textcolor{red}{\alpha} \hat{D}_1(0) + \int_0^T N_2(t) \hat{PRN}_1(t) \,d t\]}
  \only<15>{\[\alpha \hat{D}_1(0) + \textcolor{red}{\beta}\]}
\end{frame}

\begin{frame}
  \frametitle{Recap}

  \begin{itemize}
    \item<2-> We'll use the C/A signal on the L1 frequency
    
    \item<3-> The C/A signal contains the navigation message
    
    \item<4-> The navigation message lets us calculate a satellite's location and time
    
    \item<5-> Each satellite is assigned a PRN code
    
    \item<6-> The PRN code is XOR-ed with the navigation message and repeats once per ms
    
    \item<7-> To decode the navigation message bit of satellite number $i$:
    
    \begin{itemize}
      \item<8-> Record the received signal for $\qty{1}{ms}$
      
      \item<9-> Calculate its correlation with an aligned copy of satellite number $i$'s PRN code
    \end{itemize}
  \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/4 sampling/presentation.tex
================================================
\documentclass[aspectratio=169]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{siunitx}
\usepackage{xcolor}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 4: Sampling}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents
\end{frame}

\section{Hardware}

\begin{frame}
    \frametitle{Hardware}

    \begin{itemize}
        \item Software defined radio (SDR) dongle
    \end{itemize}

    \leavevmode \\

    \centering
    \includegraphics[width=\textwidth * 4 / 15]{1 hardware.jpg}
\end{frame}

\begin{frame}
    \frametitle{Hardware}

    \centering
    \includegraphics[width=\textwidth / 2]{2 GPS antenna.jpg}
\end{frame}

\section{Parameters}

\subsection{Centre frequency}

\begin{frame}
    \frametitle{Centre frequency}

    \Large
    \[f = \qty{1575.42}{MHz}\]
\end{frame}

\subsection{Bandwidth}

\begin{frame}
    \frametitle{The power spectrum of BPSK}

    \centering
    \only<1>{\includegraphics{3 BPSK PSD.pdf}}%
    \only<2>{\includegraphics{4 BPSK PSD main lobe.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Bandwidth}

    \Large
    \[B = \qty{2.046}{MHz}\]
\end{frame}

\subsection{Sampling rate}

\begin{frame}
    \frametitle{Sampling rate}

    \Large
    \[f_{L1} = \qty{1575.42}{Mhz} \approx \qty{1.6}{GHz}\]
\end{frame}

\begin{frame}
    \frametitle{The Nyquist-Shannon sampling theorem}

    \centering
    \Large
    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}$.
\end{frame}

\begin{frame}
    \frametitle{Sampling rate}

    \Large
    \begin{align*}
    f_\text{max} &= f + \frac{B}{2} \\
    \onslide<2->{&= \qty{1575.42}{MHz} + \frac{\qty{2.046}{MHz}}{2} \\}
    \onslide<3->{&= \qty{1576.443}{MHz} \\}
    \onslide<3->{& \approx \qty{1.6}{GHz} \\}
    \onslide<4->{f_s &= 2 f_\text{max} \\}
    \onslide<4->{& \approx \qty{3.2}{GHz}}
    \end{align*}
\end{frame}

\begin{frame}
    \frametitle{Sampling rate}

    \centering
    \includegraphics[width=\textwidth]{5 RTL-SDR maximum sampling rate.png}
    \texttt{\tiny{https://www.rtl-sdr.com/about-rtl-sdr/}}
\end{frame}

\begin{frame}
    \frametitle{Sampling rate}

    {\Large \[f_s = \qty{2.046}{MHz}\]}

    \leavevmode \\

    \begin{itemize}
        \item<2-> Large enough that aliases don't overlap
        
        \item<3-> $1 / 770$ of the L1 frequency $\Rightarrow$ alias at $\qty{0}{Hz}$
        
        \item<4-> $f_\text{max} = \qty{1.023}{MHz} \Rightarrow f_\text{Nyquist} = \qty{2.046}{MHz} \Rightarrow$ within SDR dongle's capabilities
    \end{itemize}
\end{frame}

\section{I/Q samples}

\subsection{Definition}

\begin{frame}
    \frametitle{The definition of I/Q samples}

    \Large
    \only<1-3>{
        \only<1-2>{\[f(t) = A(t) \cos(2 \pi f t + \phi(t))\]}
        \only<3->{\[f(t) = A(t) \, \textcolor{red}{\cos(2 \pi f t + \phi(t))}\]}
        \onslide<2->{\[\cos(\alpha + \beta) = \cos(\alpha) \cos(\beta) - \sin(\alpha) \sin(\beta)\]}
    }
    \only<4-6>{
        \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))]\]}
        \only<6->{\[f(t) = A(t) [\cos(2 \pi f t) \cos(\phi(t)) \, \textcolor{red}{- \sin(2 \pi f t)} \sin(\phi(t))]\]}
        \onslide<5->{\[-\sin(\theta) = \cos(\theta + \pi / 2)\]}
    }
    \only<7->{
        \begin{align*}
            f(t) &= A(t) [\cos(2 \pi f t) \cos(\phi(t)) + \cos(2 \pi f t + \pi / 2) \sin(\phi(t))] \\
            \onslide<8->{&= A(t) \cos(\phi(t)) \cos(2 \pi f t) + A(t) \sin(\phi(t)) \cos(2 \pi f t + \pi / 2) \\}
            \onslide<9->{&= I(t) \cos(2 \pi f t) + Q(t) \cos(2 \pi f t + \pi / 2)}
        \end{align*}

        \onslide<9->{where $I(t) = A(t) \cos(\phi(t))$ and $Q(t) = A(t) \sin(\phi(t))$.}
    }
\end{frame}

\begin{frame}
    \frametitle{Determining a signal's phase}

    \Large
    \begin{align*}
        Q / I &= \onslide<2->{\frac{A(t) \sin(\phi(t))}{A(t) \cos(\phi(t))}} \\
        \onslide<3->{&= \tan(\phi(t))} \\
        \onslide<4->{\phi(t) &= \arctan(Q / I)}
    \end{align*}
\end{frame}

\subsection{Different frequencies}

\begin{frame}
    \frametitle{Sampling a signal of a different frequency}

    \Large
    \begin{align*}
        \onslide<2->{f_1 & \\}
        \onslide<3->{f_2 & = f_1 + \Delta f} \\
        \onslide<4->{f(t) &= A \cos (2 \pi f_2 t) \\}
        \onslide<5->{&= A \cos (2 \pi (f_1 + \Delta f) t) \\}
        \onslide<5->{&= A \cos (2 \pi f_1 t + 2 \pi \Delta f t) \\}
        \onslide<6->{&= A \cos (2 \pi f_1 t + \phi(t))}
    \end{align*}

    \onslide<6->{where $\phi(t) = 2 \pi \Delta f t$.}
\end{frame}

\subsection{Complex values}

\begin{frame}
    \frametitle{Complex I/Q samples}

    \begin{itemize}
        \item<1-> $I + j Q$
        
        \item<2-> Bandpass sampling
        
        \begin{itemize}
            \item<3-> Real-valued samples $\Rightarrow$ carrier wave replaced with a real-valued constant
        
            \item<4-> Complex-valued samples $\Rightarrow$ carrier wave replaced with a complex-valued constant
            
            \item<5-> Using Euler's formula $z = A e^{j \phi}$ where $A$ is the carrier wave's amplitude and $\phi$ is its phase
        \end{itemize}

        \onslide<6->{
            \begin{align*}
                \onslide<6->{\hat{D}_i(t) \hat{PRN}_i(t) &= \pm 1 \\}
                \onslide<7->{z \hat{D}_i(t) \hat{PRN}_i(t) &= z (\pm 1) \\}
                \onslide<7->{&= A e^{j \phi} (\pm 1) \\}
                \onslide<7->{&= \pm A e^{j \phi}}
            \end{align*}
        }
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Recap}

    \begin{itemize}
        \item<2-> Hardware
        
        \begin{itemize}
            \item GPS antenna
            
            \item SDR dongle
        \end{itemize}

        \item<3-> Parameters
        
        \begin{itemize}
            \item Central frequency $f = \qty{1575.42}{MHz}$
            
            \item Bandwidth $B = \qty{2.046}{MHz}$
            
            \item Sampling rate $f_s = \qty{2.046}{MHz}$
        \end{itemize}

        \item<4-> I/Q samples
        
        \begin{itemize}
            \item<5-> Pairs of numbers that describe the signal

            \item<6-> Often expressed as a single complex number
            
            \item<7-> If the signal has a different frequency, the I/Q samples will continually rotate
        \end{itemize}
    \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/5 acquisition/presentation.tex
================================================
\documentclass[aspectratio=169]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{siunitx}
\usepackage{xcolor}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 5: Acquisition}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsubsection
\AtBeginSubsubsection[]
{
    \begin{frame}
        \frametitle{Topics}
        \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=show/shaded]
    \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents[subsubsectionstyle=hide]
\end{frame}

\section{Parameters}

\subsection{PRN code phase}

\begin{frame}
    \frametitle{PRN code phase}

    \begin{itemize}
        \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
        
        \begin{itemize}
            \item<2-> PRN codes aligned $\Rightarrow$ navigation message bit
            
            \item<3-> PRN codes misaligned $\Rightarrow$ noise
        \end{itemize}

        \item<4-> In order to align them, we need to know the phase
    \end{itemize}
\end{frame}

\subsection{Carrier wave frequency shift}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=show]
\end{frame}

\subsubsection{Modulation signal, misaligned PRN code}
\subsubsection{Modulation signal, aligned PRN code}
\subsubsection{Modulated signal, not frequency shifted}

\begin{frame}
    \frametitle{Modulated signal, not frequency shifted}

    \begin{itemize}
        \item $\hat{D}_i(t) \hat{PRN}_i(t) f(t)$
        
        \item<2-> Bandpass sampling results in $f(t) \Rightarrow A e^{j \phi}$
        
        \item<3-> Samples will be equal to $\pm A e^{j \phi}$
    \end{itemize}
\end{frame}

\subsubsection{Modulated signal, frequency shifted}

\begin{frame}
    \frametitle{Modulated signal, frequency shifted}

    \begin{itemize}
        \item $\hat{D}_i(t) \hat{PRN}_i(t) g(t)$
        
        \item<2-> A frequency shifted signal $\Leftrightarrow$ a signal with a constantly changing phase
        
        \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}$
        
        \item<4-> Samples will be equal to $\pm A e^{j \phi} e^{j 2 \pi \Delta f t}$
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Recovering the signal}

    \begin{itemize}
        \item<2-> Undo the rotation by multiplying each sample by $e^{-j 2 \pi \Delta f t}$
        
        \item<3-> This is called carrier wipeoff and it's why we need to know $\Delta f$
    \end{itemize}
\end{frame}

\section{Finding parameters}

\begin{frame}
    \frametitle{Finding parameters}

    \centering
    \only<1>{\includegraphics[width=\textwidth * 3 / 4]{1 acquisition space axes.pdf}}%
    \only<2>{\includegraphics[width=\textwidth * 3 / 4]{2 acquisition space.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Finding parameters}

    \begin{itemize}
        \item<2-> We want to calculate the correlation over a $\qty{10}{ms}$ period
        
        \item<3-> There's a higher chance that the navigation message bit will change
        
        \item<4-> If it does, some samples will cancel each other out $\Rightarrow$ smaller correlation
        
        \item<5-> Calculate correlation over $10 \times \qty{1}{ms}$ periods, add their magnitudes
        
        \item<6-> This is called non-coherent integration
    \end{itemize}
\end{frame}

\section{Parameter space}

\begin{frame}
    \frametitle{PRN code phase}

    \begin{itemize}
        \item $f_s = \qty{2.046}{MHz} \Rightarrow \qty{2046}{samples/ms}$
        
        \item<2-> PRN code is 1023 bits long, repeats once per $\unit{ms}$
        
        \item<3-> Upsample the PRN code to be 2046 half-bits long
        
        \item<4-> There are 2046 phases to check
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Carrier frequency shift}

    \begin{itemize}
        \item<2-> $\Delta f$ due to satellite motion $\pm \qty{4.9}{kHz}$
        
        \item<3-> $\Delta f$ due to Earth's rotation $\pm \qty{2.4}{kHz}$
        
        \item<4-> $\Delta f$ due to receiver motion $\pm \qty{150}{Hz}$
        
        \item<5-> $\Delta t_\text{total} \approx \pm \qty{7.5}{kHz}$
    \end{itemize}
\end{frame}

\section{Determining presence}

\begin{frame}
    \frametitle{Determining presence}

    \begin{enumerate}
        \item On startup, find the best parameters and signal strength for every satellite
        
        \item<2-> Compare the signal strength with a threshold
        
        \item<3-> If it's greater $\Rightarrow$ present
        
        \item<4-> If it's smaller $\Rightarrow$ absent

        \item<5-> Periodically try to acquire satellites we're not tracking
    \end{enumerate}
\end{frame}

\begin{frame}
    \frametitle{Recap}

    \begin{itemize}
        \item<2-> Two required parameters
        
        \begin{itemize}
            \item<3-> PRN code phase $\Rightarrow$ align the PRN codes
            
            \item<4-> Carrier frequency shift $\Rightarrow$ undo the effects of frequency shift (carrier wipeoff)
        \end{itemize}

        \item<5-> These parameters are found by brute force
        
        \item<6-> Size of the parameter space
        
        \begin{itemize}
            \item<7-> PRN code phase $\Rightarrow [0, 2046)$
            
            \item<8-> Carrier frequency shift $\Rightarrow \pm \qty{7.5}{kHz}$
        \end{itemize}

        \item<9-> Determining presence

        \begin{enumerate}
            \item<10-> Calculate the best parameters and signal strength
            
            \item<11-> Compare the signal strength against a threshold
            
            \item<12-> If above the threshold $\Rightarrow$ present
            
            \item<13-> If below the threshold $\Rightarrow$ absent, check again later
        \end{enumerate}
    \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/6 tracking/presentation.tex
================================================
\documentclass[aspectratio=169]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{mathtools}
\usepackage{siunitx}
\usepackage{xcolor}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 6: Tracking}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Goals}

    \begin{itemize}
        \item<2-> Track signal parameters over time
        
        \begin{itemize}
            \item<3-> Carrier wave frequency shift
            
            \item<4-> Carrier wave phase
            
            \item<5-> PRN code phase
        \end{itemize}

        \item<6-> Decode fragments of navigation message bits
        
        \begin{itemize}
            \item<7-> $\qty{1}{ms}$ samples $\Leftrightarrow$ 1 PRN code $\Leftrightarrow$ $1/20$ navigation message bit
        \end{itemize}

        \item<8-> Count PRN codes
        
        \begin{itemize}
            \item<9-> Required to calculate the signal transmission time
        \end{itemize}
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Topics}

    \tableofcontents
\end{frame}

\section{Carrier wipeoff}

\begin{frame}
    \frametitle{Carrier wipeoff}

    \centering
    \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}}
\end{frame}

\begin{frame}
    \frametitle{Carrier wipeoff}

    \centering
    \includegraphics[width=\textwidth * 3 / 5]{3 correlations.pdf}
\end{frame}

\begin{frame}
    \frametitle{Carrier wipeoff}

    \centering
    \includegraphics[width=\textwidth / 3]{4 bit ambiguity.pdf}
\end{frame}

\section{PRN code tracking}

\begin{frame}
    \frametitle{Delay-locked loop}

    \only<1>{\includegraphics[width=\textwidth]{5 prompt.pdf}}%
    \only<2>{\includegraphics[width=\textwidth]{6 prompt early.pdf}}%
    \only<3>{\includegraphics[width=\textwidth]{7 prompt early late.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Accounting for frequency shift}

    \only<1>{\includegraphics[width=\textwidth]{8 no frequency shift.pdf}}%
    \only<2>{\includegraphics[width=\textwidth]{9 positive.pdf}}%
    \only<3>{\includegraphics[width=\textwidth]{10 positive and negative.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Accounting for frequency shift}

    \centering
    \Large
    \[\Delta \phi = \overbrace{2046}^{\mathclap{\text{PRN length}}} \times \underbrace{\Delta f \div f_{L1}}_{\mathclap{\text{Percentage frequency change}}}\]
\end{frame}

\section{Correlation}

\begin{frame}
    \frametitle{Correlation}

    \begin{center}
        \only<1>{\includegraphics[width=\textwidth / 2]{11 correlations.pdf}}%
        \only<2->{\includegraphics[width=\textwidth / 2]{12 correlations with regions.pdf}}
    \end{center}

    \begin{itemize}
        \item<3-> These fragments are called ``pseudosymbols''
    \end{itemize}
\end{frame}

\section{Carrier wave tracking}

\begin{frame}
    \frametitle{Carrier wave tracking}

    \begin{itemize}
        \item Update our estimates of the carrier wave's frequency shift and phase
        
        \item<2-> Use a phase-locked loop (Costas loop)
        
        \begin{itemize}
            \item<3-> Calculate a single value that represents the error in both estimates

            \item<4-> Use it to update them
        \end{itemize}
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Carrier wave tracking}

    \centering
    \only<1>{\includegraphics[width=\textwidth * 3 / 4]{13 clusters.pdf}}%
    \only<2>{\includegraphics[width=\textwidth * 3 / 4]{14 cluster angles.pdf}}%
    \only<3->{\includegraphics[width=\textwidth * 3 / 4]{15 cluster angles 2.pdf}}%

    \begin{itemize}
        \item<4-> Use \texttt{atan} rather than \texttt{atan2}
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Carrier wave tracking}

    \begin{itemize}
        \item<2-> Each estimate has an associated gain factor
        
        \item<3-> To update an estimate:

        \begin{enumerate}
            \item<4-> Calculate \texttt{error $\times$ gain}

            \item<5-> Subtract it from the current estimate
        \end{enumerate}

        \item<6-> Determine gain factors experimentally
        
        \item<7-> Phase gain should be around 25 $\times$ the frequency shift gain
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{Recap}

    \begin{itemize}
        \item<2-> Negate rotation of samples caused by frequency shift and phase (carrier wipeoff)
        
        \item<3-> Update PRN code phase using a delay-locked loop
        
        \begin{enumerate}
            \item<4-> Generate early and late replicas of the PRN code
            
            \item<5-> Calculate their correlation with the $\qty{1}{ms}$ of samples

            \item<6-> Compare the magnitudes of correlations to determine best alignment
            
            \item<7-> Update the estimate
        \end{enumerate}

        \item<8-> Calculate the correlation of the samples and the PRN code $\Rightarrow$ pseudosymbol
        
        \item<9-> Update carrier wave frequency shift and phase using a phase-locked loop (Costas loop)
        
        \begin{enumerate}
            \item<10-> Calculate error as angle of correlation

            \item<11-> Multiply error by each estimate's gain factor

            \item<12-> Subtract the result from each estimate
        \end{enumerate}
    \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/7 decoding/presentation.tex
================================================
\documentclass[aspectratio=169, xcolor=table]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{mathtools}
\usepackage{siunitx}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 7: Decoding}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\begin{frame}
    \frametitle{Navigation message structure}

    \centering
    \begin{tabular}{ l|r|r }
        \textbf{Name} & \textbf{Number of bits} & \textbf{Emitted every} \\
        \hline
        Pseudosymbol & 1/20 & $\qty{1}{ms}$ \onslide<2-> \\
        Pseudobit / bit & 1 & $\qty{20}{ms}$ \onslide<3-> \\
        Word & 30 & $\qty{0.6}{s}$ \onslide<4-> \\
        Subframe & 300 & $\qty{6}{s}$ \onslide<5-> \\
        Frame & 1,500 & $\qty{30}{s}$
    \end{tabular}
\end{frame}

\section{Pseudosymbol integration}

\begin{frame}
    \frametitle{Pseudosymbol integration}

    \centering
    \includegraphics[width=\textwidth / 2]{1 correlations with regions.pdf}

    \Large
    \onslide<2->{\hspace{-0.5cm} $-1$ \hspace{2.75cm} $+1$}
\end{frame}

\begin{frame}
    \frametitle{Pseudosymbol integration}

    \centering
    \only<1>{\includegraphics[width=\textwidth]{2 pseudosymbols.pdf}}%
    \only<2-3>{\includegraphics[width=\textwidth]{3 pseudosymbols.pdf}}%
    \only<4>{\includegraphics[width=\textwidth]{4 pseudosymbols.pdf}}%
    \only<5-6>{\includegraphics[width=\textwidth]{5 pseudosymbols.pdf}}%
    \only<7>{\includegraphics[width=\textwidth]{6 pseudosymbols.pdf}}%
    \only<8>{\includegraphics[width=\textwidth]{2 pseudosymbols.pdf}}%
    \only<9>{\includegraphics[width=\textwidth]{7 pseudosymbols.pdf}}%
    
    \onslide<3->{
        \begin{tabular}{r|r}
            Offset & Score \\
            \hline
            0 & \onslide<6->{1.0} \\
            1 & \onslide<8->{2.5} \\
            \only<-8>{2}\only<9>{\textbf{2}} & \only<7-8>{4.0}\only<9>{\textbf{4.0}} \\
            3 & \onslide<8->{2.6}
        \end{tabular}
    }
\end{frame}

\section{Pseudobit integration}

\begin{frame}
    \frametitle{Pseudobit integration}

    \centering
    \includegraphics[width=\textwidth]{8 tlm word.png} \\
    \texttt{\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}

    \vspace{0.5cm}

    \Large
    \onslide<2->{\texttt{-1 +1 +1 +1 -1 +1 -1 -1} $\Rightarrow$ $-1$ maps to $1$, $+1$ maps to $0$} \\
    \onslide<3->{\texttt{+1 -1 -1 -1 +1 -1 +1 +1} $\Rightarrow$ $+1$ maps to $1$, $-1$ maps to $0$}
\end{frame}

\section{Decoding subframes}

\begin{frame}
    \frametitle{Parity bits}

    \centering
    \includegraphics[width=\textwidth * 6 / 10]{9 parity equations.png} \\
    \texttt{\tiny{Source: Table 20-XIV, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{The telemetry word}

    \centering
    \includegraphics[width=\textwidth]{8 tlm word.png} \\
    \texttt{\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{The handover word}

    \centering
    \includegraphics[width=\textwidth]{10 handover word.png} \\
    \texttt{\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{The TOW count}

    \begin{itemize}
        \item GPS started operating at midnight UTC on the night of Saturday January 5, 1980
        
        \item<2-> The number of weeks that have passed since that night is called the GPS week number
        
        \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)
        
        \item<4-> The TOW count is a 19 bit number
        
        \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
        
        \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
        
        \item<7-> We can use this to calculate the signal's transmission time
    \end{itemize}
\end{frame}

\begin{frame}
    \frametitle{The handover word}

    \centering
    \includegraphics[width=\textwidth]{10 handover word.png} \\
    \texttt{\tiny{Source: Figure 20-2, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Subframes}

    \begin{itemize}
        \item<2-> Subframe 1
        
        \begin{itemize}
            \item<3-> Clock parameters
            
            \begin{itemize}
                \item<4-> Calculate when signals were transmitted
                
                \item<5-> Correct for atomic clock drift
            \end{itemize}
            
            \item<6-> Health information
        \end{itemize}

        \item<7-> Subframes 2 and 3
        
        \begin{itemize}
            \item<8-> Orbital parameters
            
            \begin{itemize}
                \item<9-> Calculate a satellite's position
            \end{itemize}
        \end{itemize}

        \item<10-> Subframes 4 and 5
        
        \begin{itemize}
            \item<11-> Parameters change every frame over 25 frames (12.5 minutes)
            
            \begin{itemize}
                \item<12-> Other satellites
                
                \item<13-> Earth's atmospheric conditions
                
                \item<14-> Etc.
            \end{itemize}
        \end{itemize}
    \end{itemize}
\end{frame}

\section{Decoding subframe parameters}

\begin{frame}
    \frametitle{Decoding numbers}

    \centering
    \only<1>{\includegraphics[width=\textwidth * 11 / 20]{11 numbers.png}}%
    \only<2>{\includegraphics[width=\textwidth * 11 / 20]{12 numbers bits.png}}%
    \only<3>{\includegraphics[width=\textwidth * 11 / 20]{13 numbers twos complement.png}}%
    \only<4>{\includegraphics[width=\textwidth * 11 / 20]{14 numbers scale factor.png}}%
    \\ \texttt{\tiny{Source: Table 20-III, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Decoding numbers}

    \begin{enumerate}
        \item<2-> Parse the bits as if they were a normal integer

        \item<3-> Convert the integer from two's complement representation (if necessary)

        \item<4-> Multiply by the scale factor
    \end{enumerate}
\end{frame}

\begin{frame}
    \frametitle{Decoding numbers}

    \centering
    \includegraphics[width=\textwidth * 11 / 20]{15 numbers semi-circles.png} \\
    \texttt{\tiny{Source: Table 20-III, IS-GPS-200M, https://www.gps.gov/technical/icwg/IS-GPS-200M.pdf}}
\end{frame}

\begin{frame}
    \frametitle{Recap}

    \begin{itemize}
        \item<2-> Pseudosymbols $\rightarrow$ bits $\rightarrow$ words $\rightarrow$ subframes $\rightarrow$ frames
        
        \item<3-> Group pseudosymbols into pseudobits

        \begin{enumerate}
            \item<4-> Collect several bits worth of pseudosymbols

            \item<5-> Calculate a score for each possible grouping

            \item<6-> Choose the one with the greatest score
        \end{enumerate}

        \item<7-> Map pseudobits to bits

        \begin{enumerate}
            \item<8-> Collect several subframes worth of pseudobits

            \item<9-> Search for the telemetry word preamble (or its inverse)
        \end{enumerate}

        \item<10-> Obtain subframes' data bits by applying the parity algorithm
        
        \item<11-> TOW count in handover word tells when the next subframe begins transmission

        \item<12-> Different subframes contain different parameters — we need 1, 2, and 3
    \end{itemize}
\end{frame}

\end{document}

================================================
FILE: presentation/8 solving/presentation.tex
================================================
\documentclass[aspectratio=169, xcolor=table]{beamer}

\usepackage{calc}
\usepackage{graphicx}
\usepackage{mathtools}
\usepackage{siunitx}

\graphicspath{{./images}}
\setbeamertemplate{navigation symbols}{}

\author{Chris Doble}
\date{}
\subtitle{Building a GPS receiver from scratch}
\title{Part 8: Solving}
\usetheme{Madrid}

% Show the topics frame at the start of each section
\AtBeginSection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, subsubsectionstyle=hide]
  \end{frame}
}

% Show the topics frame at the start of each subsection
\AtBeginSubsection[]
{
  \begin{frame}
    \frametitle{Topics}
    \tableofcontents[currentsection, currentsubsection, subsubsectionstyle=hide]
  \end{frame}
}

\begin{document}

\frame{\titlepage}

\section{The pseudorange equation}

\begin{frame}
  \frametitle{Coordinate system}

  \centering
  \only<1>{\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\textwidth * 35 / 100]{1 no coordinate system.pdf}}%
  \only<2>{\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\textwidth * 35 / 100]{2 geodetic.pdf}}%
  \only<3-5>{\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\textwidth * 35 / 100]{3 ecef.pdf}}%
  \only<6>{\includegraphics[clip, trim={2.4cm 4.4cm 1.6cm 3.5cm}, width=\textwidth * 35 / 100]{4 signal intersection.pdf}}%

  \onslide<4->{\vspace{0.25cm} Transit time $T$}
  \onslide<5->{$\Rightarrow$ distance $c T$}
\end{frame}

\begin{frame}
  \frametitle{The pseudorange equation}

  \begin{center}
    \only<1>{\[\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} = c T\]}%
    \only<2>{\[\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c T = 0\]}%
    \only<3>{\[\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c (T - t) = 0\]}%
    \only<4>{\[\sqrt{(X - \textcolor{red}{x})^2 + (Y - \textcolor{red}{y})^2 + (Z - \textcolor{red}{z})^2} - c (T - \textcolor{red}{t}) = 0\]}%
    \only<5>{\[\sqrt{(\textcolor{red}{X} - x)^2 + (\textcolor{red}{Y} - y)^2 + (\textcolor{red}{Z} - z)^2} - c (\textcolor{red}{T} - t) = 0\]}%
  \end{center}

  \only<1-2>{where $(x, y, z)$ is our unknown location.}%
  \only<3->{where $(x, y, z)$ is our unknown location and $t$ is our clock bias.}
\end{frame}

\section{Satellite location and transit time}

\begin{frame}
  \frametitle{Transmission time}

  \centering
  \only<2,5>{\includegraphics[width=\textwidth * 3 / 4]{5 transmission time.png}}%
  \only<3,6>{\includegraphics[width=\textwidth * 3 / 4]{7 tsv.png}}%
  \only<4>{\includegraphics[width=\textwidth * 3 / 4]{8 delta tsv.png}}%
  \only<2->{\\ $\vdots$ \vspace{0.25cm} \\}
  \only<2-4,6>{\includegraphics[width=\textwidth * 3 / 4]{6 l1 correction.png}}%
  \only<5>{\includegraphics[width=\textwidth * 3 / 4]{9 l1 correction.png}}%
\end{frame}

\begin{frame}
  \frametitle{Transmission time}

  \centering
  \Large
  \[t_{sv} = \text{TOW} \times \qty{6}{s} \onslide<2->{+ \text{PRN count} \times \qty{1}{ms}}\]
\end{frame}

\begin{frame}
  \frametitle{Reception time}

  \begin{enumerate}
    \item<2-> Record the time at which we finish receiving the PRN code (our clock)
    
    \item<3-> Add 18 leap seconds
    
    \item<4-> Calculate the number of seconds since GPS started operating
    
    \item<5-> Calculate the remainder when divided by the number of seconds in a GPS week
  \end{enumerate}
\end{frame}

\begin{frame}
  \frametitle{Location}

  \centering
  \only<1>{\includegraphics[width=\textwidth/6]{10 equations.png}}%
  \only<2>{\includegraphics[width=\textwidth/6]{12 equations t.png}}%
  \hspace{0.5cm}%
  \raisebox{0.55\height}{\includegraphics[width=\textwidth/6]{11 equations 2.png}}
\end{frame}

\begin{frame}
  \frametitle{The pseudorange equation}

  \[\sqrt{(X - x)^2 + (Y - y)^2 + (Z - z)^2} - c (T - t) = 0\]
\end{frame}

\section{System of equations}

\subsection{Definition}

\begin{frame}
  \frametitle{Definition}

  \begin{align*}
    \sqrt{(X_1 - x)^2 + (Y_1 - y)^2 + (Z_1 - z)^2} - c (T_1 - t) &= 0 \\
    \sqrt{(X_2 - x)^2 + (Y_2 - y)^2 + (Z_2 - z)^2} - c (T_2 - t) &= 0 \\
    \sqrt{(X_3 - x)^2 + (Y_3 - y)^2 + (Z_3 - z)^2} - c (T_3 - t) &= 0 \\
    \sqrt{(X_4 - x)^2 + (Y_4 - y)^2 + (Z_4 - z)^2} - c (T_4 - t) &= 0
  \end{align*}
\end{frame}

\subsection{Solving}

\begin{frame}
  \frametitle{The Newton-Raphson method}

  \centering
  \only<2>{\includegraphics[width=\textwidth * 2 / 3]{13 newton raphson 1.pdf}}%
  \only<3>{\includegraphics[width=\textwidth * 2 / 3]{14 newton raphson 2.pdf}}%
  \only<4>{\includegraphics[width=\textwidth * 2 / 3]{15 newton raphson 3.pdf}}%
  \only<5>{\includegraphics[width=\textwidth * 2 / 3]{16 newton raphson 4.pdf}}%
  \only<6>{\includegraphics[width=\textwidth * 2 / 3]{17 newton raphson 5.pdf}}%
  \only<7>{\includegraphics[width=\textwidth * 2 / 3]{18 newton raphson 6.pdf}}%
\end{frame}

\begin{frame}
  \frametitle{The Gauss-Newton algorithm}

  \begin{itemize}
    \item<2-> Tries to make our four (or more) pseudorange equations of four variables all equal zero
    
    \item<3-> Initial guess $\beta_0 = (x_0, y_0, z_0, t_0)$

    \item<4-> The Jacobian matrix $J$ is the multi-dimensional equivalent of the derivative
    
    \item<5-> Cell $i, j$ tells us how the result of equation $i$ would change if we changed variable $j$
    
    \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)
    
    \item<7-> Convert from ECEF to geodetic coordinates using Bowring's method
  \end{itemize}
\end{frame}

\begin{frame}
  \frametitle{Hooray!}

  \centering
  \includegraphics[width=\textwidth * 2 / 5]{19 party.png}
\end{frame}

\begin{frame}
  \frametitle{Recap}

  \begin{itemize}
    \item<2-> We express locations using ECEF coordinates

    \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\]
    
    \item<4-> The signal transit time is defined as $T = t_\text{received} - t_\text{transmitted}$
    
    \item<5-> The satellite's location can be calculated using equations in the GPS spec
    
    \item<6-> We need a system of at least four pseudorange equations to solve for $x, y, z,$ and $t$
    
    \item<7-> The Gauss-Newton algorithm is used to estimate $x, y, z,$ and $t$
  \end{itemize}
\end{frame}

\begin{frame}
  \frametitle{}

  \centering
  \vspace{1cm}
  {\Huge Thank you!} \\
  \vspace{1cm}
  \texttt{https://github.com/chrisdoble/gps-receiver}
\end{frame}

\end{document}

================================================
FILE: rtl_sdr_gps_sampler.grc
================================================
options:
  parameters:
    author: ''
    catch_exceptions: 'True'
    category: '[GRC Hier Blocks]'
    cmake_opt: ''
    comment: ''
    copyright: ''
    description: ''
    gen_cmake: 'On'
    gen_linking: dynamic
    generate_options: qt_gui
    hier_block_src_path: '.:'
    id: rtl_sdr_gps_sampler
    max_nouts: '0'
    output_language: python
    placement: (0,0)
    qt_qss_theme: ''
    realtime_scheduling: ''
    run: 'True'
    run_command: '{python} -u {filename}'
    run_options: prompt
    sizing_mode: fixed
    thread_safe_setters: ''
    title: RTL-SDR GPS sampler
    window_size: (1000,1000)
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [8, 8]
    rotation: 0
    state: enabled

blocks:
- name: timestamp
  id: variable
  parameters:
    comment: ''
    value: str(datetime.now(timezone.utc).timestamp())
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [480, 168.0]
    rotation: 0
    state: enabled
- name: blocks_file_sink_0
  id: blocks_file_sink
  parameters:
    affinity: ''
    alias: ''
    append: 'False'
    comment: ''
    file: '''./samples-'' + timestamp'
    type: complex
    unbuffered: 'False'
    vlen: '1'
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [480, 228.0]
    rotation: 0
    state: enabled
- name: import_0
  id: import
  parameters:
    alias: ''
    comment: ''
    imports: from datetime import datetime, timezone
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [480, 128.0]
    rotation: 0
    state: enabled
- name: import_1
  id: import
  parameters:
    alias: ''
    comment: ''
    imports: import re
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [592, 128.0]
    rotation: 0
    state: enabled
- name: soapy_rtlsdr_source_0
  id: soapy_rtlsdr_source
  parameters:
    affinity: ''
    agc: 'False'
    alias: ''
    bias: 'True'
    bufflen: '16384'
    center_freq: 1575.42e6
    comment: ''
    dev_args: ''
    freq_correction: '0'
    gain: '20'
    maxoutbuf: '0'
    minoutbuf: '0'
    samp_rate: 2.046e6
    type: fc32
  states:
    bus_sink: false
    bus_source: false
    bus_structure: null
    coordinate: [272, 232.0]
    rotation: 0
    state: enabled

connections:
- [soapy_rtlsdr_source_0, '0', blocks_file_sink_0, '0']

metadata:
  file_format: 1
  grc_version: 3.10.11.0
Download .txt
gitextract_6zztte4y/

├── .gitignore
├── .vscode/
│   ├── launch.json
│   └── settings.json
├── LICENSE
├── README.md
├── bin/
│   └── generate_dashboard_types.sh
├── dashboard/
│   ├── .gitignore
│   ├── .prettierrc.json
│   ├── eslint.config.js
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── Dashboard.css
│   │   ├── Dashboard.tsx
│   │   ├── TrackedSatelliteInformation.css
│   │   ├── TrackedSatelliteInformation.tsx
│   │   ├── http_types.ts
│   │   ├── main.tsx
│   │   └── vite-env.d.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.common.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── gpsreceiver/
│   ├── .gitignore
│   ├── gpsreceiver/
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   ├── acquirer.py
│   │   ├── antenna.py
│   │   ├── config.py
│   │   ├── constants.py
│   │   ├── http_types.py
│   │   ├── pipeline.py
│   │   ├── prn_codes.py
│   │   ├── pseudobit_integrator.py
│   │   ├── pseudosymbol_integrator.py
│   │   ├── receiver.py
│   │   ├── subframe_decoder.py
│   │   ├── subframes.py
│   │   ├── tracker.py
│   │   ├── types.py
│   │   ├── utils.py
│   │   └── world.py
│   ├── makefile
│   ├── mypy.ini
│   └── requirements.txt
├── presentation/
│   ├── .gitignore
│   ├── 1 introduction/
│   │   └── presentation.tex
│   ├── 2 correlation/
│   │   └── presentation.tex
│   ├── 3 GPS signals/
│   │   └── presentation.tex
│   ├── 4 sampling/
│   │   └── presentation.tex
│   ├── 5 acquisition/
│   │   └── presentation.tex
│   ├── 6 tracking/
│   │   └── presentation.tex
│   ├── 7 decoding/
│   │   └── presentation.tex
│   └── 8 solving/
│       └── presentation.tex
└── rtl_sdr_gps_sampler.grc
Download .txt
SYMBOL INDEX (142 symbols across 17 files)

FILE: dashboard/src/Dashboard.tsx
  function Dashboard (line 15) | function Dashboard() {
  function getActualLocation (line 121) | function getActualLocation(): google.maps.LatLngLiteral | null {

FILE: dashboard/src/TrackedSatelliteInformation.tsx
  function TrackedSatelliteInformation (line 19) | function TrackedSatelliteInformation({
  function toEmoji (line 61) | function toEmoji(value: boolean): string {
  function toHoursMinutesSeconds (line 70) | function toHoursMinutesSeconds(duration: number): string {
  function LineChart_ (line 83) | function LineChart_({ data, title }: { data: number[]; title: string }) {
  function CorrelationChart (line 110) | function CorrelationChart({ data }: { data: number[][] }) {
  function CorrelationDot (line 140) | function CorrelationDot({ cx, cy }: DotProps) {

FILE: dashboard/src/http_types.ts
  type HttpData (line 9) | interface HttpData {
  type GeodeticSolution (line 17) | interface GeodeticSolution {
  type GeodeticCoordinates (line 24) | interface GeodeticCoordinates {
  type TrackedSatellite (line 32) | interface TrackedSatellite {
  type UntrackedSatellite (line 46) | interface UntrackedSatellite {

FILE: gpsreceiver/gpsreceiver/acquirer.py
  class Acquisition (line 27) | class Acquisition:
  class Acquirer (line 61) | class Acquirer(ABC):
    method __init__ (line 68) | def __init__(self) -> None:
    method handle_1ms_of_samples (line 84) | def handle_1ms_of_samples(
    method untracked_satellites (line 111) | def untracked_satellites(self) -> list[UntrackedSatellite]:
    method _get_acquisition (line 122) | def _get_acquisition(self) -> Acquisition | None:
    method _get_next_acquisition_target (line 127) | def _get_next_acquisition_target(self) -> SatelliteId | None:
  class MainProcessAcquirer (line 145) | class MainProcessAcquirer(Acquirer):
    method _get_acquisition (line 152) | def _get_acquisition(self) -> Acquisition | None:
  class SubprocessAcquirer (line 160) | class SubprocessAcquirer(Acquirer):
    method __init__ (line 167) | def __init__(self) -> None:
    method _get_acquisition (line 186) | def _get_acquisition(self) -> Acquisition | None:
  function _run_subprocess (line 207) | def _run_subprocess(connection: Connection) -> None:
  function _acquire_satellite (line 234) | def _acquire_satellite(
  function _acquire_satellite_at_frequency_shifts (line 284) | def _acquire_satellite_at_frequency_shifts(

FILE: gpsreceiver/gpsreceiver/antenna.py
  class Antenna (line 15) | class Antenna(ABC):
    method __init__ (line 21) | def __init__(self, receiver: Receiver) -> None:
    method start (line 25) | def start(self) -> None:
  class FileAntenna (line 34) | class FileAntenna(Antenna):
    method __init__ (line 41) | def __init__(
    method start (line 52) | def start(self) -> None:
    method _sample_1ms (line 56) | def _sample_1ms(self) -> OneMsOfSamples:
  class RtlSdrAntenna (line 82) | class RtlSdrAntenna(Antenna):
    method __init__ (line 88) | def __init__(self, receiver: Receiver, gain: int) -> None:
    method start (line 100) | def start(self) -> None:
    method _on_samples (line 113) | def _on_samples(self, samples: np.ndarray, _: RtlSdr) -> None:

FILE: gpsreceiver/gpsreceiver/http_types.py
  class GeodeticCoordinates (line 11) | class GeodeticCoordinates(BaseModel):
  class GeodeticSolution (line 19) | class GeodeticSolution(BaseModel):
  class TrackedSatellite (line 37) | class TrackedSatellite(BaseModel):
    method serialize_correlations (line 80) | def serialize_correlations(self, correlations: list[complex]) -> list[...
  class UntrackedSatellite (line 84) | class UntrackedSatellite(BaseModel):
  class HttpData (line 94) | class HttpData(BaseModel):

FILE: gpsreceiver/gpsreceiver/pipeline.py
  class Pipeline (line 11) | class Pipeline:
    method __init__ (line 19) | def __init__(self, acquisition: Acquisition, world: World) -> None:
    method get_tracked_satellite (line 32) | def get_tracked_satellite(self, time: UtcTimestamp) -> TrackedSatellite:
    method handle_1ms_of_samples (line 47) | def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:

FILE: gpsreceiver/gpsreceiver/prn_codes.py
  function _lfsr (line 21) | def _lfsr(outputs: list[int], taps: list[int]) -> Iterator[int]:

FILE: gpsreceiver/gpsreceiver/pseudobit_integrator.py
  class PseudobitIntegrator (line 33) | class PseudobitIntegrator:
    method __init__ (line 45) | def __init__(
    method bit_phase (line 58) | def bit_phase(self) -> BitPhase | None:
    method handle_pseudobit (line 61) | def handle_pseudobit(self, pseudobit: Pseudobit) -> None:
    method _determine_bit_phase (line 81) | def _determine_bit_phase(self) -> None:
    method _all_subframes_start_with_preamble (line 112) | def _all_subframes_start_with_preamble(
    method _resolve_bit (line 136) | def _resolve_bit(self, pseudobit: Pseudobit) -> Bit:
  class UnknownBitPhaseError (line 148) | class UnknownBitPhaseError(Exception):

FILE: gpsreceiver/gpsreceiver/pseudosymbol_integrator.py
  class PseudosymbolIntegrator (line 30) | class PseudosymbolIntegrator:
    method __init__ (line 47) | def __init__(
    method bit_boundary_found (line 56) | def bit_boundary_found(self) -> bool:
    method handle_pseudosymbol (line 59) | def handle_pseudosymbol(self, pseudosymbol: Pseudosymbol) -> None:
    method _find_bit_boundary (line 86) | def _find_bit_boundary(self) -> None:
  function _chunks (line 112) | def _chunks[T](elements: list[T], chunk_size: int) -> list[list[T]]:

FILE: gpsreceiver/gpsreceiver/receiver.py
  class Receiver (line 32) | class Receiver:
    method __init__ (line 33) | def __init__(self, acquirer: Acquirer, *, run_http_server: bool) -> None:
    method handle_1ms_of_samples (line 56) | def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:
    method _drop_satellite (line 108) | def _drop_satellite(self, satellite_id: SatelliteId) -> None:
    method _get_http_data (line 117) | def _get_http_data(self, time: UtcTimestamp) -> HttpData:
  function _run_http_subprocess (line 128) | def _run_http_subprocess(queue: Queue) -> None:
  function _ecef_to_geodetic (line 168) | def _ecef_to_geodetic(ecef: EcefCoordinates) -> GeodeticCoordinates:

FILE: gpsreceiver/gpsreceiver/subframe_decoder.py
  class SubframeDecoder (line 28) | class SubframeDecoder:
    method __init__ (line 35) | def __init__(self, satellite_id: SatelliteId, world: World) -> None:
    method count (line 43) | def count(self) -> int:
    method handle_bits (line 46) | def handle_bits(self, bits: list[Bit]) -> None:
  class _SubframeDecoder (line 55) | class _SubframeDecoder:
    method __init__ (line 63) | def __init__(self, transmitted: list[Bit]) -> None:
    method decode (line 67) | def decode(self) -> Subframe:
    method _decode_telemetry (line 90) | def _decode_telemetry(self) -> None:
    method _decode_handover (line 108) | def _decode_handover(self) -> Handover:
    method _decode_subframe_1 (line 126) | def _decode_subframe_1(self, handover: Handover) -> Subframe1:
    method _decode_subframe_2 (line 170) | def _decode_subframe_2(self, handover: Handover) -> Subframe2:
    method _decode_subframe_3 (line 204) | def _decode_subframe_3(self, handover: Handover) -> Subframe3:
    method _decode_subframe_4 (line 233) | def _decode_subframe_4(self, handover: Handover) -> Subframe4:
    method _decode_subframe_5 (line 237) | def _decode_subframe_5(self, handover: Handover) -> Subframe5:
    method _get_bit (line 241) | def _get_bit(self) -> Bit:
    method _get_bits (line 245) | def _get_bits(self, bit_count: int) -> list[Bit]:
    method _get_bool (line 255) | def _get_bool(self) -> bool:
    method _get_float (line 258) | def _get_float(
    method _get_int (line 274) | def _get_int(self, bit_count: int) -> int:
    method _skip_bits (line 277) | def _skip_bits(self, bit_count: int) -> None:
  function _decode_subframe_data (line 281) | def _decode_subframe_data(subframe_transmitted: list[Bit]) -> list[Bit]:
  class ParityError (line 370) | class ParityError(Exception):
  function _verify_parity (line 376) | def _verify_parity(

FILE: gpsreceiver/gpsreceiver/subframes.py
  class Handover (line 10) | class Handover:
  class Subframe (line 30) | class Subframe:
  class Subframe1 (line 35) | class Subframe1(Subframe):
  class Subframe2 (line 55) | class Subframe2(Subframe):
  class Subframe3 (line 72) | class Subframe3(Subframe):
  class Subframe4 (line 89) | class Subframe4(Subframe):
  class Subframe5 (line 100) | class Subframe5(Subframe):

FILE: gpsreceiver/gpsreceiver/tracker.py
  class Tracker (line 23) | class Tracker:
    method __init__ (line 26) | def __init__(
    method carrier_frequency_shifts (line 100) | def carrier_frequency_shifts(self) -> list[float]:
    method correlations (line 104) | def correlations(self) -> list[complex]:
    method handle_1ms_of_samples (line 107) | def handle_1ms_of_samples(self, samples: OneMsOfSamples) -> None:
    method prn_code_phase_shifts (line 178) | def prn_code_phase_shifts(self) -> list[float]:
    method _carrier_frequency_shift (line 182) | def _carrier_frequency_shift(self) -> float:
    method _carrier_phase_shift (line 190) | def _carrier_phase_shift(self) -> float:
    method _track_prn_code_phase_shift (line 197) | def _track_prn_code_phase_shift(self, shifted_samples: np.ndarray) -> ...
    method _prn_code_phase_shift (line 263) | def _prn_code_phase_shift(self) -> float:
    method _track_carrier (line 270) | def _track_carrier(self, correlation: complex) -> None:

FILE: gpsreceiver/gpsreceiver/types.py
  class Samples (line 35) | class Samples:
    method __add__ (line 50) | def __add__(self, other: Samples) -> Samples:
    method __getitem__ (line 68) | def __getitem__(self, key: slice) -> Samples:
  class Side (line 120) | class Side(Enum):

FILE: gpsreceiver/gpsreceiver/utils.py
  class InvariantError (line 4) | class InvariantError(Exception):
  function invariant (line 10) | def invariant(condition: bool, message: str = "") -> None:
  function parse_int_from_bits (line 20) | def parse_int_from_bits(bits: list[Bit]) -> int:

FILE: gpsreceiver/gpsreceiver/world.py
  class PendingSatelliteParameters (line 24) | class PendingSatelliteParameters:
    method handle_subframe (line 40) | def handle_subframe(self, subframe: Subframe) -> None:
    method to_satellite_parameters (line 55) | def to_satellite_parameters(self) -> SatelliteParameters | None:
  class SatelliteParameters (line 106) | class SatelliteParameters:
    method handle_subframe (line 159) | def handle_subframe(self, subframe: Subframe) -> None:
  class EcefCoordinates (line 204) | class EcefCoordinates:
  class EcefSolution (line 213) | class EcefSolution:
  class World (line 226) | class World:
    method __init__ (line 229) | def __init__(self) -> None:
    method compute_solution (line 240) | def compute_solution(self) -> EcefSolution | None:
    method has_required_subframes (line 283) | def has_required_subframes(self, satellite_id: SatelliteId) -> bool:
    method _compute_satellite_position_and_signal_transit_time (line 289) | def _compute_satellite_position_and_signal_transit_time(
    method _compute_satellite_t (line 334) | def _compute_satellite_t(self, satellite_id: SatelliteId) -> float:
    method _get_satellite_parameters_or_error (line 356) | def _get_satellite_parameters_or_error(
    method _wrap_time_delta (line 366) | def _wrap_time_delta(self, t: float) -> float:
    method _compute_satellite_e_k (line 382) | def _compute_satellite_e_k(self, satellite_id: SatelliteId, t_k: float...
    method _to_time_of_week (line 404) | def _to_time_of_week(self, timestamp: UtcTimestamp) -> float:
    method _compute_jacobian (line 427) | def _compute_jacobian(
    method _compute_residuals (line 460) | def _compute_residuals(
    method drop_satellite (line 483) | def drop_satellite(self, satellite_id: SatelliteId) -> None:
    method handle_prns_tracked (line 495) | def handle_prns_tracked(
    method handle_subframe (line 546) | def handle_subframe(self, satellite_id: SatelliteId, subframe: Subfram...
    method _maybe_promote_pending_satellite_parameters (line 561) | def _maybe_promote_pending_satellite_parameters(
Condensed preview — 55 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (201K chars).
[
  {
    "path": ".gitignore",
    "chars": 65,
    "preview": "# Mac\n.DS_Store\n\n# Project\ndata\nrtl_sdr_gps_sampler.py\nsamples-*\n"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 206,
    "preview": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Debug\",\n            \"type\": \"debugpy\",\n"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 524,
    "preview": "{\n    \"[python]\": {\n        \"editor.codeActionsOnSave\": {\n            \"source.organizeImports\": \"explicit\"\n        },\n  "
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) 2025 Chris Doble\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
  },
  {
    "path": "README.md",
    "chars": 5553,
    "preview": "This repository contains my software-defined GPS receiver project.\n\n<p align=\"center\">\n  <img src=\"./presentation/1 intr"
  },
  {
    "path": "bin/generate_dashboard_types.sh",
    "chars": 1544,
    "preview": "#!/bin/bash\nset -eu\n\nDIR=\"$(cd $(dirname \"$0\") && pwd)\"\n\nmain() {\n    if [[ \"$@\" == \"--help\" ]]; then\n        echo \"Gene"
  },
  {
    "path": "dashboard/.gitignore",
    "chars": 21,
    "preview": "*.local\nnode_modules\n"
  },
  {
    "path": "dashboard/.prettierrc.json",
    "chars": 199,
    "preview": "{\n    \"importOrder\": [\"^\\\\.\"],\n    \"importOrderCaseInsensitive\": true,\n    \"importOrderSeparation\": true,\n    \"importOrd"
  },
  {
    "path": "dashboard/eslint.config.js",
    "chars": 674,
    "preview": "import js from \"@eslint/js\";\nimport reactHooks from \"eslint-plugin-react-hooks\";\nimport reactRefresh from \"eslint-plugin"
  },
  {
    "path": "dashboard/index.html",
    "chars": 299,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "dashboard/package.json",
    "chars": 932,
    "preview": "{\n  \"dependencies\": {\n    \"@vis.gl/react-google-maps\": \"^1.5.0\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n   "
  },
  {
    "path": "dashboard/src/Dashboard.css",
    "chars": 370,
    "preview": "body {\n    font-family: sans-serif;\n    margin: 0;\n}\n\n.message {\n    left: 50%;\n    margin: 0;\n    position: absolute;\n "
  },
  {
    "path": "dashboard/src/Dashboard.tsx",
    "chars": 4049,
    "preview": "import {\n  AdvancedMarker,\n  Map,\n  Pin,\n  RenderingType,\n  useMap,\n  useMapsLibrary,\n} from \"@vis.gl/react-google-maps\""
  },
  {
    "path": "dashboard/src/TrackedSatelliteInformation.css",
    "chars": 795,
    "preview": ".tracked-satellite-container {\n    background: #fafafa;\n    border-radius: 10px;\n    padding: 10px;\n}\n\n.tracked-satellit"
  },
  {
    "path": "dashboard/src/TrackedSatelliteInformation.tsx",
    "chars": 4118,
    "preview": "import { useCallback, useMemo } from \"react\";\nimport {\n  Dot,\n  DotProps,\n  Line,\n  LineChart,\n  ResponsiveContainer,\n  "
  },
  {
    "path": "dashboard/src/http_types.ts",
    "chars": 1237,
    "preview": "/**\n * This file was automatically generated. Don't edit it by hand. Instead, change\n * gpsreceiver/gpsreceiver/http_typ"
  },
  {
    "path": "dashboard/src/main.tsx",
    "chars": 374,
    "preview": "import { APIProvider } from \"@vis.gl/react-google-maps\";\nimport { StrictMode } from \"react\";\nimport { createRoot } from "
  },
  {
    "path": "dashboard/src/vite-env.d.ts",
    "chars": 38,
    "preview": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "dashboard/tsconfig.app.json",
    "chars": 294,
    "preview": "{\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"target\": \"ES2020\",\n "
  },
  {
    "path": "dashboard/tsconfig.common.json",
    "chars": 450,
    "preview": "{\n    \"compilerOptions\": {\n        \"allowImportingTsExtensions\": true,\n        \"isolatedModules\": true,\n        \"module\""
  },
  {
    "path": "dashboard/tsconfig.json",
    "chars": 119,
    "preview": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "dashboard/tsconfig.node.json",
    "chars": 223,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\","
  },
  {
    "path": "dashboard/vite.config.ts",
    "chars": 136,
    "preview": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  plugins:"
  },
  {
    "path": "gpsreceiver/.gitignore",
    "chars": 28,
    "preview": "__pycache__\n.env\n.mypy_cache"
  },
  {
    "path": "gpsreceiver/gpsreceiver/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gpsreceiver/gpsreceiver/__main__.py",
    "chars": 1324,
    "preview": "import logging\nfrom argparse import ArgumentParser\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfro"
  },
  {
    "path": "gpsreceiver/gpsreceiver/acquirer.py",
    "chars": 12675,
    "preview": "import time\nfrom abc import ABC, abstractmethod\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom dat"
  },
  {
    "path": "gpsreceiver/gpsreceiver/antenna.py",
    "chars": 4445,
    "preview": "import signal\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import"
  },
  {
    "path": "gpsreceiver/gpsreceiver/config.py",
    "chars": 6357,
    "preview": "\"\"\"This module contains values that, at least in theory, could be changed to\nalter the receiver's behaviour. In practice"
  },
  {
    "path": "gpsreceiver/gpsreceiver/constants.py",
    "chars": 755,
    "preview": "\"\"\"This module contains commonly used values whose definitions shouldn't change,\neither because they're defined in the G"
  },
  {
    "path": "gpsreceiver/gpsreceiver/http_types.py",
    "chars": 3491,
    "preview": "\"\"\"This module contains types that are used to send data to the HTTP server\n    subprocess and are then served by that s"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pipeline.py",
    "chars": 2179,
    "preview": "from .acquirer import Acquisition\nfrom .http_types import TrackedSatellite\nfrom .pseudobit_integrator import PseudobitIn"
  },
  {
    "path": "gpsreceiver/gpsreceiver/prn_codes.py",
    "chars": 4269,
    "preview": "\"\"\"This module generates the GPS satellites' C/A PRN codes.\n\nThe PRN codes are generated by XORing the output of two lin"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pseudobit_integrator.py",
    "chars": 5771,
    "preview": "import logging\n\nimport numpy as np\n\nfrom .config import PREAMBLES_REQUIRED_TO_DETERMINE_BIT_PHASE\nfrom .constants import"
  },
  {
    "path": "gpsreceiver/gpsreceiver/pseudosymbol_integrator.py",
    "chars": 5128,
    "preview": "import logging\nfrom collections import Counter\n\nimport numpy as np\n\nfrom .config import BITS_REQUIRED_TO_DETECT_BOUNDARI"
  },
  {
    "path": "gpsreceiver/gpsreceiver/receiver.py",
    "chars": 7027,
    "preview": "import asyncio\nimport logging\nimport math\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom multiproc"
  },
  {
    "path": "gpsreceiver/gpsreceiver/subframe_decoder.py",
    "chars": 11813,
    "preview": "import logging\nfrom typing import cast\n\nfrom .constants import BITS_PER_SUBFRAME\nfrom .subframes import (\n    Handover,\n"
  },
  {
    "path": "gpsreceiver/gpsreceiver/subframes.py",
    "chars": 2629,
    "preview": "from dataclasses import dataclass\nfrom typing import Literal\n\nfrom .types import Bit\n\nSubframeId = Literal[1, 2, 3, 4, 5"
  },
  {
    "path": "gpsreceiver/gpsreceiver/tracker.py",
    "chars": 15994,
    "preview": "import math\nfrom collections import deque\nfrom datetime import timedelta\n\nimport numpy as np\nfrom typing_extensions impo"
  },
  {
    "path": "gpsreceiver/gpsreceiver/types.py",
    "chars": 4607,
    "preview": "from __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta\nfrom enum"
  },
  {
    "path": "gpsreceiver/gpsreceiver/utils.py",
    "chars": 625,
    "preview": "from .types import Bit\n\n\nclass InvariantError(Exception):\n    \"\"\"An exception raised when an invariant condition is viol"
  },
  {
    "path": "gpsreceiver/gpsreceiver/world.py",
    "chars": 23553,
    "preview": "from __future__ import annotations\n\nimport logging\nimport math\nfrom dataclasses import dataclass\nfrom datetime import da"
  },
  {
    "path": "gpsreceiver/makefile",
    "chars": 302,
    "preview": "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 gp"
  },
  {
    "path": "gpsreceiver/mypy.ini",
    "chars": 494,
    "preview": "[mypy]\nplugins = pydantic.mypy\n\ndisallow_any_explicit = True\ndisallow_subclassing_any = True\ndisallow_incomplete_defs = "
  },
  {
    "path": "gpsreceiver/requirements.txt",
    "chars": 190,
    "preview": "# 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\npyrtls"
  },
  {
    "path": "presentation/.gitignore",
    "chars": 74,
    "preview": "*.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",
    "chars": 3281,
    "preview": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\u"
  },
  {
    "path": "presentation/2 correlation/presentation.tex",
    "chars": 3486,
    "preview": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\n\\graphicspath{{./images}}\n\\setbeamerte"
  },
  {
    "path": "presentation/3 GPS signals/presentation.tex",
    "chars": 8362,
    "preview": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor"
  },
  {
    "path": "presentation/4 sampling/presentation.tex",
    "chars": 7010,
    "preview": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor"
  },
  {
    "path": "presentation/5 acquisition/presentation.tex",
    "chars": 6610,
    "preview": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{siunitx}\n\\usepackage{xcolor"
  },
  {
    "path": "presentation/6 tracking/presentation.tex",
    "chars": 6011,
    "preview": "\\documentclass[aspectratio=169]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\usepackage{siun"
  },
  {
    "path": "presentation/7 decoding/presentation.tex",
    "chars": 8302,
    "preview": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\u"
  },
  {
    "path": "presentation/8 solving/presentation.tex",
    "chars": 6559,
    "preview": "\\documentclass[aspectratio=169, xcolor=table]{beamer}\n\n\\usepackage{calc}\n\\usepackage{graphicx}\n\\usepackage{mathtools}\n\\u"
  },
  {
    "path": "rtl_sdr_gps_sampler.grc",
    "chars": 2481,
    "preview": "options:\n  parameters:\n    author: ''\n    catch_exceptions: 'True'\n    category: '[GRC Hier Blocks]'\n    cmake_opt: ''\n "
  }
]

About this extraction

This page contains the full source code of the chrisdoble/gps-receiver GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 55 files (184.7 KB), approximately 52.3k tokens, and a symbol index with 142 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!