Add Starlight documentation site with diataxis structure
14 content pages across four diataxis categories: - Getting Started: introduction, installation, quick-start tutorial - Understanding Apollo USB: signal architecture, PCM frame structure, AGC integration - How-To Guides: tuning parameters, test signals, voice audio, AGC bridge, PCM telemetry - Reference: block reference (all 16 blocks), constants, protocol specification Includes Mermaid diagram support via rehype-mermaid with client-side rendering, dark theme, Pagefind search index, and edit-on-GitHub links.
This commit is contained in:
parent
0ee7ff0ad7
commit
12fb284d5f
21
docs/.gitignore
vendored
Normal file
21
docs/.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
49
docs/README.md
Normal file
49
docs/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Starlight Starter Kit: Basics
|
||||||
|
|
||||||
|
[](https://starlight.astro.build)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm create astro@latest -- --template starlight
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ ├── assets/
|
||||||
|
│ ├── content/
|
||||||
|
│ │ └── docs/
|
||||||
|
│ └── content.config.ts
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||||
|
|
||||||
|
Static assets, like favicons, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||||
64
docs/astro.config.mjs
Normal file
64
docs/astro.config.mjs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import starlight from '@astrojs/starlight';
|
||||||
|
import rehypeMermaid from 'rehype-mermaid';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: 'gr-apollo',
|
||||||
|
description: 'Apollo Unified S-Band decoder for GNU Radio 3.10+',
|
||||||
|
social: [
|
||||||
|
{ icon: 'github', label: 'GitHub', href: 'https://github.com/rpm/gr-apollo' },
|
||||||
|
],
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: 'Getting Started',
|
||||||
|
items: [
|
||||||
|
{ label: 'Introduction', slug: 'getting-started/introduction' },
|
||||||
|
{ label: 'Installation', slug: 'getting-started/installation' },
|
||||||
|
{ label: 'Quick Start', slug: 'getting-started/quick-start' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Understanding Apollo USB',
|
||||||
|
items: [
|
||||||
|
{ label: 'Signal Architecture', slug: 'explanation/signal-architecture' },
|
||||||
|
{ label: 'PCM Frame Structure', slug: 'explanation/pcm-frames' },
|
||||||
|
{ label: 'Virtual AGC Integration', slug: 'explanation/virtual-agc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'How-To Guides',
|
||||||
|
items: [
|
||||||
|
{ label: 'Tune Demodulator Parameters', slug: 'guides/tuning-parameters' },
|
||||||
|
{ label: 'Generate Test Signals', slug: 'guides/test-signals' },
|
||||||
|
{ label: 'Decode Voice Audio', slug: 'guides/voice-audio' },
|
||||||
|
{ label: 'Connect to Virtual AGC', slug: 'guides/agc-bridge' },
|
||||||
|
{ label: 'Work with PCM Telemetry', slug: 'guides/pcm-telemetry' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reference',
|
||||||
|
items: [
|
||||||
|
{ label: 'Block Reference', slug: 'reference/blocks' },
|
||||||
|
{ label: 'Constants & Parameters', slug: 'reference/constants' },
|
||||||
|
{ label: 'Protocol Specification', slug: 'reference/protocol' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editLink: {
|
||||||
|
baseUrl: 'https://github.com/rpm/gr-apollo/edit/main/docs/',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Head: './src/components/Head.astro',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
markdown: {
|
||||||
|
rehypePlugins: [
|
||||||
|
[rehypeMermaid, { strategy: 'pre-mermaid' }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devToolbar: { enabled: false },
|
||||||
|
});
|
||||||
8159
docs/package-lock.json
generated
Normal file
8159
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
docs/package.json
Normal file
19
docs/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/starlight": "^0.37.6",
|
||||||
|
"astro": "^5.6.1",
|
||||||
|
"mermaid": "^11.12.3",
|
||||||
|
"rehype-mermaid": "^3.0.0",
|
||||||
|
"sharp": "^0.34.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
docs/public/favicon.svg
Normal file
1
docs/public/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
BIN
docs/src/assets/houston.webp
Normal file
BIN
docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
8
docs/src/components/Head.astro
Normal file
8
docs/src/components/Head.astro
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import type { Props } from '@astrojs/starlight/props';
|
||||||
|
import Default from '@astrojs/starlight/components/Head.astro';
|
||||||
|
import MermaidInit from './MermaidInit.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Default {...Astro.props}><slot /></Default>
|
||||||
|
<MermaidInit />
|
||||||
52
docs/src/components/MermaidInit.astro
Normal file
52
docs/src/components/MermaidInit.astro
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
import mermaid from 'mermaid';
|
||||||
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: 'dark',
|
||||||
|
themeVariables: {
|
||||||
|
primaryColor: '#3b82f6',
|
||||||
|
primaryTextColor: '#f8fafc',
|
||||||
|
primaryBorderColor: '#60a5fa',
|
||||||
|
lineColor: '#94a3b8',
|
||||||
|
secondaryColor: '#1e3a5f',
|
||||||
|
tertiaryColor: '#0f172a',
|
||||||
|
background: '#0f172a',
|
||||||
|
mainBkg: '#1e293b',
|
||||||
|
nodeBorder: '#60a5fa',
|
||||||
|
clusterBkg: '#1e293b',
|
||||||
|
clusterBorder: '#334155',
|
||||||
|
titleColor: '#f8fafc',
|
||||||
|
edgeLabelBackground: '#1e293b',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderMermaid() {
|
||||||
|
const elements = document.querySelectorAll('pre.mermaid');
|
||||||
|
for (const el of elements) {
|
||||||
|
const code = el.textContent || '';
|
||||||
|
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const { svg } = await mermaid.render(id, code);
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.classList.add('mermaid-diagram');
|
||||||
|
wrapper.innerHTML = svg;
|
||||||
|
el.replaceWith(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render on initial load and on Astro view transitions
|
||||||
|
renderMermaid();
|
||||||
|
document.addEventListener('astro:after-swap', renderMermaid);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.mermaid-diagram {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.mermaid-diagram svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
docs/src/content.config.ts
Normal file
7
docs/src/content.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||||
|
import { docsSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
231
docs/src/content/docs/explanation/pcm-frames.mdx
Normal file
231
docs/src/content/docs/explanation/pcm-frames.mdx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
title: "PCM Frame Structure"
|
||||||
|
description: "How Apollo telemetry frames are constructed, synchronized, and interpreted"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The PCM (Pulse Code Modulation) telemetry system converts analog sensor readings and digital status words from the spacecraft into a serial bit stream. This stream is organized into fixed-length frames, each beginning with a sync word that lets the ground station find the frame boundaries in the raw data. Understanding the frame structure is essential for interpreting anything the spacecraft sends.
|
||||||
|
|
||||||
|
## Frame layout
|
||||||
|
|
||||||
|
Each high-rate frame contains exactly 128 eight-bit words, for a total of 1024 bits. The first four words (32 bits) are the sync word. The remaining 124 words carry telemetry data.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
block-beta
|
||||||
|
columns 8
|
||||||
|
|
||||||
|
block:sync:4
|
||||||
|
s1["Word 1"] s2["Word 2"] s3["Word 3"] s4["Word 4"]
|
||||||
|
end
|
||||||
|
block:data:4
|
||||||
|
d1["Word 5"] d2["..."] d3["..."] d4["Word 128"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style sync fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style data fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
At the high bit rate of 51,200 bps, each 1024-bit frame takes exactly 20 milliseconds to transmit. Fifty of these frames make up one subframe, which spans exactly one second.
|
||||||
|
|
||||||
|
## The sync word
|
||||||
|
|
||||||
|
The 32-bit sync word is not a simple fixed pattern. It encodes four distinct fields:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
block-beta
|
||||||
|
columns 32
|
||||||
|
block:a:5
|
||||||
|
a1["A field<br/>5 bits"]
|
||||||
|
end
|
||||||
|
block:core:15
|
||||||
|
c1["Fixed Core<br/>15 bits"]
|
||||||
|
end
|
||||||
|
block:b:6
|
||||||
|
b1["B field<br/>6 bits"]
|
||||||
|
end
|
||||||
|
block:fid:6
|
||||||
|
f1["Frame ID<br/>6 bits"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style a fill:#2d5016,stroke:#4a8c2a
|
||||||
|
style core fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style b fill:#2d5016,stroke:#4a8c2a
|
||||||
|
style fid fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Bits | Default Value | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | 5 | `10101` | Patchboard-selectable identifier |
|
||||||
|
| Core | 15 | `111001101011100` | Fixed correlator pattern (complemented on odd frames) |
|
||||||
|
| B | 6 | `110100` | Patchboard-selectable identifier |
|
||||||
|
| Frame ID | 6 | 1-50 | Frame number within subframe |
|
||||||
|
|
||||||
|
The A and B fields were configurable via patchboard on the actual spacecraft hardware -- they served as a mission identifier, letting the ground station distinguish between multiple spacecraft transmitting on similar frequencies. gr-apollo uses the default values from the study guide.
|
||||||
|
|
||||||
|
The Frame ID counts from 1 to 50 within each subframe. This is how the ground station knows which frame it is looking at within the one-second subframe cycle.
|
||||||
|
|
||||||
|
### Complement-on-odd
|
||||||
|
|
||||||
|
The 15-bit fixed core is bitwise complemented on odd-numbered frames. On even frames (2, 4, 6, ..., 50), the core bits are `111001101011100`. On odd frames (1, 3, 5, ..., 49), they become `000110010100011`.
|
||||||
|
|
||||||
|
This alternation serves two purposes:
|
||||||
|
|
||||||
|
1. **DC balance.** If the core never changed, the sync word would have a persistent DC bias that could cause baseline wander in the NRZ bit stream. Alternating between the pattern and its complement ensures the long-term average is near zero.
|
||||||
|
|
||||||
|
2. **Frame counting verification.** The ground station can confirm it has not gained or lost a frame by checking whether the core matches the expected polarity for the current frame ID. If the polarity disagrees with the frame count, something has slipped.
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
The correlator in `pcm_frame_sync` checks against both the normal and complemented core simultaneously. Whichever matches tells the engine whether the current frame is odd or even, which it reports in the `odd_frame` metadata field of each output PDU.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Frame sync state machine
|
||||||
|
|
||||||
|
Finding frame boundaries in a continuous bit stream is a three-phase process. The `FrameSyncEngine` implements a state machine:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> SEARCH
|
||||||
|
SEARCH --> VERIFY : Sync match found<br/>(Hamming distance <= 3)
|
||||||
|
VERIFY --> LOCKED : N consecutive hits<br/>at frame boundaries
|
||||||
|
VERIFY --> SEARCH : Miss at expected boundary
|
||||||
|
LOCKED --> LOCKED : Hit at frame boundary
|
||||||
|
LOCKED --> SEARCH : M consecutive misses
|
||||||
|
|
||||||
|
note right of SEARCH
|
||||||
|
Sliding 32-bit window
|
||||||
|
checks every bit position
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of VERIFY
|
||||||
|
Candidate found,
|
||||||
|
waiting to confirm
|
||||||
|
at next frame boundary
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of LOCKED
|
||||||
|
Stable lock,
|
||||||
|
emitting frames
|
||||||
|
end note
|
||||||
|
```
|
||||||
|
|
||||||
|
**SEARCH** -- The engine slides a 32-bit window across the incoming bits one bit at a time. At each position, it correlates the window contents against both the even and odd reference patterns (the 26 static bits -- A + core + B). When the Hamming distance falls within the threshold, the engine has a candidate.
|
||||||
|
|
||||||
|
**VERIFY** -- One match is not enough. Noise could produce a false trigger. The engine waits for the next frame boundary (1024 bits later at high rate) and checks whether another valid sync word appears there. After the configured number of consecutive hits at expected boundaries (default: 2), it transitions to LOCKED.
|
||||||
|
|
||||||
|
**LOCKED** -- The engine is confident it has found the correct frame alignment. It emits complete frames as PDUs. If noise corrupts a sync word, the engine tolerates it and keeps going. Only after multiple consecutive misses at frame boundaries (default: 3) does it give up and return to SEARCH.
|
||||||
|
|
||||||
|
### Why a 3-bit Hamming distance threshold?
|
||||||
|
|
||||||
|
The sync word has 26 static bits (the A, core, and B fields). The frame ID changes every frame, so the correlator ignores it. A random 26-bit sequence has an expected Hamming distance of 13 from any reference pattern. Allowing up to 3 bit errors gives a comfortable margin:
|
||||||
|
|
||||||
|
- **Probability of false trigger** (random data matching within distance 3 of a 26-bit pattern): approximately 1 in 4,000. At 51,200 bits per second, this means a false trigger roughly every 80 milliseconds -- which is why the VERIFY state exists. Two false triggers landing exactly 1024 bits apart is astronomically unlikely.
|
||||||
|
|
||||||
|
- **Probability of missed detection** (a real sync word with more than 3 errors): at typical operating SNR, the bit error rate is well below 10^-3, making 4 or more errors in the 26 static bits extremely rare.
|
||||||
|
|
||||||
|
## Data words
|
||||||
|
|
||||||
|
The 124 data words (positions 5-128) carry three types of measurements:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="High-Level Analog">
|
||||||
|
Most data words are high-level analog measurements. The spacecraft's A/D converter (the "coder") samples 0-5V sensor outputs with 8-bit resolution.
|
||||||
|
|
||||||
|
The mapping is not quite what you would expect from a standard ADC:
|
||||||
|
|
||||||
|
| Code | Voltage | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | -- | Below range (error) |
|
||||||
|
| 1 | 0.00 V | Zero reference |
|
||||||
|
| 2 | 0.0197 V | First real step |
|
||||||
|
| 127 | 2.48 V | Midscale |
|
||||||
|
| 254 | 4.98 V | Full scale |
|
||||||
|
| 255 | > 5.0 V | Overflow |
|
||||||
|
|
||||||
|
The step size is 19.7 mV per LSB, calculated as 4.98V / 253 steps. Code 0 and code 255 are reserved as boundary indicators, leaving 253 actual measurement levels from code 1 to code 254.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The `adc_to_voltage()` function in `protocol.py` implements this exact mapping: `voltage = (code - 1) * 4.98 / 253`.
|
||||||
|
</Aside>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Low-Level Analog">
|
||||||
|
Some spacecraft sensors produce signals in the 0-40 mV range (thermocouples, strain gauges). These are amplified by a factor of 125 before reaching the A/D converter, so a 40 mV input produces a full-scale 5V signal at the coder input.
|
||||||
|
|
||||||
|
The telemetry system does not mark which words are low-level -- that is determined by the patchboard configuration and known a priori from the telemetry assignment list. When processing low-level channels, divide the recovered voltage by 125 to get the actual sensor voltage.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Digital Words">
|
||||||
|
Several word positions carry digital status bits rather than analog measurements. These come from two sources:
|
||||||
|
|
||||||
|
- **Parallel digital inputs** -- 8-bit status registers read directly into frame positions
|
||||||
|
- **Serial digital inputs** -- shift-register data clocked in by the PCM system
|
||||||
|
|
||||||
|
The most important digital words for gr-apollo are at positions 34, 35, and 57, which carry AGC downlink telemetry data. These three positions are the pipeline through which the Apollo Guidance Computer's internal state reaches the ground.
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## AGC telemetry channels
|
||||||
|
|
||||||
|
Three word positions within each frame have special significance for computer telemetry:
|
||||||
|
|
||||||
|
| Word Position | AGC Channel | Octal | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 34 | DNTM1 | 034 | Telemetry word high byte (bits 14-8) |
|
||||||
|
| 35 | DNTM2 | 035 | Telemetry word low byte (bits 7-0) |
|
||||||
|
| 57 | OUTLINK | 057 | Additional digital downlink data |
|
||||||
|
|
||||||
|
The Apollo Guidance Computer uses 15-bit words internally. Since the PCM system works in 8-bit bytes, each AGC word is split across two consecutive frame positions. Word 34 carries the upper 7 bits (bits 14 through 8) in its lower 7 bits. Word 35 carries the lower 8 bits (bits 7 through 0) directly.
|
||||||
|
|
||||||
|
Reassembly is straightforward:
|
||||||
|
|
||||||
|
```python
|
||||||
|
agc_word = ((dntm1_byte & 0x7F) << 8) | dntm2_byte
|
||||||
|
```
|
||||||
|
|
||||||
|
At 50 frames per second, this means 50 AGC words per second arrive through the telemetry channel. The AGC buffers 400 words into each downlink list snapshot (taking about 8 seconds at high rate), then transmits the buffer contents as a coherent block of navigation state, autopilot settings, or other mission-phase-specific data.
|
||||||
|
|
||||||
|
## High rate vs. low rate
|
||||||
|
|
||||||
|
The PCM system supports two bit rates, selectable by ground command. The choice affects frame size, frame rate, and the amount of data per second:
|
||||||
|
|
||||||
|
| Parameter | High Rate | Low Rate |
|
||||||
|
|---|---|---|
|
||||||
|
| Bit rate | 51,200 bps | 1,600 bps |
|
||||||
|
| Clock divider | 512 kHz / 10 | 512 kHz / 320 |
|
||||||
|
| Words per frame | 128 | 200 |
|
||||||
|
| Frames per second | 50 | 1 |
|
||||||
|
| Words per second | 6,400 | 200 |
|
||||||
|
| Subframe period | 1 second (50 frames) | N/A |
|
||||||
|
| Bits per frame | 1,024 | 1,600 |
|
||||||
|
| Frame period | 20 ms | 1 second |
|
||||||
|
|
||||||
|
Low rate was used during translunar coast and other quiet phases where the Deep Space Network antenna time was shared between missions. High rate was used during critical mission phases -- launch, lunar orbit insertion, powered descent, EVA -- where dense telemetry coverage was essential.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Low-rate mode uses 200 words per frame instead of 128. The frame sync word is still 32 bits (4 words), but the frame boundary is now 1,600 bits apart instead of 1,024. The `FrameSyncEngine` automatically adjusts its frame-boundary prediction based on the configured bit rate.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Subframe structure
|
||||||
|
|
||||||
|
At high rate, 50 consecutive frames form one subframe spanning exactly one second. The subframe concept matters because some telemetry measurements are commutated -- they appear in different word positions across different frames within the subframe.
|
||||||
|
|
||||||
|
For example, a temperature sensor might be assigned to word position 72 in frame 3 of each subframe, while a pressure sensor occupies word 72 in frame 4. The same word position carries different physical measurements depending on which frame within the subframe you are looking at.
|
||||||
|
|
||||||
|
The frame ID field in the sync word (values 1 through 50) tells you which frame you are in, enabling the ground station software to route each word to the correct telemetry parameter.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph Subframe ["Subframe (1 second)"]
|
||||||
|
F1["Frame 1<br/>ID=1, odd"] --> F2["Frame 2<br/>ID=2, even"]
|
||||||
|
F2 --> F3["Frame 3<br/>ID=3, odd"]
|
||||||
|
F3 --> F4["..."]
|
||||||
|
F4 --> F50["Frame 50<br/>ID=50, even"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style Subframe fill:#1a1a2e,stroke:#3a7abd
|
||||||
|
style F1 fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style F2 fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style F3 fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style F50 fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
The alternating colors reflect the complement-on-odd behavior of the sync core: odd frames (1, 3, 5, ...) use the complemented pattern, even frames (2, 4, 6, ...) use the normal pattern.
|
||||||
173
docs/src/content/docs/explanation/signal-architecture.mdx
Normal file
173
docs/src/content/docs/explanation/signal-architecture.mdx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
title: "Signal Architecture"
|
||||||
|
description: "How the Apollo Unified S-Band downlink signal is structured, and how gr-apollo decomposes it into telemetry"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Apollo Unified S-Band (USB) system multiplexes voice, telemetry, and ranging onto a single 2287.5 MHz carrier using nested modulation layers. Understanding these layers -- and the reasons behind each design choice -- is the key to understanding what gr-apollo does and why its blocks are structured the way they are.
|
||||||
|
|
||||||
|
## The downlink signal, inside out
|
||||||
|
|
||||||
|
The spacecraft transmitter begins with a stable carrier at 2287.5 MHz. Multiple information streams are combined onto this carrier through a two-level modulation scheme: subcarriers are first individually modulated with their data, then the composite subcarrier signal phase-modulates the RF carrier.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A["PCM Telemetry<br/>51.2 kbps NRZ"] -->|BPSK| B["1.024 MHz<br/>Subcarrier"]
|
||||||
|
C["Voice Audio<br/>300-3000 Hz"] -->|FM<br/>+/-29 kHz dev| D["1.25 MHz<br/>Subcarrier"]
|
||||||
|
E["Ranging Code<br/>PRN sequence"] --> F["Ranging<br/>Subcarrier"]
|
||||||
|
|
||||||
|
B --> G["Composite<br/>Baseband Signal"]
|
||||||
|
D --> G
|
||||||
|
F --> G
|
||||||
|
|
||||||
|
G -->|PM<br/>0.133 rad peak| H["2287.5 MHz<br/>RF Carrier"]
|
||||||
|
|
||||||
|
H --> I["Transmitted<br/>Downlink"]
|
||||||
|
|
||||||
|
style A fill:#2d5016,stroke:#4a8c2a
|
||||||
|
style C fill:#2d5016,stroke:#4a8c2a
|
||||||
|
style E fill:#2d5016,stroke:#4a8c2a
|
||||||
|
style H fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style I fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a frequency-division multiplexing system. The PCM subcarrier sits at 1.024 MHz, voice at 1.25 MHz, and ranging at its own frequency. Because these subcarriers are well-separated in frequency, the receiver can extract each one independently with bandpass filters. The entire system uses a single RF carrier -- hence "Unified" S-Band.
|
||||||
|
|
||||||
|
## Why 0.133 radians?
|
||||||
|
|
||||||
|
The PM peak deviation of 0.133 rad (7.6 degrees) is one of the most important numbers in the system. It seems absurdly small -- barely a wiggle on the carrier phase. That is the point.
|
||||||
|
|
||||||
|
At small modulation indices, the relationship between the modulating signal and the recovered phase is very nearly linear. The small-angle approximation `sin(theta) ~= theta` holds to within 0.3% at 0.133 rad:
|
||||||
|
|
||||||
|
| Angle (rad) | sin(angle) | Error |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.133 | 0.1326 | 0.29% |
|
||||||
|
| 0.5 | 0.4794 | 4.1% |
|
||||||
|
| 1.0 | 0.8415 | 15.9% |
|
||||||
|
|
||||||
|
This linearity means the PM demodulator output is a faithful reproduction of the composite subcarrier signal. There is no need for pre-distortion or nonlinear correction -- the receiver just extracts the phase and gets the subcarriers back, ready for filtering and demodulation.
|
||||||
|
|
||||||
|
The tradeoff is signal power. Most of the transmitted power remains in the carrier rather than the sidebands. The spacecraft burns roughly 20 watts of RF power (via the traveling-wave tube amplifier), but only a fraction of a watt ends up in each subcarrier. The Deep Space Network's 26-meter dishes made up the difference with raw antenna gain.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The 0.133 rad deviation is for the combined PCM mode (voice + telemetry). During FM-only mode (used for pre-launch checkout), the spacecraft switches to wideband FM with much higher deviation, and the subcarrier oscillator system is used instead of PCM.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## The timing hierarchy
|
||||||
|
|
||||||
|
Every timing parameter in the Apollo PCM system derives from a single 512 kHz master clock in the Central Timing Equipment (CTE). This is not a coincidence -- it is the entire design philosophy. When all frequencies share a common root, the receiver can exploit integer relationships for synchronization.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
M["512 kHz<br/>Master Clock"] --> A["51.2 kbps bit clock<br/>divide by 10"]
|
||||||
|
M --> B["1.6 kbps bit clock<br/>divide by 320"]
|
||||||
|
A --> C["6,400 words/sec<br/>8 bits/word"]
|
||||||
|
B --> D["200 words/sec<br/>8 bits/word"]
|
||||||
|
C --> E["50 frames/sec<br/>128 words/frame"]
|
||||||
|
D --> F["1 frame/sec<br/>200 words/frame"]
|
||||||
|
E --> G["1 subframe/sec<br/>50 frames"]
|
||||||
|
|
||||||
|
M --> H["1.024 MHz subcarrier<br/>multiply by 2"]
|
||||||
|
|
||||||
|
style M fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style H fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
At high rate: 512,000 / 10 = 51,200 bits/sec. Divide by 8 bits/word = 6,400 words/sec. Divide by 128 words/frame = 50 frames/sec. Multiply 50 frames by the frame period and you get exactly 1 second per subframe.
|
||||||
|
|
||||||
|
The 1.024 MHz PCM subcarrier is 512 kHz times 2. This means exactly 20 subcarrier cycles fit in one bit period (1,024,000 / 51,200 = 20). The BPSK demodulator can use this integer relationship for more stable symbol timing recovery.
|
||||||
|
|
||||||
|
### Why 5.12 MHz sample rate?
|
||||||
|
|
||||||
|
gr-apollo's default baseband sample rate is 5.12 MHz -- exactly 10 times the master clock. This choice is deliberate:
|
||||||
|
|
||||||
|
| Relationship | Value | Integer? |
|
||||||
|
|---|---|---|
|
||||||
|
| Samples per master clock cycle | 5,120,000 / 512,000 = 10 | Yes |
|
||||||
|
| Samples per PCM subcarrier cycle | 5,120,000 / 1,024,000 = 5 | Yes |
|
||||||
|
| Samples per bit (high rate) | 5,120,000 / 51,200 = 100 | Yes |
|
||||||
|
| Samples per voice subcarrier cycle | 5,120,000 / 1,250,000 = 4.096 | No |
|
||||||
|
|
||||||
|
Every PCM-related rate divides evenly into the sample rate. This eliminates fractional sample offsets in the bit timing recovery loop, making synchronization faster and more reliable. The voice subcarrier does not divide evenly, but voice is FM -- it tolerates timing imprecision far better than digital data.
|
||||||
|
|
||||||
|
## The gr-apollo block decomposition
|
||||||
|
|
||||||
|
The receiver follows the signal structure in reverse. Each modulation layer gets its own block, and the blocks chain together in the same order the signal was built:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A["Complex<br/>Baseband<br/>5.12 MHz"] --> B["pm_demod"]
|
||||||
|
B --> C["subcarrier_extract<br/>1.024 MHz"]
|
||||||
|
C --> D["bpsk_demod"]
|
||||||
|
D --> E["pcm_frame_sync"]
|
||||||
|
E --> F["pcm_demux"]
|
||||||
|
|
||||||
|
B --> G["voice_subcarrier_demod<br/>1.25 MHz"]
|
||||||
|
|
||||||
|
F -->|"frames PDU"| H["AGC Data"]
|
||||||
|
F -->|"telemetry PDU"| I["Telemetry Words"]
|
||||||
|
F -->|"agc_data PDU"| J["downlink_decoder"]
|
||||||
|
J -->|"downlink PDU"| K["AGC Bridge"]
|
||||||
|
|
||||||
|
G --> L["Audio Out<br/>8 kHz"]
|
||||||
|
|
||||||
|
style A fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style B fill:#3a1a5c,stroke:#7a3abd
|
||||||
|
style C fill:#3a1a5c,stroke:#7a3abd
|
||||||
|
style D fill:#3a1a5c,stroke:#7a3abd
|
||||||
|
style E fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style F fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style G fill:#2d5016,stroke:#4a8c2a
|
||||||
|
```
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Streaming Chain">
|
||||||
|
The first four blocks form a streaming signal processing chain, each transforming the signal's representation:
|
||||||
|
|
||||||
|
1. **pm_demod** -- Carrier PLL locks to the residual carrier, then `atan2(Im, Re)` extracts instantaneous phase. Output is a float representing the composite modulating signal.
|
||||||
|
2. **subcarrier_extract** -- Frequency-translating FIR filter shifts the 1.024 MHz subcarrier to DC and applies a 150 kHz bandpass (matching the spec's 949-1099 kHz range). Output is complex baseband.
|
||||||
|
3. **bpsk_demod** -- Costas loop resolves phase, Mueller & Muller TED recovers symbol timing, binary slicer makes hard decisions. Output is one byte per bit (0 or 1).
|
||||||
|
4. **pcm_frame_sync** -- Sliding-window correlator finds the 32-bit sync word. Transitions through SEARCH, VERIFY, and LOCKED states. Emits complete frames as PDU messages.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Message Chain">
|
||||||
|
After frame sync, the data path switches from streaming samples to asynchronous PDU messages:
|
||||||
|
|
||||||
|
5. **pcm_demux** -- Receives frame PDUs, separates the 4-byte sync word from the 124 data words, identifies AGC channel data at word positions 34, 35, and 57, applies A/D voltage scaling.
|
||||||
|
6. **downlink_decoder** -- Collects channel 34/35 byte pairs, reassembles 15-bit AGC words, buffers 400 words into complete downlink list snapshots.
|
||||||
|
7. **agc_bridge** -- TCP client connecting to the Virtual AGC emulator (yaAGC). Bidirectional: sends uplink commands, receives AGC I/O channel updates.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Voice Path">
|
||||||
|
The voice path branches off after the PM demodulator and uses its own subcarrier extraction:
|
||||||
|
|
||||||
|
- **voice_subcarrier_demod** -- Extracts the 1.25 MHz subcarrier (58 kHz bandwidth), applies quadrature FM demodulation, bandpasses to 300-3000 Hz, and resamples to 8 kHz audio output.
|
||||||
|
|
||||||
|
The voice path is completely independent of the PCM path. Both operate simultaneously on the same PM demodulator output.
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
The `usb_downlink_receiver` hierarchical block wires the PCM chain together as a convenience -- connect complex baseband in, get telemetry PDUs out. For finer control over PLL bandwidths or to tap intermediate signals, the individual blocks can be used directly.
|
||||||
|
|
||||||
|
## Phase ambiguity and frame sync
|
||||||
|
|
||||||
|
The Costas loop in `bpsk_demod` has a fundamental 180-degree phase ambiguity. A 2nd-order Costas loop locks to one of two stable equilibria that are 180 degrees apart. If it locks to the wrong one, every recovered bit is inverted.
|
||||||
|
|
||||||
|
The system resolves this at the frame sync layer rather than at the Costas loop. The 32-bit sync word has a known pattern: `[5-bit A][15-bit core][6-bit B][6-bit frame ID]`. The A, B, and core fields are fixed (the core complements on odd frames, but this is deterministic). If the correlator finds the complement of the expected pattern everywhere, it knows the Costas loop locked to the wrong phase, and can invert the bit stream.
|
||||||
|
|
||||||
|
This is a well-known technique in BPSK systems. It is more reliable than trying to resolve the ambiguity in the Costas loop itself, because the frame sync pattern provides a definitive answer rather than a probabilistic one.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The carrier PLL in `pm_demod` also needs time to settle after initial acquisition. During this settling period (typically the first frame), the phase estimate is unreliable and the downstream blocks will not produce valid output. This is physically correct behavior -- the real ground station receivers exhibited the same startup transient. Do not be alarmed by a lost first frame.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Coherent frequency plan
|
||||||
|
|
||||||
|
The Apollo USB system maintains a coherent frequency relationship between uplink and downlink. The spacecraft's receiver locks to the uplink at 2106.40625 MHz, and the transmitter generates its downlink carrier by multiplying the received frequency by 240/221:
|
||||||
|
|
||||||
|
```
|
||||||
|
2106.40625 MHz x 240/221 = 2287.5 MHz
|
||||||
|
```
|
||||||
|
|
||||||
|
This coherent turnaround allows the ground station to measure the two-way Doppler shift with extreme precision -- the ratio is exact, so any frequency difference between transmitted uplink and received downlink is entirely due to spacecraft velocity. This is how NASA tracked the spacecraft's range rate to centimeter-per-second precision using 1960s technology.
|
||||||
|
|
||||||
|
gr-apollo does not implement the coherent turnaround (it is a receiver, not a transponder), but the frequency plan explains why the numbers are what they are. The 2287.5 MHz downlink frequency is not arbitrary -- it is locked to the uplink via a ratio that was carefully chosen to avoid ambiguities in the Doppler measurement.
|
||||||
285
docs/src/content/docs/explanation/virtual-agc.mdx
Normal file
285
docs/src/content/docs/explanation/virtual-agc.mdx
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
---
|
||||||
|
title: "Virtual AGC Integration"
|
||||||
|
description: "How gr-apollo bridges decoded telemetry to the Virtual AGC emulator and enables ground-to-spacecraft commanding"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Virtual AGC project provides a cycle-accurate emulator of the Apollo Guidance Computer -- the onboard computer that navigated the spacecraft to the Moon and back. gr-apollo connects to this emulator, feeding it telemetry data exactly as the real spacecraft's telecommunications system would have, and receiving commands exactly as the real uplink would have delivered them.
|
||||||
|
|
||||||
|
This connection closes the loop: you can run the actual Apollo flight software, receive its downlink telemetry through a realistic RF signal chain, and send it commands through the DSKY interface.
|
||||||
|
|
||||||
|
## What is Virtual AGC?
|
||||||
|
|
||||||
|
The Virtual AGC (yaAGC) is Ron Burkey's emulator of the Apollo Guidance Computer, hosted at [ibiblio.org/apollo](https://www.ibiblio.org/apollo/). It executes the original flight software -- the actual code that guided Apollo 11 to the lunar surface -- instruction by instruction, using the AGC's native 15-bit word architecture and fixed-point arithmetic.
|
||||||
|
|
||||||
|
The emulator exposes its I/O channels over TCP sockets. Each I/O channel corresponds to a hardware interface on the real AGC: the DSKY (display/keyboard), the inertial measurement unit, the rendezvous radar, the digital autopilot, and -- most relevant here -- the telecommunications system.
|
||||||
|
|
||||||
|
gr-apollo's `agc_bridge` module connects to yaAGC's TCP socket and speaks its 4-byte packet protocol, acting as the telecom hardware that sits between the AGC and the radio.
|
||||||
|
|
||||||
|
## The 4-byte packet protocol
|
||||||
|
|
||||||
|
yaAGC communicates using a simple binary protocol: each I/O operation is a 4-byte TCP packet. The protocol encodes a 9-bit channel number and a 15-bit value, matching the AGC's native I/O architecture (the real AGC had 512 possible I/O channels and used 15-bit words).
|
||||||
|
|
||||||
|
Each byte carries a 2-bit signature in its upper bits that identifies its position in the packet. This makes the protocol self-synchronizing -- if the TCP stream gets misaligned, the receiver can scan for valid signature sequences to recover.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
block-beta
|
||||||
|
columns 8
|
||||||
|
|
||||||
|
block:b0:2
|
||||||
|
b0h["00"] b0d["Channel[8:4]"]
|
||||||
|
end
|
||||||
|
block:b1:2
|
||||||
|
b1h["01"] b1d["Ch[3:1] | Val[14:12]"]
|
||||||
|
end
|
||||||
|
block:b2:2
|
||||||
|
b2h["10"] b2d["Value[11:6]"]
|
||||||
|
end
|
||||||
|
block:b3:2
|
||||||
|
b3h["11"] b3d["Value[5:0]"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style b0h fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style b1h fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style b2h fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style b3h fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style b0d fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style b1d fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style b2d fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
style b3d fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
The bit-level layout of each byte:
|
||||||
|
|
||||||
|
| Byte | Bits 7-6 | Bits 5-0 | Content |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | `00` | Channel bits 8-3 | High channel bits |
|
||||||
|
| 1 | `01` | Channel bits 2-0 (in bits 5-3), Value bits 14-12 (in bits 2-0) | Channel/value boundary |
|
||||||
|
| 2 | `10` | Value bits 11-6 | Middle value bits |
|
||||||
|
| 3 | `11` | Value bits 5-0 | Low value bits |
|
||||||
|
|
||||||
|
The encoding in `protocol.py` follows the original C implementation from `yaAGC/SocketAPI.c`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
b0 = (channel >> 3) & 0x3F # signature 00 is implicit (bits 7-6 = 0)
|
||||||
|
b1 = 0x40 | ((channel & 0x07) << 3) | ((value >> 12) & 0x07)
|
||||||
|
b2 = 0x80 | ((value >> 6) & 0x3F)
|
||||||
|
b3 = 0xC0 | (value & 0x3F)
|
||||||
|
```
|
||||||
|
|
||||||
|
The parser validates the signature bits before extracting the channel and value, rejecting malformed packets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if (b0 & 0xC0) != 0x00: raise ValueError(...) # must be 00xxxxxx
|
||||||
|
if (b1 & 0xC0) != 0x40: raise ValueError(...) # must be 01xxxxxx
|
||||||
|
if (b2 & 0xC0) != 0x80: raise ValueError(...) # must be 10xxxxxx
|
||||||
|
if (b3 & 0xC0) != 0xC0: raise ValueError(...) # must be 11xxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The protocol also supports a "u-bit" for mask-update operations (a yaAGC extension for shared-memory simulation of external hardware). gr-apollo does not use this feature -- standard data packets have the u-bit set to 0.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Telecom channels
|
||||||
|
|
||||||
|
The AGC has hundreds of I/O channels, but only four are relevant to the telecommunications system:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Downlink Channels">
|
||||||
|
| Channel | Octal | Decimal | Name | Direction | Purpose |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| DNTM1 | 034 | 28 | Downlink Telemetry 1 | AGC -> Ground | High byte of AGC word (bits 14-8) |
|
||||||
|
| DNTM2 | 035 | 29 | Downlink Telemetry 2 | AGC -> Ground | Low byte of AGC word (bits 7-0) |
|
||||||
|
| OUTLINK | 057 | 47 | Digital Downlink | AGC -> Ground | Additional digital data |
|
||||||
|
|
||||||
|
The AGC writes 15-bit words to these channels. The PCM system reads channels 034 and 035 at word positions 34 and 35 of each telemetry frame, encoding the AGC's data into the serial bit stream that reaches the ground.
|
||||||
|
|
||||||
|
In gr-apollo, this path is reversed: the `pcm_demux` block extracts bytes from word positions 34 and 35 of decoded frames, and the `downlink_decoder` reassembles them into 15-bit AGC words.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Uplink Channel">
|
||||||
|
| Channel | Octal | Decimal | Name | Direction | Purpose |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| INLINK | 045 | 37 | Uplink Data | Ground -> AGC | DSKY keycodes and commands |
|
||||||
|
|
||||||
|
Each write to channel 045 triggers the AGC's UPRUPT interrupt, causing the flight software to read and process the 15-bit word. The INLINK is the only way for the ground to interact with the AGC during flight.
|
||||||
|
|
||||||
|
In gr-apollo, the `uplink_encoder` formats DSKY commands (VERB, NOUN, digits, ENTER) into 15-bit words, and the `agc_bridge` delivers them to yaAGC via the TCP socket.
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
`AGCBridgeClient` filters incoming packets to these four channels by default (the `AGC_TELECOM_CHANNELS` frozenset). Other AGC channels -- the DSKY display driver, IMU CDUs, jet select logic -- generate traffic on the socket but are not relevant to the telecom system.
|
||||||
|
|
||||||
|
## 15-bit word reassembly
|
||||||
|
|
||||||
|
The AGC uses 15-bit words internally. The PCM telemetry system carries 8-bit bytes. Each AGC word is split across two PCM channels:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
block-beta
|
||||||
|
columns 15
|
||||||
|
|
||||||
|
block:high:7
|
||||||
|
h1["bit 14"] h2["13"] h3["12"] h4["11"] h5["10"] h6["9"] h7["bit 8"]
|
||||||
|
end
|
||||||
|
block:low:8
|
||||||
|
l1["bit 7"] l2["6"] l3["5"] l4["4"] l5["3"] l6["2"] l7["1"] l8["bit 0"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style high fill:#5c3a1a,stroke:#bd7a3a
|
||||||
|
style low fill:#1a3a5c,stroke:#3a7abd
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DNTM1 (channel 034)** carries bits 14-8 in the lower 7 bits of its byte. The MSB of the byte is unused.
|
||||||
|
- **DNTM2 (channel 035)** carries bits 7-0 directly.
|
||||||
|
|
||||||
|
The `reassemble_agc_word()` function in `downlink_decoder.py` performs the join:
|
||||||
|
|
||||||
|
```python
|
||||||
|
agc_word = ((dntm1_byte & 0x7F) << 8) | dntm2_byte
|
||||||
|
```
|
||||||
|
|
||||||
|
At the high PCM rate (50 frames/sec), one AGC word pair arrives every 20 ms. The `DownlinkEngine` accumulates 400 of these words (taking approximately 8 seconds) into a complete downlink buffer before attempting to decode the contents.
|
||||||
|
|
||||||
|
## Downlink list types
|
||||||
|
|
||||||
|
The AGC organizes its downlink telemetry into "lists" -- structured snapshots of internal state tailored to the current mission phase. Each list type contains a different set of navigation variables, autopilot parameters, and system status words.
|
||||||
|
|
||||||
|
The list type is identified by the lower 4 bits of the first word in each 400-word buffer:
|
||||||
|
|
||||||
|
| ID | List Name | When Used |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | CM Powered Flight | Launch, translunar injection, course corrections |
|
||||||
|
| 1 | LM Orbital Maneuvers | LM engine burns in lunar orbit |
|
||||||
|
| 2 | CM Coast/Alignment | Translunar and transearth coast, IMU alignment |
|
||||||
|
| 3 | LM Coast/Alignment | LM IMU alignment and coast phases |
|
||||||
|
| 7 | LM Descent/Ascent | Powered descent to lunar surface, ascent to orbit |
|
||||||
|
| 8 | LM Lunar Surface Alignment | Surface operations, pre-liftoff alignment |
|
||||||
|
| 9 | CM Entry Update | Atmospheric reentry guidance |
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
The list IDs are not contiguous (4, 5, and 6 are unused). This is because the AGC flight software evolved across missions, and these IDs correspond to program sections in the original source code. The `identify_list_type()` function returns "Unknown" for unrecognized IDs rather than failing.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Each list type has a defined structure specifying what each word position within the 400-word buffer represents -- things like the spacecraft's position vector, velocity, gimbal angles, fuel remaining, guidance targets, and alarm status. Fully decoding these structures requires mission-specific knowledge (the word assignments changed between Apollo missions as the flight software was updated).
|
||||||
|
|
||||||
|
## Uplink commands
|
||||||
|
|
||||||
|
Ground-to-spacecraft commanding works through the DSKY (Display and Keyboard) interface. The ground station sends DSKY keycodes via the Up-Data Link, which delivers them to AGC channel 045 (INLINK). Each keystroke triggers the UPRUPT interrupt, and the flight software processes it exactly as if an astronaut had pressed the key.
|
||||||
|
|
||||||
|
The 15-bit INLINK word encodes the keycode in bits 14-10:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bits: 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
|
||||||
|
[--- 5-bit keycode ---] [---------- data / zeros ----------]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key DSKY keycodes (5-bit values in octal):
|
||||||
|
|
||||||
|
| Key | Octal | Decimal | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| VERB | 21 | 17 | Begin verb entry |
|
||||||
|
| NOUN | 37 | 31 | Begin noun entry |
|
||||||
|
| ENTER | 34 | 28 | Execute / Proceed |
|
||||||
|
| RESET | 22 | 18 | Key release / Error reset |
|
||||||
|
| CLEAR | 36 | 30 | Clear current entry |
|
||||||
|
| 0-9 | 20, 01-07, 10-11 | various | Digit keys |
|
||||||
|
| + | 32 | 26 | Positive sign |
|
||||||
|
| - | 33 | 27 | Negative sign |
|
||||||
|
|
||||||
|
A typical ground command sequence -- say, requesting program P63 (Braking Phase) via Verb 37 -- looks like this:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant GS as Ground Station
|
||||||
|
participant UE as uplink_encoder
|
||||||
|
participant AB as agc_bridge
|
||||||
|
participant AGC as yaAGC
|
||||||
|
|
||||||
|
GS->>UE: VERB 37
|
||||||
|
UE->>AB: (ch 045, VERB keycode)
|
||||||
|
AB->>AGC: 4-byte packet
|
||||||
|
Note over AGC: UPRUPT fires
|
||||||
|
UE->>AB: (ch 045, digit 3)
|
||||||
|
AB->>AGC: 4-byte packet
|
||||||
|
Note over AGC: UPRUPT fires
|
||||||
|
UE->>AB: (ch 045, digit 7)
|
||||||
|
AB->>AGC: 4-byte packet
|
||||||
|
Note over AGC: UPRUPT fires
|
||||||
|
|
||||||
|
GS->>UE: ENTER
|
||||||
|
UE->>AB: (ch 045, ENTER keycode)
|
||||||
|
AB->>AGC: 4-byte packet
|
||||||
|
Note over AGC: UPRUPT fires,<br/>V37 N63 executes
|
||||||
|
|
||||||
|
AGC-->>AB: DNTM1/DNTM2 words<br/>(telemetry response)
|
||||||
|
AB-->>GS: Downlink PDUs
|
||||||
|
```
|
||||||
|
|
||||||
|
Each (channel, value) pair is sent as a separate 4-byte TCP packet. The AGC processes one word per UPRUPT, so multi-word sequences (like the 4-packet V37 ENTER sequence above) are sent in order. The `AGCBridgeClient` handles the TCP mechanics; the `UplinkEncoder` handles the DSKY encoding logic.
|
||||||
|
|
||||||
|
## The full data path
|
||||||
|
|
||||||
|
Putting it all together, the complete round-trip path from spacecraft to ground and back:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Spacecraft ["Spacecraft (yaAGC Emulator)"]
|
||||||
|
AGC["Apollo Guidance<br/>Computer"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RF ["RF Signal Chain"]
|
||||||
|
SIG["USB Signal<br/>(2287.5 MHz)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Receiver ["gr-apollo Receiver"]
|
||||||
|
PM["pm_demod"]
|
||||||
|
SC["subcarrier_extract"]
|
||||||
|
BPSK["bpsk_demod"]
|
||||||
|
FS["pcm_frame_sync"]
|
||||||
|
DM["pcm_demux"]
|
||||||
|
DD["downlink_decoder"]
|
||||||
|
BR["agc_bridge"]
|
||||||
|
UE["uplink_encoder"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Ground ["Ground Station"]
|
||||||
|
DISP["Telemetry Display"]
|
||||||
|
CMD["Command Entry"]
|
||||||
|
end
|
||||||
|
|
||||||
|
AGC -->|"ch 034/035<br/>DNTM1/DNTM2"| SIG
|
||||||
|
SIG --> PM --> SC --> BPSK --> FS --> DM
|
||||||
|
DM -->|"agc_data PDU"| DD
|
||||||
|
DD -->|"400-word snapshot"| BR
|
||||||
|
BR <-->|"TCP port 19697<br/>4-byte packets"| AGC
|
||||||
|
BR --> DISP
|
||||||
|
CMD --> UE -->|"DSKY keycodes"| BR
|
||||||
|
BR -->|"ch 045 INLINK"| AGC
|
||||||
|
|
||||||
|
style Spacecraft fill:#1a1a2e,stroke:#3a7abd
|
||||||
|
style RF fill:#2e1a2e,stroke:#bd3a7a
|
||||||
|
style Receiver fill:#1a2e1a,stroke:#3abd3a
|
||||||
|
style Ground fill:#2e2e1a,stroke:#bdbd3a
|
||||||
|
```
|
||||||
|
|
||||||
|
The downlink path (top to bottom) is fully implemented: complex baseband samples go in, decoded telemetry PDUs come out. The uplink path (bottom to top) sends DSKY commands through the AGC bridge to the emulator.
|
||||||
|
|
||||||
|
## Connection management
|
||||||
|
|
||||||
|
The `AGCBridgeClient` handles the TCP connection to yaAGC as a background concern. It runs a receive loop in a daemon thread, automatically reconnects with exponential backoff if the connection drops, and delivers packets via callbacks.
|
||||||
|
|
||||||
|
Connection states:
|
||||||
|
|
||||||
|
| State | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `disconnected` | No TCP connection. Attempting to reconnect. |
|
||||||
|
| `connecting` | TCP handshake in progress. |
|
||||||
|
| `connected` | Active connection. Reading packets, ready to send. |
|
||||||
|
|
||||||
|
The backoff starts at 0.5 seconds and doubles up to a maximum of 30 seconds. This means if yaAGC is not running when gr-apollo starts, the bridge will keep trying without flooding the network with connection attempts.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The default TCP port is 19697 (`AGC_PORT_BASE`). If you are running multiple yaAGC instances (e.g., both the Command Module and Lunar Module computers), they listen on sequential ports. Make sure the `agc_bridge` block is configured with the correct port for the AGC instance you want to connect to.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
The GNU Radio wrapper (`agc_bridge` block) exposes three message ports: `uplink_data` (input), `downlink_data` (output), and `status` (output). The status port emits the connection state string whenever it changes, which can be connected to a QT GUI label or logged for monitoring.
|
||||||
185
docs/src/content/docs/getting-started/installation.mdx
Normal file
185
docs/src/content/docs/getting-started/installation.mdx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
---
|
||||||
|
title: "Installation"
|
||||||
|
description: "Install gr-apollo and verify the pure-Python engines and GNU Radio blocks are working."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Tabs, TabItem, Aside, Code, LinkCard } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
gr-apollo requires Python 3.10+ and uses `uv` for dependency management. GNU Radio is only needed if you want to use the GR block wrappers.
|
||||||
|
|
||||||
|
| Dependency | Required for | Install |
|
||||||
|
|------------|-------------|---------|
|
||||||
|
| Python 3.10+ | Everything | System package manager |
|
||||||
|
| uv | Dependency management | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||||
|
| NumPy | Signal processing | Installed automatically |
|
||||||
|
| GNU Radio 3.10+ | GR blocks only | System package manager |
|
||||||
|
|
||||||
|
## Install gr-apollo
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.supported.systems/rf/gr-apollo.git
|
||||||
|
cd gr-apollo
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a virtual environment and install**
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Without GNU Radio">
|
||||||
|
This installs the pure-Python engines (signal generator, frame sync, demux, AGC bridge). No GNU Radio dependency.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="With GNU Radio">
|
||||||
|
GNU Radio installs Python bindings as system packages. To access them from a virtualenv, use `--system-site-packages`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv --system-site-packages
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets the virtualenv see `gnuradio`, `pmt`, and other system-installed GR modules while still isolating gr-apollo's own dependencies.
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
3. **Install GRC block definitions** (optional, for GNU Radio Companion)
|
||||||
|
|
||||||
|
Copy the YAML block descriptions so GRC can find them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.local/share/gnuradio/grc/blocks/
|
||||||
|
cp grc/*.yml ~/.local/share/gnuradio/grc/blocks/
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs 12 block definitions: PM demod, subcarrier extract, BPSK demod, frame sync, demux, downlink decoder, voice demod, SCO demod, AGC bridge, uplink encoder, and the all-in-one `usb_downlink_receiver`.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Verify the installation
|
||||||
|
|
||||||
|
### Pure-Python engines
|
||||||
|
|
||||||
|
These should work regardless of whether GNU Radio is installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import apollo; print(f'gr-apollo v{apollo.__version__}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```
|
||||||
|
gr-apollo v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Test the signal generator and frame sync engine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
from apollo import generate_usb_baseband, FrameSyncEngine
|
||||||
|
signal, bits = generate_usb_baseband(frames=1)
|
||||||
|
print(f'Generated {len(signal)} samples, {len(bits[0])} bits/frame')
|
||||||
|
engine = FrameSyncEngine()
|
||||||
|
frames = engine.process_bits(bits[0])
|
||||||
|
print(f'Frame sync: {len(frames)} frame(s), state={engine.state_name}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Generated 102400 samples, 1024 bits/frame
|
||||||
|
Frame sync: 1 frame(s), state=VERIFY
|
||||||
|
```
|
||||||
|
|
||||||
|
### GNU Radio blocks
|
||||||
|
|
||||||
|
If you installed with `--system-site-packages`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
from apollo import pm_demod, bpsk_demod, pcm_frame_sync
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
print('All GR blocks imported successfully')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
If you see `ImportError: No module named 'gnuradio'`, make sure you created the virtualenv with `--system-site-packages` and that GNU Radio's Python bindings are installed system-wide. On Arch Linux: `sudo pacman -S gnuradio`. On Ubuntu/Debian: `sudo apt install gnuradio python3-gnuradio`.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### GRC blocks
|
||||||
|
|
||||||
|
Open GNU Radio Companion and search for "apollo" in the block list. You should see blocks prefixed with `Apollo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnuradio-companion
|
||||||
|
```
|
||||||
|
|
||||||
|
If the blocks do not appear, verify the YAML files are in the right location:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls ~/.local/share/gnuradio/grc/blocks/apollo_*.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see 12 files. If the directory was wrong for your GR installation, check where GRC looks for blocks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnuradio-config-info --prefix
|
||||||
|
# Blocks may also live under:
|
||||||
|
# /usr/share/gnuradio/grc/blocks/
|
||||||
|
# /usr/local/share/gnuradio/grc/blocks/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `ModuleNotFoundError: No module named 'apollo'`
|
||||||
|
|
||||||
|
You are running Python outside the virtualenv. Either activate it (`source .venv/bin/activate`) or use `uv run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -c "import apollo; print(apollo.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ImportError: No module named 'gnuradio'`
|
||||||
|
|
||||||
|
The virtualenv cannot see GNU Radio's system packages. Recreate with system site access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf .venv
|
||||||
|
uv venv --system-site-packages
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### GRC blocks not visible
|
||||||
|
|
||||||
|
GRC only scans certain directories for block YAML files. If `~/.local/share/gnuradio/grc/blocks/` is not picked up, try the system-wide path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp grc/*.yml /usr/share/gnuradio/grc/blocks/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart GNU Radio Companion.
|
||||||
|
|
||||||
|
### NumPy version conflicts
|
||||||
|
|
||||||
|
If GNU Radio was built against a specific NumPy version, you may see warnings or errors. Pin to the system NumPy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install --force-reinstall numpy==$(python -c "import numpy; print(numpy.__version__)")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
|
||||||
|
<LinkCard
|
||||||
|
title="Quick Start"
|
||||||
|
description="Generate a synthetic signal and decode your first telemetry frame."
|
||||||
|
href="/getting-started/quick-start/"
|
||||||
|
/>
|
||||||
94
docs/src/content/docs/getting-started/introduction.mdx
Normal file
94
docs/src/content/docs/getting-started/introduction.mdx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: "What is gr-apollo?"
|
||||||
|
description: "An overview of the gr-apollo project — a GNU Radio decoder for Apollo Unified S-Band spacecraft telemetry signals."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Card, CardGrid, Aside, LinkCard } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Apollo Unified S-Band (USB) system was the backbone of all communications between ground stations and Apollo spacecraft from 1967 through 1975. A single 2287.5 MHz carrier handled everything: PCM telemetry, astronaut voice, ranging, and command uplinks. gr-apollo decodes these signals using modern software-defined radio.
|
||||||
|
|
||||||
|
## What gr-apollo does
|
||||||
|
|
||||||
|
gr-apollo provides a set of GNU Radio blocks and pure-Python engines that implement the full Apollo USB downlink demodulation chain. Feed it complex baseband samples and it produces decoded telemetry frames, voice audio, and AGC computer data.
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
<Card title="PCM Telemetry" icon="document">
|
||||||
|
128-word frames at 51.2 kbps with 32-bit sync correlation, A/D voltage scaling, and automatic complement-on-odd frame detection.
|
||||||
|
</Card>
|
||||||
|
<Card title="Voice Audio" icon="comment">
|
||||||
|
1.25 MHz FM subcarrier demodulated to 300--3000 Hz audio at 8 kHz output rate. Listen to astronaut comms.
|
||||||
|
</Card>
|
||||||
|
<Card title="Virtual AGC Bridge" icon="rocket">
|
||||||
|
TCP bridge to the Virtual AGC emulator. Send uplink commands, receive decoded AGC downlink lists (navigation state, autopilot data, DSKY display).
|
||||||
|
</Card>
|
||||||
|
<Card title="Test Signal Generator" icon="puzzle">
|
||||||
|
Synthetic USB baseband generator with configurable SNR, known payloads, and voice. No RF hardware needed to develop and test.
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
## Signal chain overview
|
||||||
|
|
||||||
|
The downlink receiver processes a phase-modulated carrier through five stages. Each stage has a standalone GNU Radio block, or you can use `usb_downlink_receiver` which chains them together.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Complex<br/>Baseband<br/>5.12 MHz"] --> B["PM Demod<br/><em>Carrier PLL +<br/>phase extraction</em>"]
|
||||||
|
B --> C["Subcarrier<br/>Extract<br/><em>BPF + translate<br/>1.024 MHz → DC</em>"]
|
||||||
|
C --> D["BPSK Demod<br/><em>Costas loop +<br/>symbol sync</em>"]
|
||||||
|
D --> E["Frame Sync<br/><em>32-bit correlator<br/>SEARCH → LOCKED</em>"]
|
||||||
|
E --> F["PCM Demux<br/><em>128-word frames<br/>+ AGC channels</em>"]
|
||||||
|
|
||||||
|
B --> V["Voice<br/>Extract<br/><em>1.25 MHz FM</em>"]
|
||||||
|
V --> VA["Audio<br/>8 kHz"]
|
||||||
|
F --> T["Telemetry<br/>PDUs"]
|
||||||
|
F --> AG["AGC Data<br/>Ch 34/35/57"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key parameters
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Downlink frequency | 2287.5 MHz |
|
||||||
|
| PM peak deviation | 0.133 rad (7.6 deg) |
|
||||||
|
| PCM subcarrier | 1.024 MHz, BPSK |
|
||||||
|
| PCM bit rate | 51.2 kbps (high) / 1.6 kbps (low) |
|
||||||
|
| Voice subcarrier | 1.25 MHz, FM +/-29 kHz |
|
||||||
|
| Frame size | 128 words x 8 bits = 1024 bits |
|
||||||
|
| Frame rate | 50 fps (high rate) |
|
||||||
|
| Master clock | 512 kHz |
|
||||||
|
| Baseband sample rate | 5.12 MHz |
|
||||||
|
|
||||||
|
## Two ways to use it
|
||||||
|
|
||||||
|
gr-apollo splits cleanly into two layers:
|
||||||
|
|
||||||
|
**Pure-Python engines** (no GNU Radio required) -- the signal generator, frame sync engine, demux engine, downlink decoder, and AGC bridge client all work standalone. Use these for scripting, testing, or integration with other tools.
|
||||||
|
|
||||||
|
**GNU Radio blocks** (require `gnuradio` runtime) -- each engine is wrapped in a GR block with proper streaming I/O and message ports. Use these in GRC flowgraphs or programmatic `top_block` scripts.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Pure Python — always available
|
||||||
|
from apollo import generate_usb_baseband, FrameSyncEngine, DemuxEngine
|
||||||
|
|
||||||
|
# GNU Radio blocks — require gnuradio installed
|
||||||
|
from apollo import pm_demod, bpsk_demod, pcm_frame_sync, usb_downlink_receiver
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you will need
|
||||||
|
|
||||||
|
- **Python 3.10+**
|
||||||
|
- **uv** (for dependency management and running)
|
||||||
|
- **NumPy** (installed automatically)
|
||||||
|
- **GNU Radio 3.10+** (only for the GR blocks -- the pure-Python engines work without it)
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
You can use the signal generator and frame processing engines without GNU Radio installed. This is useful for offline analysis, unit testing, or environments where GR is hard to install.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
|
||||||
|
<LinkCard
|
||||||
|
title="Installation"
|
||||||
|
description="Install gr-apollo and verify everything works."
|
||||||
|
href="/getting-started/installation/"
|
||||||
|
/>
|
||||||
226
docs/src/content/docs/getting-started/quick-start.mdx
Normal file
226
docs/src/content/docs/getting-started/quick-start.mdx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
---
|
||||||
|
title: "Quick Start"
|
||||||
|
description: "Generate a synthetic Apollo USB signal and decode telemetry frames in under 5 minutes."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Tabs, TabItem, Aside, Code, LinkCard, CardGrid, Card } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
This walkthrough generates a synthetic Apollo USB downlink signal, runs it through the decoder, and prints the recovered telemetry. No RF hardware or GNU Radio installation required -- the pure-Python engines handle everything.
|
||||||
|
|
||||||
|
## Your first decode
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. **Generate a synthetic signal**
|
||||||
|
|
||||||
|
The signal generator creates complex baseband samples that replicate a real Apollo USB downlink: PM-modulated carrier with a 1.024 MHz BPSK subcarrier carrying PCM telemetry.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
from apollo import generate_usb_baseband
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
|
||||||
|
np.random.seed(42)
|
||||||
|
known_payload = bytes(range(124)) # deterministic test data
|
||||||
|
|
||||||
|
signal, frame_bits = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
frame_data=[known_payload] * 5,
|
||||||
|
snr_db=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration_ms = len(signal) / SAMPLE_RATE_BASEBAND * 1000
|
||||||
|
print(f"Signal: {len(signal)} samples, {duration_ms:.1f} ms")
|
||||||
|
print(f"Frames: {len(frame_bits)} x {len(frame_bits[0])} bits")
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Signal: 512000 samples, 100.0 ms
|
||||||
|
Frames: 5 x 1024 bits
|
||||||
|
```
|
||||||
|
|
||||||
|
That is 5 complete PCM frames at 51.2 kbps, each with a 32-bit sync word followed by 124 bytes of payload, phase-modulated onto a carrier with 30 dB SNR additive noise.
|
||||||
|
|
||||||
|
2. **Decode with the pure-Python engines**
|
||||||
|
|
||||||
|
Feed the known bit sequences through `FrameSyncEngine` to find frame boundaries, then `DemuxEngine` to extract individual telemetry words:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo import FrameSyncEngine, DemuxEngine
|
||||||
|
|
||||||
|
sync = FrameSyncEngine()
|
||||||
|
demux = DemuxEngine(output_format="scaled")
|
||||||
|
|
||||||
|
for i, bits in enumerate(frame_bits):
|
||||||
|
frames = sync.process_bits(bits)
|
||||||
|
for frame in frames:
|
||||||
|
result = demux.process_frame(frame["frame_bytes"], frame)
|
||||||
|
print(f"Frame {frame['frame_id']:2d} "
|
||||||
|
f"sync={frame['sync_confidence']}/32 "
|
||||||
|
f"state={frame['state']:<8s} "
|
||||||
|
f"words={len(result['words'])} "
|
||||||
|
f"agc_channels={len(result['agc_data'])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Frame 1 sync=32/32 state=VERIFY words=124 agc_channels=3
|
||||||
|
Frame 2 sync=32/32 state=LOCKED words=124 agc_channels=3
|
||||||
|
Frame 3 sync=32/32 state=LOCKED words=124 agc_channels=3
|
||||||
|
Frame 4 sync=32/32 state=LOCKED words=124 agc_channels=3
|
||||||
|
Frame 5 sync=32/32 state=LOCKED words=124 agc_channels=3
|
||||||
|
```
|
||||||
|
|
||||||
|
The sync engine moves from SEARCH to VERIFY on the first frame, then locks after two consecutive hits. Each frame produces 124 data words and 3 AGC channel extractions (channels 34, 35, and 57).
|
||||||
|
|
||||||
|
3. **Inspect a decoded frame**
|
||||||
|
|
||||||
|
The demux output gives you structured access to every telemetry word:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Decode the last frame
|
||||||
|
last_frame = frames[-1]
|
||||||
|
result = demux.process_frame(last_frame["frame_bytes"], last_frame)
|
||||||
|
|
||||||
|
# Sync word fields
|
||||||
|
sync_fields = result["sync"]
|
||||||
|
print(f"Sync A: 0b{sync_fields['a_bits']:05b}")
|
||||||
|
print(f"Sync core: 0x{sync_fields['core']:04x}")
|
||||||
|
print(f"Sync B: 0b{sync_fields['b_bits']:06b}")
|
||||||
|
print(f"Frame ID: {sync_fields['frame_id']}")
|
||||||
|
|
||||||
|
# First 5 data words with voltage scaling
|
||||||
|
print("\nWord Raw Voltage")
|
||||||
|
for w in result["words"][:5]:
|
||||||
|
print(f" {w['position']:3d} 0x{w['raw_value']:02x} {w['voltage']:.3f} V")
|
||||||
|
|
||||||
|
# AGC channel data
|
||||||
|
print("\nAGC channels:")
|
||||||
|
for agc in result["agc_data"]:
|
||||||
|
print(f" Ch {agc['channel_octal']} (word {agc['word_position']}): "
|
||||||
|
f"raw=0x{agc['raw_value']:02x}")
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Full-chain decode with GNU Radio
|
||||||
|
|
||||||
|
If you have GNU Radio installed, the `usb_downlink_receiver` hierarchical block chains the entire demod pipeline into a single block. This is the flowgraph approach shown in `examples/usb_downlink_demo.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Full GR flowgraph: generate signal -> decode -> print frames."""
|
||||||
|
import numpy as np
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
from apollo.usb_signal_gen import generate_usb_baseband
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
np.random.seed(42)
|
||||||
|
payload = bytes(np.random.randint(0, 256, size=124, dtype=np.uint8))
|
||||||
|
|
||||||
|
signal, _ = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
frame_data=[payload] * 5,
|
||||||
|
snr_db=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the flowgraph
|
||||||
|
tb = gr.top_block()
|
||||||
|
src = blocks.vector_source_c(signal.tolist())
|
||||||
|
receiver = usb_downlink_receiver(output_format="scaled")
|
||||||
|
sink = blocks.message_debug()
|
||||||
|
|
||||||
|
tb.connect(src, receiver)
|
||||||
|
tb.msg_connect(receiver, "frames", sink, "store")
|
||||||
|
|
||||||
|
tb.run()
|
||||||
|
|
||||||
|
n = sink.num_messages()
|
||||||
|
print(f"Decoded {n} frame(s)")
|
||||||
|
|
||||||
|
if n > 0:
|
||||||
|
import pmt
|
||||||
|
msg = sink.get_message(0)
|
||||||
|
meta = pmt.car(msg)
|
||||||
|
fid = pmt.to_long(
|
||||||
|
pmt.dict_ref(meta, pmt.intern("frame_id"), pmt.from_long(-1))
|
||||||
|
)
|
||||||
|
conf = pmt.to_long(
|
||||||
|
pmt.dict_ref(meta, pmt.intern("sync_confidence"), pmt.from_long(-1))
|
||||||
|
)
|
||||||
|
print(f"First frame: id={fid}, sync_confidence={conf}/32")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python examples/usb_downlink_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## What just happened
|
||||||
|
|
||||||
|
Here is what each stage did to the signal:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph gen ["Signal Generator"]
|
||||||
|
G1["generate_usb_baseband()"] --> G2["NRZ waveform<br/>+1/-1 per bit"]
|
||||||
|
G2 --> G3["BPSK modulate<br/>onto 1.024 MHz"]
|
||||||
|
G3 --> G4["PM modulate<br/>onto carrier"]
|
||||||
|
G4 --> G5["Add AWGN<br/>@ 30 dB SNR"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph rx ["Receiver Chain"]
|
||||||
|
R1["PM Demod<br/><em>PLL locks carrier,<br/>extracts phase</em>"] --> R2["Subcarrier Extract<br/><em>BPF 949-1099 kHz,<br/>translate to DC</em>"]
|
||||||
|
R2 --> R3["BPSK Demod<br/><em>Costas loop resolves<br/>180-deg ambiguity</em>"]
|
||||||
|
R3 --> R4["Frame Sync<br/><em>32-bit correlator<br/>Hamming distance ≤ 3</em>"]
|
||||||
|
R4 --> R5["PCM Demux<br/><em>Split 128 words,<br/>identify AGC channels</em>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
G5 --> R1
|
||||||
|
R5 --> OUT["Telemetry PDUs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | Input | Output | What it does |
|
||||||
|
|-------|-------|--------|-------------|
|
||||||
|
| PM Demod | Complex IQ | Float | Carrier PLL tracks frequency drift, `atan2(Im, Re)` extracts instantaneous phase |
|
||||||
|
| Subcarrier Extract | Float (composite) | Complex (baseband) | Bandpass filter isolates 1.024 MHz region, frequency-translating FIR shifts to DC |
|
||||||
|
| BPSK Demod | Complex (baseband) | Bytes (0/1) | Costas loop recovers carrier phase, Mueller-Muller TED locks to symbol transitions, binary slicer decides bits |
|
||||||
|
| Frame Sync | Byte stream | Frame PDUs | Slides a 32-bit window, correlates against sync pattern (even + odd), state machine: SEARCH -> VERIFY -> LOCKED |
|
||||||
|
| PCM Demux | Frame PDUs | Word PDUs | Separates sync (words 1-4) from data (words 5-128), applies A/D voltage scaling, extracts AGC channels 34/35/57 |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The frame sync engine uses Hamming distance to tolerate up to 3 bit errors in the 26-bit static portion of the sync word. The remaining 6 bits encode the frame ID (1-50 within each 1-second subframe).
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
<LinkCard
|
||||||
|
title="Signal Architecture"
|
||||||
|
description="How the Apollo USB system works, from RF to bits."
|
||||||
|
href="/explanation/signal-architecture/"
|
||||||
|
/>
|
||||||
|
<LinkCard
|
||||||
|
title="Generate Test Signals"
|
||||||
|
description="Create custom test scenarios with controlled parameters."
|
||||||
|
href="/guides/test-signals/"
|
||||||
|
/>
|
||||||
|
<LinkCard
|
||||||
|
title="Block Reference"
|
||||||
|
description="Detailed API for every gr-apollo block."
|
||||||
|
href="/reference/blocks/"
|
||||||
|
/>
|
||||||
|
<LinkCard
|
||||||
|
title="Connect to Virtual AGC"
|
||||||
|
description="Bridge decoded telemetry to the Apollo Guidance Computer emulator."
|
||||||
|
href="/guides/agc-bridge/"
|
||||||
|
/>
|
||||||
|
</CardGrid>
|
||||||
303
docs/src/content/docs/guides/agc-bridge.mdx
Normal file
303
docs/src/content/docs/guides/agc-bridge.mdx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
---
|
||||||
|
title: "Connect to Virtual AGC"
|
||||||
|
description: "How to bridge gr-apollo to the Virtual AGC emulator for live Apollo Guidance Computer interaction."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Virtual AGC project provides a cycle-accurate emulator of the Apollo Guidance Computer (yaAGC). gr-apollo can connect to it over TCP, feeding decoded downlink telemetry into the emulator and receiving uplink commands. This guide covers both the standalone Python client and the GNU Radio block.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
1. **Install Virtual AGC.** Build from source or use a pre-built binary from the [Virtual AGC project](https://www.ibiblio.org/apollo/). The key binary is `yaAGC`.
|
||||||
|
|
||||||
|
2. **Start the AGC emulator.** Launch yaAGC with a mission rope (flight software image):
|
||||||
|
```bash
|
||||||
|
yaAGC --port=19697 Luminary099.bin
|
||||||
|
```
|
||||||
|
The emulator listens on TCP port 19697 by default.
|
||||||
|
|
||||||
|
3. **Install gr-apollo.**
|
||||||
|
```bash
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
The `AGCBridgeClient` and `UplinkEncoder` are pure Python -- they work without GNU Radio installed.
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## The AGC Socket Protocol
|
||||||
|
|
||||||
|
yaAGC communicates using 4-byte packets over TCP. Each packet encodes an I/O channel number (9 bits) and a data value (15 bits):
|
||||||
|
|
||||||
|
```
|
||||||
|
Byte 0: [Channel bits 8-4][0x00 signature]
|
||||||
|
Byte 1: [0x40 | Channel bits 3-1][Value bits 14-12]
|
||||||
|
Byte 2: [0x80 | Value bits 11-6]
|
||||||
|
Byte 3: [0xC0 | Value bits 5-0]
|
||||||
|
```
|
||||||
|
|
||||||
|
The telecom-relevant channels are:
|
||||||
|
|
||||||
|
| Channel | Octal | Direction | Purpose |
|
||||||
|
|---------|-------|-----------|---------|
|
||||||
|
| 37 | 045 | Uplink (in) | INLINK -- ground commands to AGC |
|
||||||
|
| 47 | 057 | Downlink (out) | OUTLINK -- digital downlink data |
|
||||||
|
| 28 | 034 | Downlink (out) | DNTM1 -- telemetry word high byte |
|
||||||
|
| 29 | 035 | Downlink (out) | DNTM2 -- telemetry word low byte |
|
||||||
|
|
||||||
|
## Standalone Python Client
|
||||||
|
|
||||||
|
The `AGCBridgeClient` connects to yaAGC in a background thread, auto-reconnects on disconnection, and delivers received packets through callbacks.
|
||||||
|
|
||||||
|
### Basic Connection
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.agc_bridge import AGCBridgeClient
|
||||||
|
|
||||||
|
def on_packet(channel, value):
|
||||||
|
print(f"Received: ch={channel} ({channel:03o}o) value={value:05o}o ({value})")
|
||||||
|
|
||||||
|
def on_status(state):
|
||||||
|
print(f"Connection: {state}")
|
||||||
|
|
||||||
|
client = AGCBridgeClient(
|
||||||
|
host="localhost",
|
||||||
|
port=19697,
|
||||||
|
on_packet=on_packet,
|
||||||
|
on_status=on_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
The client runs a daemon thread that:
|
||||||
|
- Connects to yaAGC at the specified host and port
|
||||||
|
- Reads 4-byte packets continuously
|
||||||
|
- Filters for telecom channels (034, 035, 045, 057) by default
|
||||||
|
- Calls `on_packet(channel, value)` for each matching packet
|
||||||
|
- Auto-reconnects with exponential backoff (0.5s to 30s) on disconnection
|
||||||
|
|
||||||
|
### Checking Connection State
|
||||||
|
|
||||||
|
```python
|
||||||
|
print(client.state) # "connected", "connecting", or "disconnected"
|
||||||
|
print(client.connected) # True / False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Data to the AGC
|
||||||
|
|
||||||
|
Send raw (channel, value) pairs directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Send a value on channel 045 (INLINK)
|
||||||
|
client.send(channel=0o45, value=0o12345)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `send()` method returns `True` if the packet was sent, `False` if not connected.
|
||||||
|
|
||||||
|
### Disabling Channel Filtering
|
||||||
|
|
||||||
|
By default, only telecom channels (034, 035, 045, 057) pass through. To receive all AGC I/O:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = AGCBridgeClient(
|
||||||
|
host="localhost",
|
||||||
|
port=19697,
|
||||||
|
channel_filter=None, # Accept all channels
|
||||||
|
on_packet=on_packet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping the Client
|
||||||
|
|
||||||
|
```python
|
||||||
|
client.stop() # Signals the rx thread and waits up to 5 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sending DSKY Commands via UplinkEncoder
|
||||||
|
|
||||||
|
The `UplinkEncoder` translates DSKY keystrokes and VERB-NOUN commands into the (channel, value) pairs that the AGC expects on channel 045.
|
||||||
|
|
||||||
|
### Encoding a VERB-NOUN Sequence
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.agc_bridge import AGCBridgeClient
|
||||||
|
from apollo.uplink_encoder import UplinkEncoder
|
||||||
|
|
||||||
|
client = AGCBridgeClient(host="localhost", port=19697)
|
||||||
|
client.start()
|
||||||
|
|
||||||
|
enc = UplinkEncoder()
|
||||||
|
|
||||||
|
# V37N00 -- select idle program (P00)
|
||||||
|
commands = enc.encode_verb_noun(37, 0)
|
||||||
|
# Returns: [(37, verb_key), (37, digit_3), (37, digit_7),
|
||||||
|
# (37, noun_key), (37, digit_0), (37, digit_0),
|
||||||
|
# (37, enter_key)]
|
||||||
|
|
||||||
|
for channel, value in commands:
|
||||||
|
client.send(channel, value)
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The AGC processes one uplink word per UPRUPT interrupt. In a real system, the ground station paced the words. When sending multiple commands in a loop, you may need a short delay between sends (10--50 ms) to avoid overwhelming the emulator.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### Available Encoding Methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
enc = UplinkEncoder()
|
||||||
|
|
||||||
|
# Individual DSKY keys
|
||||||
|
enc.encode_verb(37) # [VERB, 3, 7]
|
||||||
|
enc.encode_noun(1) # [NOUN, 0, 1]
|
||||||
|
enc.encode_data(12345) # [+, 1, 2, 3, 4, 5]
|
||||||
|
enc.encode_data(-500) # [-, 0, 0, 5, 0, 0]
|
||||||
|
enc.encode_proceed() # [ENTER]
|
||||||
|
|
||||||
|
# Full VERB-NOUN-ENTER sequence
|
||||||
|
enc.encode_verb_noun(16, 65) # V16N65 ENTER
|
||||||
|
|
||||||
|
# Generic dispatcher
|
||||||
|
enc.encode_command("VERB", 37)
|
||||||
|
enc.encode_command("NOUN", 0)
|
||||||
|
enc.encode_command("DATA", 12345)
|
||||||
|
enc.encode_command("PROCEED")
|
||||||
|
```
|
||||||
|
|
||||||
|
Each method returns a list of `(channel, value)` tuples. The channel defaults to 037 (octal 045, INLINK).
|
||||||
|
|
||||||
|
### DSKY Key Codes
|
||||||
|
|
||||||
|
The encoder uses the same 5-bit key codes as the real AGC:
|
||||||
|
|
||||||
|
| Key | Octal Code | Decimal |
|
||||||
|
|-----|-----------|---------|
|
||||||
|
| VERB | 021 | 17 |
|
||||||
|
| NOUN | 037 | 31 |
|
||||||
|
| ENTER / PROCEED | 034 | 28 |
|
||||||
|
| RESET / KEY RELEASE | 022 | 18 |
|
||||||
|
| CLEAR | 036 | 30 |
|
||||||
|
| + | 032 | 26 |
|
||||||
|
| - | 033 | 27 |
|
||||||
|
| 0 | 020 | 16 |
|
||||||
|
| 1-7 | 001-007 | 1-7 |
|
||||||
|
| 8 | 010 | 8 |
|
||||||
|
| 9 | 011 | 9 |
|
||||||
|
|
||||||
|
## GNU Radio Block: `agc_bridge`
|
||||||
|
|
||||||
|
For use in GRC flowgraphs, the `agc_bridge` block wraps the TCP client and exposes message ports:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph agc_bridge
|
||||||
|
direction TB
|
||||||
|
UP["uplink_data (input)"]
|
||||||
|
DN["downlink_data (output)"]
|
||||||
|
ST["status (output)"]
|
||||||
|
end
|
||||||
|
GR_UPLINK["Uplink PDUs"] --> UP
|
||||||
|
DN --> GR_DOWNLINK["Downlink PDUs"]
|
||||||
|
ST --> GR_STATUS["Status Messages"]
|
||||||
|
UP -.->|TCP| AGC["yaAGC :19697"]
|
||||||
|
AGC -.->|TCP| DN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using in a Flowgraph
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.agc_bridge import agc_bridge
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
bridge = agc_bridge(host="localhost", port=19697)
|
||||||
|
debug = blocks.message_debug()
|
||||||
|
|
||||||
|
# Monitor downlink data and status
|
||||||
|
tb.msg_connect(bridge, "downlink_data", debug, "print")
|
||||||
|
tb.msg_connect(bridge, "status", debug, "print")
|
||||||
|
|
||||||
|
tb.start()
|
||||||
|
# ... bridge runs until stopped
|
||||||
|
tb.stop()
|
||||||
|
tb.wait()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connecting the Decoder Chain to AGC
|
||||||
|
|
||||||
|
A complete pipeline: USB baseband signal decoded to PCM frames, AGC telemetry extracted and forwarded to yaAGC:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.agc_bridge import agc_bridge
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.downlink_decoder import downlink_decoder
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
src = blocks.file_source(gr.sizeof_gr_complex, "recording.cf32", repeat=False)
|
||||||
|
receiver = usb_downlink_receiver(output_format="raw")
|
||||||
|
decoder = downlink_decoder()
|
||||||
|
bridge = agc_bridge(host="localhost", port=19697)
|
||||||
|
|
||||||
|
tb.connect(src, receiver)
|
||||||
|
|
||||||
|
# Route AGC channel data through the downlink decoder
|
||||||
|
tb.msg_connect(receiver, "agc_data", decoder, "agc_data")
|
||||||
|
|
||||||
|
# Forward decoded downlink lists (for monitoring)
|
||||||
|
debug = blocks.message_debug()
|
||||||
|
tb.msg_connect(decoder, "downlink", debug, "print")
|
||||||
|
|
||||||
|
tb.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Reconnect Behavior
|
||||||
|
|
||||||
|
The `AGCBridgeClient` (used by both the standalone client and the GR block) handles connection failures automatically:
|
||||||
|
|
||||||
|
| Event | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| Initial connection fails | Retry with exponential backoff (0.5s, 1s, 2s, ... up to 30s) |
|
||||||
|
| Connection lost mid-session | Close socket, reset to DISCONNECTED, begin retry loop |
|
||||||
|
| yaAGC restarted | Client reconnects within the backoff window |
|
||||||
|
| `stop()` called | Stop event signals the rx thread, socket is closed, thread joins within 5s |
|
||||||
|
| Callback raises exception | Exception is logged, processing continues |
|
||||||
|
|
||||||
|
The backoff resets to 0.5 seconds after each successful connection.
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
You can start the `AGCBridgeClient` before yaAGC is running. It will retry in the background until the emulator comes online. The `on_status` callback lets you track connection state changes in your application.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Receiving Downlink Telemetry
|
||||||
|
|
||||||
|
The AGC sends telemetry on channels 034 and 035 as pairs of bytes that together form 15-bit AGC words. The `DownlinkEngine` reassembles these:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.agc_bridge import AGCBridgeClient
|
||||||
|
from apollo.downlink_decoder import DownlinkEngine
|
||||||
|
|
||||||
|
engine = DownlinkEngine()
|
||||||
|
|
||||||
|
def on_packet(channel, value):
|
||||||
|
snapshot = engine.feed_agc_word(channel, value)
|
||||||
|
if snapshot is not None:
|
||||||
|
print(f"Downlink list: {snapshot['list_name']}")
|
||||||
|
print(f" Words: {snapshot['word_count']}")
|
||||||
|
print(f" First word: {snapshot['words'][0]:05o}o")
|
||||||
|
|
||||||
|
client = AGCBridgeClient(
|
||||||
|
host="localhost",
|
||||||
|
port=19697,
|
||||||
|
on_packet=on_packet,
|
||||||
|
)
|
||||||
|
client.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine buffers 400 words (one complete downlink snapshot) before emitting a decoded list. See the [PCM Telemetry guide](/guides/pcm-telemetry/) for details on interpreting the word contents.
|
||||||
401
docs/src/content/docs/guides/pcm-telemetry.mdx
Normal file
401
docs/src/content/docs/guides/pcm-telemetry.mdx
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
---
|
||||||
|
title: "Work with PCM Telemetry"
|
||||||
|
description: "How to extract, demultiplex, and interpret Apollo PCM telemetry frames from a decoded bit stream."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Apollo PCM telemetry system encodes spacecraft sensor data, AGC state, and digital downlink information into structured frames transmitted at 50 frames per second. This guide shows how to go from a raw bit stream to named telemetry fields using the gr-apollo engines.
|
||||||
|
|
||||||
|
## PCM Frame Structure
|
||||||
|
|
||||||
|
Each high-rate frame contains 128 words of 8 bits each (1024 bits total):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Frame ["128-word PCM Frame"]
|
||||||
|
direction LR
|
||||||
|
S["Sync Word<br/>Words 1-4<br/>(32 bits)"]
|
||||||
|
D["Data Words<br/>Words 5-128<br/>(124 words)"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The 32-bit sync word encodes four fields:
|
||||||
|
|
||||||
|
| Field | Bits | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| A | 5 | Patchboard-selectable (default: `10101`) |
|
||||||
|
| Core | 15 | Fixed pattern, complemented on odd frames |
|
||||||
|
| B | 6 | Patchboard-selectable (default: `110100`) |
|
||||||
|
| Frame ID | 6 | Frame number within subframe (1--50) |
|
||||||
|
|
||||||
|
Fifty frames make one subframe (1 second at high rate). The frame ID counts from 1 to 50 within each subframe.
|
||||||
|
|
||||||
|
## Step 1: Frame Synchronization with FrameSyncEngine
|
||||||
|
|
||||||
|
The `FrameSyncEngine` is a pure-Python class that accepts individual bits (0 or 1) and outputs complete frames. No GNU Radio installation required.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_frame_sync import FrameSyncEngine
|
||||||
|
|
||||||
|
engine = FrameSyncEngine(
|
||||||
|
bit_rate=51200, # High rate (or 1600 for low rate)
|
||||||
|
max_bit_errors=3, # Hamming distance threshold
|
||||||
|
verify_count=2, # Consecutive hits to confirm lock
|
||||||
|
miss_limit=3, # Consecutive misses before re-searching
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing a Bit Stream
|
||||||
|
|
||||||
|
Feed bits from any source -- a BPSK demodulator, a file, or the test signal generator:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_pcm_frame
|
||||||
|
|
||||||
|
# Generate 5 frames of known data
|
||||||
|
all_bits = []
|
||||||
|
for i in range(5):
|
||||||
|
frame_id = i + 1
|
||||||
|
odd = (frame_id % 2) == 1
|
||||||
|
bits = generate_pcm_frame(frame_id=frame_id, odd=odd)
|
||||||
|
all_bits.extend(bits)
|
||||||
|
|
||||||
|
# Process through the sync engine
|
||||||
|
frames = engine.process_bits(all_bits)
|
||||||
|
|
||||||
|
print(f"Sync state: {engine.state_name}")
|
||||||
|
print(f"Frames found: {len(frames)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frame Output Format
|
||||||
|
|
||||||
|
Each frame returned by `process_bits()` is a dictionary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"frame_id": 1, # 1-50 within subframe
|
||||||
|
"odd_frame": True, # Odd frames have complemented sync core
|
||||||
|
"sync_confidence": 29, # Bits correct out of 32
|
||||||
|
"timestamp": 1708444800.123, # time.time() when frame was emitted
|
||||||
|
"state": "LOCKED", # Engine state when emitted
|
||||||
|
"frame_bytes": b'\xab\xce...', # Complete frame as bytes (128 bytes)
|
||||||
|
"frame_bits": [1, 0, 1, ...], # Complete frame as bit list (1024 bits)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The `sync_confidence` field counts how many of the 32 sync bits matched the reference. A value of 32 means a perfect match. The engine emits frames even in VERIFY state (before full lock), so check the `state` field if you need confirmed data only.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### Resetting the Engine
|
||||||
|
|
||||||
|
To reprocess from a clean state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
engine.reset()
|
||||||
|
print(engine.state_name) # "SEARCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Frame Demultiplexing with DemuxEngine
|
||||||
|
|
||||||
|
The `DemuxEngine` takes complete frame bytes and separates them into individual telemetry words with optional voltage scaling.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_demux import DemuxEngine
|
||||||
|
|
||||||
|
demux = DemuxEngine(
|
||||||
|
output_format="scaled", # "raw", "scaled", or "engineering"
|
||||||
|
words_per_frame=128, # 128 (high rate) or 200 (low rate)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing a Frame
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Using a frame from the sync engine
|
||||||
|
frame = frames[0]
|
||||||
|
result = demux.process_frame(
|
||||||
|
frame["frame_bytes"],
|
||||||
|
meta={"frame_id": frame["frame_id"], "odd_frame": frame["odd_frame"]},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demux Output Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"sync": {
|
||||||
|
"a_bits": 21, # 5-bit A field (decimal)
|
||||||
|
"core": 29404, # 15-bit core (decimal)
|
||||||
|
"b_bits": 52, # 6-bit B field (decimal)
|
||||||
|
"frame_id": 1, # 6-bit frame ID
|
||||||
|
"word": 0xABCE_D401, # Raw 32-bit sync word
|
||||||
|
},
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"position": 5, # 1-indexed word position
|
||||||
|
"raw_value": 128, # 8-bit ADC code
|
||||||
|
"voltage": 2.49, # Scaled voltage (if format is "scaled")
|
||||||
|
"voltage_low_level": 0.020, # Low-level input voltage
|
||||||
|
},
|
||||||
|
# ... words 5 through 128
|
||||||
|
],
|
||||||
|
"agc_data": [
|
||||||
|
{
|
||||||
|
"channel": 28, # Decimal channel number
|
||||||
|
"channel_octal": "034", # Octal for reference
|
||||||
|
"raw_value": 42, # 8-bit value
|
||||||
|
"word_position": 34, # 1-indexed position in frame
|
||||||
|
"voltage": 0.80, # Scaled (if "scaled" format)
|
||||||
|
},
|
||||||
|
# Entries for channels 034, 035, 057
|
||||||
|
],
|
||||||
|
"raw_frame": b'\xab\xce...', # Original frame bytes
|
||||||
|
"meta": {"frame_id": 1, ...}, # Pass-through metadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Formats
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="raw">
|
||||||
|
Returns 8-bit integer values with no conversion:
|
||||||
|
```python
|
||||||
|
demux = DemuxEngine(output_format="raw")
|
||||||
|
result = demux.process_frame(frame_bytes)
|
||||||
|
word = result["words"][0]
|
||||||
|
print(word["raw_value"]) # 128
|
||||||
|
# No "voltage" key present
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="scaled">
|
||||||
|
Applies the ADC voltage conversion (code 1 = 0V, code 254 = 4.98V):
|
||||||
|
```python
|
||||||
|
demux = DemuxEngine(output_format="scaled")
|
||||||
|
result = demux.process_frame(frame_bytes)
|
||||||
|
word = result["words"][0]
|
||||||
|
print(word["raw_value"]) # 128
|
||||||
|
print(f"{word['voltage']:.2f}") # 2.49
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="engineering">
|
||||||
|
Same as "scaled" (engineering-unit named fields are planned for a future release):
|
||||||
|
```python
|
||||||
|
demux = DemuxEngine(output_format="engineering")
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
### Accessing a Specific Word
|
||||||
|
|
||||||
|
Extract a single word by its 1-indexed position without processing the entire frame:
|
||||||
|
|
||||||
|
```python
|
||||||
|
word = demux.extract_word(frame_bytes, word_position=34)
|
||||||
|
print(f"Word 34: code={word['raw_value']}, voltage={word['voltage']:.3f}V")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: ADC Voltage Conversion
|
||||||
|
|
||||||
|
The Apollo PCM system uses an 8-bit ADC with these characteristics:
|
||||||
|
|
||||||
|
| Code | Voltage | Meaning |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 0 | 0.0 V | Below range |
|
||||||
|
| 1 | 0.0 V | Zero reference |
|
||||||
|
| 128 | ~2.49 V | Mid-scale |
|
||||||
|
| 254 | 4.98 V | Full-scale |
|
||||||
|
| 255 | 5.0 V | Overflow (>5V) |
|
||||||
|
|
||||||
|
The step size is 19.7 mV per LSB. For low-level inputs (0--40 mV range), the hardware applies x125 gain before the ADC:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import adc_to_voltage
|
||||||
|
|
||||||
|
# Standard high-level input (0-5V)
|
||||||
|
voltage = adc_to_voltage(128)
|
||||||
|
print(f"{voltage:.3f}V") # 2.492V
|
||||||
|
|
||||||
|
# Low-level input (0-40 mV, with x125 gain removed)
|
||||||
|
voltage_ll = adc_to_voltage(128, low_level=True)
|
||||||
|
print(f"{voltage_ll:.4f}V") # 0.0199V (19.9 mV)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: AGC Downlink Decoding with DownlinkEngine
|
||||||
|
|
||||||
|
Channels 034 and 035 in the PCM frame carry 8-bit halves of 15-bit AGC words. The `DownlinkEngine` reassembles them into full words and groups them into downlink list snapshots.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.downlink_decoder import DownlinkEngine, reassemble_agc_word
|
||||||
|
|
||||||
|
# Manual word reassembly
|
||||||
|
high_byte = 0x2A # From channel 034 (DNTM1)
|
||||||
|
low_byte = 0x55 # From channel 035 (DNTM2)
|
||||||
|
agc_word = reassemble_agc_word(high_byte, low_byte)
|
||||||
|
print(f"AGC word: {agc_word:05o}o ({agc_word})") # 15-bit value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the Engine
|
||||||
|
|
||||||
|
```python
|
||||||
|
engine = DownlinkEngine(buffer_size=400)
|
||||||
|
|
||||||
|
# Feed AGC data from demux results
|
||||||
|
for frame_result in all_demux_results:
|
||||||
|
for agc in frame_result["agc_data"]:
|
||||||
|
snapshot = engine.feed_agc_word(agc["channel"], agc["raw_value"])
|
||||||
|
if snapshot is not None:
|
||||||
|
print(f"List type: {snapshot['list_name']}")
|
||||||
|
print(f"Words collected: {snapshot['word_count']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Downlink List Types
|
||||||
|
|
||||||
|
The first word of each 400-word buffer identifies the list type:
|
||||||
|
|
||||||
|
| ID | List Name | Mission Phase |
|
||||||
|
|----|-----------|--------------|
|
||||||
|
| 0 | CM Powered Flight | Boost, TLI, MCC burns |
|
||||||
|
| 1 | LM Orbital Maneuvers | DOI, PDI, APS burns |
|
||||||
|
| 2 | CM Coast/Alignment | Translunar coast, platform alignment |
|
||||||
|
| 3 | LM Coast/Alignment | Lunar orbit coast |
|
||||||
|
| 7 | LM Descent/Ascent | Powered descent, ascent |
|
||||||
|
| 8 | LM Lunar Surface Alignment | Surface activities |
|
||||||
|
| 9 | CM Entry Update | Re-entry corridor |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.downlink_decoder import identify_list_type
|
||||||
|
|
||||||
|
list_id, list_name = identify_list_type(snapshot["words"][0])
|
||||||
|
print(f"List {list_id}: {list_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Pipeline: Bits to Named Fields
|
||||||
|
|
||||||
|
Here is a full example that ties all the engines together:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.downlink_decoder import DownlinkEngine
|
||||||
|
from apollo.pcm_demux import DemuxEngine
|
||||||
|
from apollo.pcm_frame_sync import FrameSyncEngine
|
||||||
|
from apollo.usb_signal_gen import generate_pcm_frame
|
||||||
|
|
||||||
|
# Step 1: Generate test data (5 frames with known payloads)
|
||||||
|
all_bits = []
|
||||||
|
for i in range(5):
|
||||||
|
frame_id = i + 1
|
||||||
|
odd = (frame_id % 2) == 1
|
||||||
|
# Payload: word positions encoded as byte values
|
||||||
|
payload = bytes(range(5, 129)) # Words 5-128 = values 5-128
|
||||||
|
bits = generate_pcm_frame(frame_id=frame_id, odd=odd, data=payload)
|
||||||
|
all_bits.extend(bits)
|
||||||
|
|
||||||
|
# Step 2: Frame sync
|
||||||
|
sync_engine = FrameSyncEngine(bit_rate=51200, max_bit_errors=3)
|
||||||
|
frames = sync_engine.process_bits(all_bits)
|
||||||
|
print(f"Sync state: {sync_engine.state_name}")
|
||||||
|
print(f"Frames decoded: {len(frames)}")
|
||||||
|
|
||||||
|
# Step 3: Demux each frame
|
||||||
|
demux = DemuxEngine(output_format="scaled", words_per_frame=128)
|
||||||
|
dl_engine = DownlinkEngine()
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
result = demux.process_frame(frame["frame_bytes"])
|
||||||
|
|
||||||
|
# Print some telemetry words
|
||||||
|
print(f"\nFrame {result['sync']['frame_id']}:")
|
||||||
|
for word in result["words"][:5]:
|
||||||
|
print(f" Word {word['position']:3d}: "
|
||||||
|
f"code={word['raw_value']:3d} "
|
||||||
|
f"voltage={word['voltage']:.3f}V")
|
||||||
|
|
||||||
|
# Step 4: Feed AGC channels into downlink decoder
|
||||||
|
for agc in result["agc_data"]:
|
||||||
|
print(f" AGC ch {agc['channel_octal']}: "
|
||||||
|
f"word {agc['word_position']}, "
|
||||||
|
f"raw={agc['raw_value']}")
|
||||||
|
snapshot = dl_engine.feed_agc_word(agc["channel"], agc["raw_value"])
|
||||||
|
if snapshot:
|
||||||
|
print(f" >>> Downlink snapshot: {snapshot['list_name']}")
|
||||||
|
|
||||||
|
# Flush any remaining buffered AGC data
|
||||||
|
final = dl_engine.force_flush()
|
||||||
|
if final:
|
||||||
|
print(f"\nFinal partial snapshot: {final['word_count']} words")
|
||||||
|
```
|
||||||
|
|
||||||
|
## GNU Radio Blocks
|
||||||
|
|
||||||
|
When GNU Radio is available, the same processing chain runs as GR blocks connected via message ports:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["pcm_frame_sync<br/>(byte stream in)"] -->|"frames (PDU)"| B["pcm_demux"]
|
||||||
|
B -->|"telemetry (PDU)"| C["Per-word data"]
|
||||||
|
B -->|"agc_data (PDU)"| D["downlink_decoder"]
|
||||||
|
B -->|"raw_frame (PDU)"| E["Full frame"]
|
||||||
|
D -->|"downlink (PDU)"| F["Decoded lists"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import gr
|
||||||
|
|
||||||
|
from apollo.downlink_decoder import downlink_decoder
|
||||||
|
from apollo.pcm_demux import pcm_demux
|
||||||
|
from apollo.pcm_frame_sync import pcm_frame_sync
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
fsync = pcm_frame_sync(bit_rate=51200, max_bit_errors=3)
|
||||||
|
demux = pcm_demux(output_format="scaled")
|
||||||
|
decoder = downlink_decoder(buffer_size=400)
|
||||||
|
|
||||||
|
tb.msg_connect(fsync, "frames", demux, "frames")
|
||||||
|
tb.msg_connect(demux, "agc_data", decoder, "agc_data")
|
||||||
|
```
|
||||||
|
|
||||||
|
The GR blocks use the same `FrameSyncEngine`, `DemuxEngine`, and `DownlinkEngine` internally -- the message-port wrappers just handle PDU serialization.
|
||||||
|
|
||||||
|
## High Rate vs. Low Rate
|
||||||
|
|
||||||
|
| | High Rate | Low Rate |
|
||||||
|
|--|-----------|----------|
|
||||||
|
| Bit rate | 51,200 bps | 1,600 bps |
|
||||||
|
| Words per frame | 128 | 200 |
|
||||||
|
| Frames per second | 50 | 1 |
|
||||||
|
| Frame period | ~20 ms | 1 s |
|
||||||
|
| `FrameSyncEngine(bit_rate=...)` | `51200` | `1600` |
|
||||||
|
| `DemuxEngine(words_per_frame=...)` | `128` | `200` |
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The bit rate and words-per-frame must be consistent between `FrameSyncEngine` and `DemuxEngine`. If the frame sync produces 128-byte frames but the demux expects 200, you will get "Frame too short" errors.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Word Position Reference
|
||||||
|
|
||||||
|
Key word positions in the 128-word high-rate frame:
|
||||||
|
|
||||||
|
| Word(s) | Content |
|
||||||
|
|---------|---------|
|
||||||
|
| 1--4 | 32-bit sync word (A + core + B + frame ID) |
|
||||||
|
| 5--33 | High-level analog sensors (0--5V range) |
|
||||||
|
| 34 | AGC channel 034 (DNTM1) -- telemetry high byte |
|
||||||
|
| 35 | AGC channel 035 (DNTM2) -- telemetry low byte |
|
||||||
|
| 36--56 | Mixed analog and digital inputs |
|
||||||
|
| 57 | AGC channel 057 (OUTLINK) -- digital downlink |
|
||||||
|
| 58--128 | Remaining telemetry channels |
|
||||||
|
|
||||||
|
Access specific words by position:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract just the AGC channels
|
||||||
|
dntm1 = demux.extract_word(frame_bytes, 34)
|
||||||
|
dntm2 = demux.extract_word(frame_bytes, 35)
|
||||||
|
outlink = demux.extract_word(frame_bytes, 57)
|
||||||
|
|
||||||
|
print(f"DNTM1: {dntm1['raw_value']}")
|
||||||
|
print(f"DNTM2: {dntm2['raw_value']}")
|
||||||
|
print(f"OUTLINK: {outlink['raw_value']}")
|
||||||
|
```
|
||||||
249
docs/src/content/docs/guides/test-signals.mdx
Normal file
249
docs/src/content/docs/guides/test-signals.mdx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
title: "Generate Test Signals"
|
||||||
|
description: "How to create synthetic Apollo USB baseband signals for testing the demodulation chain."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The `usb_signal_gen` module produces synthetic complex baseband signals that exercise the full demodulation chain. The generator creates PM-modulated carriers with BPSK PCM subcarriers, optional FM voice, and configurable noise -- all without requiring GNU Radio or an SDR.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Install gr-apollo in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
The signal generator and its dependencies (`numpy`) are pure Python. No GNU Radio installation is needed.
|
||||||
|
|
||||||
|
## Basic Signal Generation
|
||||||
|
|
||||||
|
The core function is `generate_usb_baseband()`. It returns a tuple of the complex baseband signal and a list of per-frame bit sequences (useful for verifying decoder output).
|
||||||
|
|
||||||
|
### Clean Signal
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_usb_baseband
|
||||||
|
|
||||||
|
signal, frame_bits = generate_usb_baseband(frames=5)
|
||||||
|
|
||||||
|
print(f"Samples: {len(signal)}")
|
||||||
|
print(f"Frames generated: {len(frame_bits)}")
|
||||||
|
print(f"Bits per frame: {len(frame_bits[0])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces 5 PCM frames at 51.2 kbps with random payload data and no added noise. Each frame contains 128 words (1024 bits), and the signal is sampled at 5.12 MHz.
|
||||||
|
|
||||||
|
### With Noise
|
||||||
|
|
||||||
|
Add additive white Gaussian noise at a specified SNR:
|
||||||
|
|
||||||
|
```python
|
||||||
|
signal, frame_bits = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
snr_db=20.0, # 20 dB SNR
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| SNR | Signal Quality | Use Case |
|
||||||
|
|-----|---------------|----------|
|
||||||
|
| None | No noise (clean) | Verifying decoder logic |
|
||||||
|
| 40 dB | Very clean | Baseline performance test |
|
||||||
|
| 30 dB | Moderate noise | Realistic strong signal |
|
||||||
|
| 20 dB | Noisy | Stress-testing the demodulator |
|
||||||
|
| 10 dB | Very noisy | Threshold performance testing |
|
||||||
|
|
||||||
|
### Custom Payload Data
|
||||||
|
|
||||||
|
Instead of random data, supply specific byte sequences for each frame:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Known payload: 124 data bytes (words 5-128)
|
||||||
|
payload = bytes(range(124))
|
||||||
|
|
||||||
|
signal, frame_bits = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
frame_data=[payload] * 5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Each frame has 128 words total, but the first 4 are the 32-bit sync word. The `frame_data` list provides the remaining 124 data bytes per frame. If you supply fewer than 124 bytes, the rest are zero-padded.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### With Voice Subcarrier
|
||||||
|
|
||||||
|
Enable the 1.25 MHz FM voice subcarrier with a test tone:
|
||||||
|
|
||||||
|
```python
|
||||||
|
signal, frame_bits = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
voice_enabled=True,
|
||||||
|
voice_tone_hz=1000.0, # 1 kHz test tone (default)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The voice subcarrier is FM-modulated with the specified tone at the Apollo specification deviation of +/-29 kHz. Its level relative to the PCM subcarrier matches the spec ratio (1.68 Vpp voice / 2.2 Vpp PCM).
|
||||||
|
|
||||||
|
## All `generate_usb_baseband` Parameters
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_usb_baseband(
|
||||||
|
frames: int = 1, # Number of PCM frames
|
||||||
|
bit_rate: float = 51_200, # 51200 (high) or 1600 (low)
|
||||||
|
sample_rate: float = 5_120_000, # Output sample rate in Hz
|
||||||
|
pm_deviation: float = 0.133, # Peak PM deviation in radians
|
||||||
|
voice_enabled: bool = False, # Include FM voice subcarrier
|
||||||
|
voice_tone_hz: float = 1000.0, # Voice test tone frequency
|
||||||
|
snr_db: float | None = None, # Add AWGN at this SNR (None = no noise)
|
||||||
|
frame_data: list[bytes] | None = None, # Custom payload per frame
|
||||||
|
) -> tuple[np.ndarray, list[list[int]]]:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Generated Signals with GNU Radio
|
||||||
|
|
||||||
|
Feed the synthetic signal into a GNU Radio flowgraph using `vector_source_c`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
from apollo.usb_signal_gen import generate_usb_baseband
|
||||||
|
|
||||||
|
# Generate test signal with known payload
|
||||||
|
np.random.seed(42)
|
||||||
|
payload = bytes(np.random.randint(0, 256, size=124, dtype=np.uint8))
|
||||||
|
|
||||||
|
signal, frame_bits = generate_usb_baseband(
|
||||||
|
frames=5,
|
||||||
|
frame_data=[payload] * 5,
|
||||||
|
snr_db=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build flowgraph
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
src = blocks.vector_source_c(signal.tolist())
|
||||||
|
receiver = usb_downlink_receiver(output_format="scaled")
|
||||||
|
debug = blocks.message_debug()
|
||||||
|
|
||||||
|
tb.connect(src, receiver)
|
||||||
|
tb.msg_connect(receiver, "frames", debug, "store")
|
||||||
|
|
||||||
|
# Run and check results
|
||||||
|
tb.run()
|
||||||
|
print(f"Decoded {debug.num_messages()} frames")
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Setting `np.random.seed()` before generation ensures reproducible test signals. This is useful for regression tests where you want to verify the same output each time.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Lower-Level Signal Components
|
||||||
|
|
||||||
|
The `usb_signal_gen` module also exposes the individual signal generation stages. These are useful for testing specific blocks in isolation.
|
||||||
|
|
||||||
|
### NRZ Waveform
|
||||||
|
|
||||||
|
Convert a bit list to an NRZ baseband waveform (+1/-1 values):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_nrz_waveform
|
||||||
|
|
||||||
|
bits = [1, 0, 1, 1, 0, 0, 1, 0]
|
||||||
|
nrz = generate_nrz_waveform(bits, bit_rate=51200, sample_rate=5_120_000)
|
||||||
|
# Each bit maps to 100 samples at 5.12 MHz / 51.2 kHz
|
||||||
|
```
|
||||||
|
|
||||||
|
### PCM Frame Bits
|
||||||
|
|
||||||
|
Generate the bit-level content of a single PCM frame (sync word + data):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_pcm_frame
|
||||||
|
|
||||||
|
frame = generate_pcm_frame(
|
||||||
|
frame_id=1, # Frame 1 of 50
|
||||||
|
odd=False, # Even frame (normal sync core)
|
||||||
|
data=bytes(124), # All-zero payload
|
||||||
|
words_per_frame=128, # High rate
|
||||||
|
)
|
||||||
|
print(f"Frame bits: {len(frame)}") # 1024
|
||||||
|
```
|
||||||
|
|
||||||
|
### BPSK Subcarrier
|
||||||
|
|
||||||
|
Modulate NRZ data onto a 1.024 MHz carrier:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_bpsk_subcarrier, generate_nrz_waveform
|
||||||
|
|
||||||
|
bits = [1, 0, 1, 1, 0]
|
||||||
|
nrz = generate_nrz_waveform(bits, 51200, 5_120_000)
|
||||||
|
bpsk = generate_bpsk_subcarrier(nrz, 1_024_000, 5_120_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### FM Voice Subcarrier
|
||||||
|
|
||||||
|
Generate a standalone FM voice subcarrier (no PCM):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_fm_voice_subcarrier
|
||||||
|
|
||||||
|
voice = generate_fm_voice_subcarrier(
|
||||||
|
n_samples=512_000, # 100 ms at 5.12 MHz
|
||||||
|
sample_rate=5_120_000,
|
||||||
|
tone_freq=1000.0, # 1 kHz test tone
|
||||||
|
subcarrier_freq=1_250_000, # 1.25 MHz center
|
||||||
|
fm_deviation=29_000, # +/-29 kHz
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Demo Script
|
||||||
|
|
||||||
|
A ready-to-run demo is included in the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python examples/test_signal_gen_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This script generates clean, voiced, and noisy signals, runs spectral analysis, and prints power measurements for each subcarrier band. Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Apollo USB Signal Generator Demo
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
1. Clean PCM-only signal (3 frames):
|
||||||
|
Samples: 307200 (expected 307200)
|
||||||
|
Duration: 60.0 ms
|
||||||
|
Envelope std: 0.0040 (PM = near-constant)
|
||||||
|
|
||||||
|
2. Spectral analysis:
|
||||||
|
PCM band (950-1100 kHz): 12.3 dB re total
|
||||||
|
|
||||||
|
3. Signal with voice subcarrier:
|
||||||
|
Voice band (1.2-1.3 MHz): 9.1 dB re total
|
||||||
|
|
||||||
|
4. Signal with 20 dB SNR noise:
|
||||||
|
Envelope std: 0.0712 (noisy = higher variance)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Signal Structure at a Glance
|
||||||
|
|
||||||
|
The generated baseband signal has this structure:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[PCM Bits<br/>NRZ +1/-1] --> B["BPSK Mod<br/>data * cos(2pi * 1.024M * t)"]
|
||||||
|
C[Voice Tone<br/>1 kHz sine] --> D["FM Mod<br/>cos(2pi * 1.25M * t + ...)"]
|
||||||
|
B --> E["PM Mod<br/>exp(j * composite)"]
|
||||||
|
D --> E
|
||||||
|
E --> F[Complex<br/>Baseband<br/>5.12 MHz]
|
||||||
|
G["AWGN<br/>(optional)"] --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
The PM deviation is 0.133 radians (7.6 degrees), which is small enough that the small-angle approximation holds with less than 0.3% error. This means the composite modulating signal maps nearly linearly into the carrier phase.
|
||||||
230
docs/src/content/docs/guides/tuning-parameters.mdx
Normal file
230
docs/src/content/docs/guides/tuning-parameters.mdx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
---
|
||||||
|
title: "Tune Demodulator Parameters"
|
||||||
|
description: "How to adjust carrier PLL, BPSK loop, subcarrier bandwidth, and frame sync thresholds for different signal conditions."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The gr-apollo demodulation chain has several parameters that control how aggressively each stage tracks the signal. The defaults work well for clean synthetic signals, but real-world recordings and noisy captures often need adjustment.
|
||||||
|
|
||||||
|
This guide walks through each tunable parameter, explains what it controls, and gives practical guidance on when and how to change it.
|
||||||
|
|
||||||
|
## Parameter Overview
|
||||||
|
|
||||||
|
| Parameter | Default | Block | What It Controls |
|
||||||
|
|-----------|---------|-------|-----------------|
|
||||||
|
| `carrier_pll_bw` | 0.02 | `pm_demod` | Carrier tracking loop bandwidth (rad/sample) |
|
||||||
|
| `bpsk_loop_bw` | 0.045 | `bpsk_demod` | Costas loop + symbol sync bandwidth |
|
||||||
|
| `subcarrier_bw` | 150,000 Hz | `subcarrier_extract` | Bandpass filter width around 1.024 MHz |
|
||||||
|
| `max_bit_errors` | 3 | `pcm_frame_sync` | Hamming distance threshold for sync word match |
|
||||||
|
| `bit_rate` | 51,200 | `pcm_frame_sync` | PCM bit rate (high or low) |
|
||||||
|
|
||||||
|
All of these are constructor arguments to `usb_downlink_receiver`, or can be set on individual blocks when building a custom flowgraph.
|
||||||
|
|
||||||
|
## Carrier PLL Bandwidth
|
||||||
|
|
||||||
|
The `carrier_pll_bw` parameter controls how quickly the PLL in `pm_demod` locks onto the residual carrier and tracks frequency drift. It is specified in radians per sample at the 5.12 MHz baseband rate.
|
||||||
|
|
||||||
|
| Value | Behavior | Use When |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| 0.005 | Very narrow -- slow acquisition, best noise rejection | Weak signal, steady carrier |
|
||||||
|
| 0.02 | Default -- balanced tracking and noise | General-purpose decoding |
|
||||||
|
| 0.05 | Wide -- fast acquisition, admits more noise | Strong signal, rapid frequency changes |
|
||||||
|
| 0.10 | Very wide -- immediate lock, noisy output | Initial acquisition sweep, testing |
|
||||||
|
|
||||||
|
### Adjusting the Carrier PLL
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="usb_downlink_receiver">
|
||||||
|
```python
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
|
||||||
|
receiver = usb_downlink_receiver(
|
||||||
|
carrier_pll_bw=0.01, # Narrow for weak signals
|
||||||
|
)
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Individual block">
|
||||||
|
```python
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
|
||||||
|
pm = pm_demod(carrier_pll_bw=0.01, sample_rate=5_120_000)
|
||||||
|
|
||||||
|
# Can also adjust at runtime:
|
||||||
|
pm.set_carrier_pll_bw(0.03)
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
If the PLL never acquires lock on a real recording, try starting with a wide bandwidth (0.08) to find the carrier, then narrow it once you confirm the signal is present. The PLL frequency capture range is approximately `carrier_pll_bw * sample_rate / (2 * pi)` Hz.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## BPSK Loop Bandwidth
|
||||||
|
|
||||||
|
The `bpsk_loop_bw` parameter sets the bandwidth for both the Costas carrier-recovery loop and the Mueller & Muller symbol timing recovery inside `bpsk_demod`. It affects how well the demodulator tracks phase variations in the 1.024 MHz BPSK subcarrier.
|
||||||
|
|
||||||
|
| Value | Behavior | Use When |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| 0.01 | Very tight tracking | Clean signal, minimal phase noise |
|
||||||
|
| 0.045 | Default | General-purpose, synthetic signals |
|
||||||
|
| 0.08 | Loose tracking | Noisy signal with phase jitter |
|
||||||
|
| 0.12 | Very loose | Severely degraded signal, testing only |
|
||||||
|
|
||||||
|
The symbol sync loop bandwidth is automatically derived as `loop_bw * 0.5`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside bpsk_demod.__init__:
|
||||||
|
self.sym_sync = digital.symbol_sync_cc(
|
||||||
|
digital.TED_MUELLER_AND_MULLER,
|
||||||
|
self._sps, # samples per symbol
|
||||||
|
loop_bw * 0.5, # timing loop BW = half the Costas BW
|
||||||
|
1.0, # damping factor
|
||||||
|
1.0, # TED gain
|
||||||
|
1.5, # max deviation (samples)
|
||||||
|
1, # output samples per symbol
|
||||||
|
bpsk_constellation,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Setting `bpsk_loop_bw` too wide causes the Costas loop to track noise rather than the carrier. You will see the bit error rate increase even though the loop appears "locked." If decoded frames have poor sync confidence, try *reducing* this value first.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Subcarrier Bandwidth
|
||||||
|
|
||||||
|
The `subcarrier_bw` parameter sets the passband width of the frequency-translating FIR filter in `subcarrier_extract`. The default 150 kHz matches the Apollo PCM bandpass filter specification (949--1099 kHz).
|
||||||
|
|
||||||
|
| Value | Behavior | Trade-off |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| 80,000 Hz | Very narrow | Rejects adjacent subcarriers but may clip sideband energy |
|
||||||
|
| 150,000 Hz | Default (spec) | Matches the original hardware BPF |
|
||||||
|
| 200,000 Hz | Wide | More tolerant of frequency offset, but admits more noise |
|
||||||
|
| 250,000 Hz | Very wide | Use only if the subcarrier frequency is uncertain |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.subcarrier_extract import subcarrier_extract
|
||||||
|
|
||||||
|
sc = subcarrier_extract(
|
||||||
|
center_freq=1_024_000,
|
||||||
|
bandwidth=200_000, # Wider than default
|
||||||
|
sample_rate=5_120_000,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The transition bandwidth is automatically set to 20% of the passband width. A 150 kHz passband uses a 30 kHz transition, so the actual -6 dB points are approximately 924 kHz and 1,124 kHz.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Frame Sync Bit Error Threshold
|
||||||
|
|
||||||
|
The `max_bit_errors` parameter controls how many bits in the 26-bit static portion of the sync word (the 5-bit A field + 15-bit core + 6-bit B field) can differ from the reference and still count as a match. The 6-bit frame ID field is not included in the correlation.
|
||||||
|
|
||||||
|
| Value | Behavior | Trade-off |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| 0 | Exact match only | No false locks, but very slow acquisition on noisy signals |
|
||||||
|
| 3 | Default | Tolerates 3 bit errors in the 26-bit pattern |
|
||||||
|
| 5 | Permissive | Faster acquisition, but higher chance of false sync |
|
||||||
|
| 8 | Very permissive | Testing only -- will produce many false frames |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_frame_sync import FrameSyncEngine
|
||||||
|
|
||||||
|
engine = FrameSyncEngine(
|
||||||
|
bit_rate=51200,
|
||||||
|
max_bit_errors=2, # Stricter than default
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frame Sync State Machine
|
||||||
|
|
||||||
|
The sync engine uses a three-state machine that provides additional protection against false locks:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> SEARCH
|
||||||
|
SEARCH --> VERIFY : Sync match found (Hamming distance <= max_bit_errors)
|
||||||
|
VERIFY --> LOCKED : verify_count consecutive hits (default 2)
|
||||||
|
VERIFY --> SEARCH : miss_limit consecutive misses (default 3)
|
||||||
|
LOCKED --> LOCKED : Continuous sync matches
|
||||||
|
LOCKED --> SEARCH : miss_limit consecutive misses
|
||||||
|
```
|
||||||
|
|
||||||
|
Even with a permissive `max_bit_errors`, the VERIFY stage requires multiple consecutive sync hits at the expected frame spacing before declaring lock.
|
||||||
|
|
||||||
|
## High Rate vs. Low Rate
|
||||||
|
|
||||||
|
Apollo PCM telemetry operates at two bit rates, both derived from the 512 kHz master clock:
|
||||||
|
|
||||||
|
| Parameter | High Rate | Low Rate |
|
||||||
|
|-----------|-----------|----------|
|
||||||
|
| Bit rate | 51,200 bps | 1,600 bps |
|
||||||
|
| Clock divider | 512 kHz / 10 | 512 kHz / 320 |
|
||||||
|
| Words per frame | 128 | 200 |
|
||||||
|
| Frames per second | 50 | 1 |
|
||||||
|
| Frame period | ~20 ms | 1 s |
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="High rate (default)">
|
||||||
|
```python
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
|
||||||
|
receiver = usb_downlink_receiver(
|
||||||
|
bit_rate=51200,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Low rate">
|
||||||
|
```python
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
|
||||||
|
receiver = usb_downlink_receiver(
|
||||||
|
bit_rate=1600,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The bit rate selection changes the BPSK symbol rate, frame length, and sync correlator timing. If you set the wrong rate, the frame sync will never achieve lock. High rate (51.2 kbps) was used for the majority of the mission; low rate (1.6 kbps) was used during specific mission phases when bandwidth was constrained.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Troubleshooting by Symptom
|
||||||
|
|
||||||
|
Use this table to identify which parameter to adjust based on what you observe:
|
||||||
|
|
||||||
|
| Symptom | Likely Cause | Parameter to Adjust |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| No frames decoded at all | Carrier PLL not locking | Increase `carrier_pll_bw` to 0.05+ |
|
||||||
|
| Frames appear then disappear | BPSK loop tracking noise | Decrease `bpsk_loop_bw` to 0.02 |
|
||||||
|
| Low sync confidence values | Bit errors in sync word | Increase `max_bit_errors` to 4-5 |
|
||||||
|
| Noisy decoded voltage values | Too much noise in passband | Decrease `subcarrier_bw` to 100,000 |
|
||||||
|
| PLL acquires then drifts | Carrier PLL too narrow for drift | Increase `carrier_pll_bw` slightly |
|
||||||
|
| Rapid false frame emissions | Sync threshold too permissive | Decrease `max_bit_errors` to 1-2 |
|
||||||
|
| "Frame too short" errors | Wrong bit rate selected | Switch between 51200 and 1600 |
|
||||||
|
|
||||||
|
## Recommended Starting Points
|
||||||
|
|
||||||
|
For a synthetic test signal at 30 dB SNR, the defaults work as-is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
receiver = usb_downlink_receiver(
|
||||||
|
carrier_pll_bw=0.02,
|
||||||
|
bpsk_loop_bw=0.045,
|
||||||
|
subcarrier_bw=150_000,
|
||||||
|
max_bit_errors=3,
|
||||||
|
bit_rate=51200,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a weak or noisy real-world recording, start with these and iterate:
|
||||||
|
|
||||||
|
```python
|
||||||
|
receiver = usb_downlink_receiver(
|
||||||
|
carrier_pll_bw=0.05, # Wider for acquisition
|
||||||
|
bpsk_loop_bw=0.03, # Tighter to reject noise
|
||||||
|
subcarrier_bw=120_000, # Narrower to reduce noise floor
|
||||||
|
max_bit_errors=4, # More tolerant of bit errors
|
||||||
|
bit_rate=51200,
|
||||||
|
)
|
||||||
|
```
|
||||||
239
docs/src/content/docs/guides/voice-audio.mdx
Normal file
239
docs/src/content/docs/guides/voice-audio.mdx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
---
|
||||||
|
title: "Decode Voice Audio"
|
||||||
|
description: "How to extract and play back astronaut voice from the 1.25 MHz FM subcarrier in an Apollo USB downlink signal."
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The Apollo Unified S-Band downlink carries a voice channel on a 1.25 MHz FM subcarrier alongside the PCM telemetry. The `voice_subcarrier_demod` block extracts this subcarrier and recovers 300--3000 Hz telephone-quality audio suitable for playback or recording.
|
||||||
|
|
||||||
|
## Voice Channel Specifications
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Subcarrier frequency | 1.25 MHz (FM modulated) |
|
||||||
|
| FM deviation | +/-29 kHz |
|
||||||
|
| Audio bandwidth | 300--3000 Hz |
|
||||||
|
| Default output sample rate | 8000 Hz |
|
||||||
|
| Modulation mode | PM downlink only (not FM mode) |
|
||||||
|
|
||||||
|
On the spacecraft, the audio path runs through an FM VCO at 113 kHz, then a balanced mixer with the 512 kHz master clock, a bandpass filter, and a frequency doubler to produce the 1.25 MHz subcarrier. The receiver reverses this process: extract the subcarrier, apply an FM discriminator, and bandpass-filter the resulting audio.
|
||||||
|
|
||||||
|
## Building a Voice Demodulation Flowgraph
|
||||||
|
|
||||||
|
The simplest approach uses `pm_demod` to recover the composite modulating signal, then `voice_subcarrier_demod` to extract the audio.
|
||||||
|
|
||||||
|
### Live Playback
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import audio, blocks, gr
|
||||||
|
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
# Source: complex baseband from SDR or file
|
||||||
|
src = blocks.file_source(
|
||||||
|
gr.sizeof_gr_complex,
|
||||||
|
"/path/to/apollo_baseband.cf32",
|
||||||
|
repeat=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 1: PM demodulator -- carrier PLL + phase extraction
|
||||||
|
pm = pm_demod(carrier_pll_bw=0.02, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
|
||||||
|
# Stage 2: Voice subcarrier demod -- extract 1.25 MHz FM, output 8 kHz audio
|
||||||
|
voice = voice_subcarrier_demod(
|
||||||
|
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||||
|
audio_rate=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 3: Audio sink for live playback
|
||||||
|
audio_sink = audio.sink(8000)
|
||||||
|
|
||||||
|
tb.connect(src, pm, voice, audio_sink)
|
||||||
|
tb.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record to WAV File
|
||||||
|
|
||||||
|
To save the decoded audio to a file instead of playing it live:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
src = blocks.file_source(
|
||||||
|
gr.sizeof_gr_complex,
|
||||||
|
"/path/to/apollo_baseband.cf32",
|
||||||
|
repeat=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pm = pm_demod(carrier_pll_bw=0.02, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
|
||||||
|
voice = voice_subcarrier_demod(
|
||||||
|
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||||
|
audio_rate=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAV file sink -- 1 channel, 8 kHz, 16-bit PCM
|
||||||
|
wav_sink = blocks.wavfile_sink(
|
||||||
|
"/path/to/apollo_voice.wav",
|
||||||
|
1, # channels
|
||||||
|
8000, # sample rate
|
||||||
|
blocks.FORMAT_WAV,
|
||||||
|
blocks.FORMAT_PCM_16,
|
||||||
|
)
|
||||||
|
|
||||||
|
tb.connect(src, pm, voice, wav_sink)
|
||||||
|
tb.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using a Synthetic Test Signal
|
||||||
|
|
||||||
|
To test the voice demod without a real recording, generate a signal with the voice subcarrier enabled:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
from apollo.usb_signal_gen import generate_usb_baseband
|
||||||
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||||
|
|
||||||
|
# Generate 100 frames (~2 seconds) with a 1 kHz voice tone
|
||||||
|
signal, _ = generate_usb_baseband(
|
||||||
|
frames=100,
|
||||||
|
voice_enabled=True,
|
||||||
|
voice_tone_hz=1000.0,
|
||||||
|
snr_db=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
src = blocks.vector_source_c(signal.tolist())
|
||||||
|
pm = pm_demod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
voice = voice_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND, audio_rate=8000)
|
||||||
|
|
||||||
|
wav_sink = blocks.wavfile_sink(
|
||||||
|
"test_voice_output.wav", 1, 8000,
|
||||||
|
blocks.FORMAT_WAV, blocks.FORMAT_PCM_16,
|
||||||
|
)
|
||||||
|
|
||||||
|
tb.connect(src, pm, voice, wav_sink)
|
||||||
|
tb.run()
|
||||||
|
|
||||||
|
print("Wrote test_voice_output.wav")
|
||||||
|
```
|
||||||
|
|
||||||
|
The output WAV file should contain a clean 1 kHz tone.
|
||||||
|
|
||||||
|
## Internal Processing Chain
|
||||||
|
|
||||||
|
The `voice_subcarrier_demod` block is a hierarchical block that chains four stages internally:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["PM Demod<br/>Output (float)"] --> B["Subcarrier Extract<br/>BPF 1.25 MHz<br/>BW = 58 kHz<br/>Decimation ~40x"]
|
||||||
|
B --> C["Quadrature Demod<br/>FM discriminator"]
|
||||||
|
C --> D["Audio BPF<br/>300-3000 Hz"]
|
||||||
|
D --> E["Rational Resampler<br/>to 8000 Hz"]
|
||||||
|
E --> F["Audio<br/>Output (float)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The aggressive decimation in stage 1 (from 5.12 MHz down to ~128 kHz) reduces the processing load before the FM discriminator runs. The audio bandpass removes DC offset from the discriminator and any noise above 3 kHz.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
The `voice_subcarrier_demod` constructor accepts two parameters:
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `sample_rate` | 5,120,000 | Input sample rate from PM demodulator |
|
||||||
|
| `audio_rate` | 8000 | Output audio sample rate in Hz |
|
||||||
|
|
||||||
|
The subcarrier frequency (1.25 MHz), FM deviation (+/-29 kHz), and audio bandwidth (300--3000 Hz) are fixed to the Apollo specification and are not configurable.
|
||||||
|
|
||||||
|
You can query the actual output rate and intermediate rate at runtime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
voice = voice_subcarrier_demod(sample_rate=5_120_000, audio_rate=8000)
|
||||||
|
|
||||||
|
print(f"Output rate: {voice.output_sample_rate} Hz") # 8000.0
|
||||||
|
print(f"Extracted rate: {voice.extracted_rate} Hz") # ~128000.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extracting Voice Alongside PCM Telemetry
|
||||||
|
|
||||||
|
Since the voice and PCM subcarriers occupy different frequency bands, you can decode both simultaneously by splitting the PM demodulator output:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from gnuradio import blocks, gr
|
||||||
|
|
||||||
|
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||||
|
|
||||||
|
tb = gr.top_block()
|
||||||
|
|
||||||
|
src = blocks.file_source(gr.sizeof_gr_complex, "recording.cf32", repeat=False)
|
||||||
|
|
||||||
|
# Shared PM demod
|
||||||
|
pm = pm_demod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
|
||||||
|
# PCM telemetry path (uses the full receiver for convenience)
|
||||||
|
# Note: usb_downlink_receiver has its own PM demod internally,
|
||||||
|
# so for combined use, build the chain with individual blocks.
|
||||||
|
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod
|
||||||
|
from apollo.pcm_demux import pcm_demux
|
||||||
|
from apollo.pcm_frame_sync import pcm_frame_sync
|
||||||
|
|
||||||
|
bpsk = bpsk_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
fsync = pcm_frame_sync(bit_rate=51200)
|
||||||
|
demux = pcm_demux(output_format="scaled")
|
||||||
|
|
||||||
|
# Voice path
|
||||||
|
voice = voice_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||||
|
wav = blocks.wavfile_sink("voice.wav", 1, 8000, blocks.FORMAT_WAV, blocks.FORMAT_PCM_16)
|
||||||
|
|
||||||
|
# Frame output
|
||||||
|
frame_sink = blocks.message_debug()
|
||||||
|
|
||||||
|
# Connect shared PM demod
|
||||||
|
tb.connect(src, pm)
|
||||||
|
|
||||||
|
# Split PM output to both paths
|
||||||
|
tb.connect(pm, bpsk, fsync)
|
||||||
|
tb.connect(pm, voice, wav)
|
||||||
|
|
||||||
|
# Message connections for telemetry
|
||||||
|
tb.msg_connect(fsync, "frames", demux, "frames")
|
||||||
|
tb.msg_connect(fsync, "frames", frame_sink, "store")
|
||||||
|
|
||||||
|
tb.run()
|
||||||
|
print(f"Decoded {frame_sink.num_messages()} PCM frames")
|
||||||
|
print("Wrote voice.wav")
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The voice subcarrier is only present during PM downlink mode. In FM downlink mode, the 1.25 MHz band is used differently, and the voice path is replaced by Subcarrier Oscillator (SCO) channels. The `sco_demod` block handles FM-mode analog telemetry.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Audio Quality Notes
|
||||||
|
|
||||||
|
The recovered audio has telephone-grade quality (300--3000 Hz, 8 kHz sample rate). This matches the original system design -- the Apollo voice link was optimized for intelligibility, not high fidelity. Expect the following characteristics:
|
||||||
|
|
||||||
|
- No low-frequency content below 300 Hz (filtered by design)
|
||||||
|
- No high-frequency content above 3 kHz (filtered by design)
|
||||||
|
- FM noise floor depends on the input signal SNR
|
||||||
|
- The 200 Hz transition bands in the audio BPF may cause slight rolloff near the band edges
|
||||||
|
|
||||||
|
For the best audio quality from a noisy recording, use a narrower carrier PLL bandwidth (0.01) in the PM demod to minimize phase noise that propagates into the voice demodulator.
|
||||||
36
docs/src/content/docs/index.mdx
Normal file
36
docs/src/content/docs/index.mdx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: gr-apollo
|
||||||
|
description: Apollo Unified S-Band decoder for GNU Radio 3.10+
|
||||||
|
template: splash
|
||||||
|
hero:
|
||||||
|
tagline: Decode Apollo-era spacecraft telemetry with modern software-defined radio
|
||||||
|
actions:
|
||||||
|
- text: Get Started
|
||||||
|
link: /getting-started/introduction/
|
||||||
|
icon: right-arrow
|
||||||
|
variant: primary
|
||||||
|
- text: View on GitHub
|
||||||
|
link: https://github.com/rpm/gr-apollo
|
||||||
|
icon: external
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
<CardGrid stagger>
|
||||||
|
<Card title="PCM Telemetry" icon="document">
|
||||||
|
Decode 128-word PCM frames at 51.2 kbps with 32-bit frame synchronization,
|
||||||
|
A/D voltage scaling, and automatic complement-on-odd detection.
|
||||||
|
</Card>
|
||||||
|
<Card title="Voice Channel" icon="comment">
|
||||||
|
Extract astronaut voice from the 1.25 MHz FM subcarrier —
|
||||||
|
demodulate to 300-3000 Hz audio at 8 kHz sample rate.
|
||||||
|
</Card>
|
||||||
|
<Card title="Virtual AGC Bridge" icon="rocket">
|
||||||
|
Bridge decoded telemetry directly to the Virtual AGC emulator
|
||||||
|
via TCP socket — send DSKY commands, receive downlink data.
|
||||||
|
</Card>
|
||||||
|
<Card title="Signal Generator" icon="puzzle">
|
||||||
|
Generate synthetic USB baseband signals with configurable SNR,
|
||||||
|
known payloads, and voice — no hardware needed for testing.
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
925
docs/src/content/docs/reference/blocks.mdx
Normal file
925
docs/src/content/docs/reference/blocks.mdx
Normal file
@ -0,0 +1,925 @@
|
|||||||
|
---
|
||||||
|
title: "Block Reference"
|
||||||
|
description: "Complete API reference for every gr-apollo GNU Radio block and pure-Python engine"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Every component in gr-apollo falls into one of two categories: **GNU Radio blocks** that require the `gnuradio` runtime, and **pure-Python engines** that work standalone. The engines power the GR blocks internally, but you can also use them directly for testing, scripting, or integration without GNU Radio.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Blocks that require GNU Radio are imported lazily. If `gnuradio` is not installed, the pure-Python engines (`FrameSyncEngine`, `DemuxEngine`, `DownlinkEngine`, `AGCBridgeClient`, `UplinkEncoder`, `generate_usb_baseband`) remain available.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signal Generation
|
||||||
|
|
||||||
|
### `generate_usb_baseband`
|
||||||
|
|
||||||
|
**Module:** `apollo.usb_signal_gen`
|
||||||
|
**Type:** Pure-Python function (numpy)
|
||||||
|
**Purpose:** Generate a synthetic Apollo USB downlink complex baseband signal for testing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_signal_gen import generate_usb_baseband
|
||||||
|
|
||||||
|
signal, frame_bits = generate_usb_baseband(frames=10, snr_db=20.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_usb_baseband(
|
||||||
|
frames: int = 1,
|
||||||
|
bit_rate: float = 51_200,
|
||||||
|
sample_rate: float = 5_120_000,
|
||||||
|
pm_deviation: float = 0.133,
|
||||||
|
voice_enabled: bool = False,
|
||||||
|
voice_tone_hz: float = 1000.0,
|
||||||
|
snr_db: float | None = None,
|
||||||
|
frame_data: list[bytes] | None = None,
|
||||||
|
) -> tuple[np.ndarray, list[list[int]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `frames` | `int` | `1` | Number of PCM frames to generate |
|
||||||
|
| `bit_rate` | `float` | `51200` | PCM bit rate in bps. Use `51200` for high rate, `1600` for low rate |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Output sample rate in Hz |
|
||||||
|
| `pm_deviation` | `float` | `0.133` | Peak PM deviation in radians (7.6 degrees) |
|
||||||
|
| `voice_enabled` | `bool` | `False` | Include 1.25 MHz FM voice subcarrier with test tone |
|
||||||
|
| `voice_tone_hz` | `float` | `1000.0` | Audio frequency of the voice test tone in Hz |
|
||||||
|
| `snr_db` | `float \| None` | `None` | If set, add AWGN at this signal-to-noise ratio (dB). `None` means no noise |
|
||||||
|
| `frame_data` | `list[bytes] \| None` | `None` | Optional per-frame payload bytes. `None` generates random data |
|
||||||
|
|
||||||
|
#### Return Value
|
||||||
|
|
||||||
|
A tuple of `(signal, frame_bits)`:
|
||||||
|
- `signal`: `np.ndarray` of `complex64` -- the complex baseband IQ samples
|
||||||
|
- `frame_bits`: `list[list[int]]` -- the transmitted bit sequences per frame (for verification)
|
||||||
|
|
||||||
|
#### Supporting Functions
|
||||||
|
|
||||||
|
The module also exposes the individual stages of signal generation:
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| `generate_pcm_frame` | `(frame_id, odd, data, words_per_frame) -> list[int]` | Bit list (length = words_per_frame * 8) |
|
||||||
|
| `generate_nrz_waveform` | `(bits, bit_rate, sample_rate) -> np.ndarray` | Float32 NRZ samples (+1.0 / -1.0) |
|
||||||
|
| `generate_bpsk_subcarrier` | `(nrz_data, subcarrier_freq, sample_rate) -> np.ndarray` | Float32 BPSK subcarrier samples |
|
||||||
|
| `generate_fm_voice_subcarrier` | `(n_samples, sample_rate, tone_freq, subcarrier_freq, fm_deviation) -> np.ndarray` | Float32 FM subcarrier samples |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Demodulation
|
||||||
|
|
||||||
|
### `pm_demod`
|
||||||
|
|
||||||
|
**Module:** `apollo.pm_demod`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Extract phase modulation from complex baseband using a carrier-tracking PLL.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pm_demod import pm_demod
|
||||||
|
|
||||||
|
blk = pm_demod(carrier_pll_bw=0.02, sample_rate=5_120_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `complex` | Complex baseband IQ samples |
|
||||||
|
| `out0` | Output | `float` | Demodulated composite signal containing all subcarriers |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `carrier_pll_bw` | `float` | `0.02` | PLL loop bandwidth in rad/sample. Narrower tracks better, wider acquires faster |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
|
||||||
|
#### Runtime Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `get_carrier_pll_bw` | `() -> float` | Read current PLL loop bandwidth |
|
||||||
|
| `set_carrier_pll_bw` | `(bw: float) -> None` | Update PLL loop bandwidth at runtime |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
`input -> pll_carriertracking_cc -> complex_to_arg -> output`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `subcarrier_extract`
|
||||||
|
|
||||||
|
**Module:** `apollo.subcarrier_extract`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Bandpass-filter and frequency-translate a subcarrier to complex baseband.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.subcarrier_extract import subcarrier_extract
|
||||||
|
|
||||||
|
blk = subcarrier_extract(center_freq=1_024_000, bandwidth=150_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `float` | PM demodulator output (composite subcarrier signal) |
|
||||||
|
| `out0` | Output | `complex` | Baseband subcarrier signal (decimated) |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `center_freq` | `float` | `1024000` | Subcarrier center frequency in Hz |
|
||||||
|
| `bandwidth` | `float` | `150000` | Passband bandwidth in Hz. Transition band is 20% of this value |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
| `decimation` | `int` | `1` | Output decimation factor |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `output_sample_rate` | `float` | Effective output rate: `sample_rate / decimation` |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
`input -> float_to_complex -> freq_xlating_fir_filter_ccc -> output`
|
||||||
|
|
||||||
|
The filter taps are designed as a Hamming-windowed lowpass at `bandwidth / 2` cutoff with 20% transition width.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `bpsk_demod`
|
||||||
|
|
||||||
|
**Module:** `apollo.bpsk_demod`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Recover NRZ bits from a BPSK baseband signal using Costas loop carrier recovery and Mueller-Muller symbol sync.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.bpsk_demod import bpsk_demod
|
||||||
|
|
||||||
|
blk = bpsk_demod(symbol_rate=51_200, sample_rate=5_120_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `complex` | Baseband BPSK signal (from `subcarrier_extract`) |
|
||||||
|
| `out0` | Output | `byte` | Recovered NRZ bits (0 or 1), one per symbol period |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `symbol_rate` | `float` | `51200` | Symbol (bit) rate in Hz |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
| `loop_bw` | `float` | `0.045` | Costas loop bandwidth in rad/sample |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
`input -> costas_loop_cc(order=2) -> symbol_sync_cc(TED_MUELLER_AND_MULLER) -> complex_to_real -> binary_slicer_fb -> output`
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
BPSK has an inherent 180-degree phase ambiguity. The Costas loop locks to one of two stable points. If it locks to the wrong one, all bits are inverted. The downstream frame synchronizer resolves this by checking both normal and complemented sync patterns.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `bpsk_subcarrier_demod`
|
||||||
|
|
||||||
|
**Module:** `apollo.bpsk_subcarrier_demod`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Convenience wrapper combining `subcarrier_extract` and `bpsk_demod` for the 1.024 MHz PCM subcarrier.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod
|
||||||
|
|
||||||
|
blk = bpsk_subcarrier_demod(sample_rate=5_120_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `float` | PM demodulator output (composite subcarrier signal) |
|
||||||
|
| `out0` | Output | `byte` | Recovered NRZ bits (0 or 1) |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `subcarrier_freq` | `float` | `1024000` | PCM subcarrier frequency in Hz (`PCM_SUBCARRIER_HZ`) |
|
||||||
|
| `bandwidth` | `float` | `150000` | Bandpass filter bandwidth in Hz (`PCM_BPF_BW_HZ`) |
|
||||||
|
| `bit_rate` | `float` | `51200` | PCM bit rate in Hz (`PCM_HIGH_BIT_RATE`) |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
| `decimation` | `int` | `1` | Subcarrier extraction decimation factor |
|
||||||
|
| `loop_bw` | `float` | `0.045` | BPSK Costas loop bandwidth |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
`input -> subcarrier_extract -> bpsk_demod -> output`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frame Processing
|
||||||
|
|
||||||
|
### `FrameSyncEngine` / `pcm_frame_sync`
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Pure Python Engine">
|
||||||
|
|
||||||
|
**Module:** `apollo.pcm_frame_sync`
|
||||||
|
**Type:** Pure-Python class
|
||||||
|
**Purpose:** Acquire and track the 32-bit PCM sync pattern from an NRZ bit stream using a three-state machine (SEARCH, VERIFY, LOCKED).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_frame_sync import FrameSyncEngine
|
||||||
|
|
||||||
|
engine = FrameSyncEngine(bit_rate=51_200, max_bit_errors=3)
|
||||||
|
frames = engine.process_bits([1, 0, 1, 1, ...])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high) or `1600` (low). Determines words per frame |
|
||||||
|
| `max_bit_errors` | `int` | `3` | Maximum Hamming distance for a sync pattern match |
|
||||||
|
| `verify_count` | `int` | `2` | Consecutive frame-boundary hits to transition VERIFY to LOCKED |
|
||||||
|
| `miss_limit` | `int` | `3` | Consecutive frame-boundary misses to drop LOCKED to SEARCH |
|
||||||
|
| `a_bits` | `int` | `0b10101` | 5-bit patchboard-selectable A field |
|
||||||
|
| `core` | `int` | `0b111001101011100` | 15-bit fixed core (even-frame value) |
|
||||||
|
| `b_bits` | `int` | `0b110100` | 6-bit patchboard-selectable B field |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `process_bits` | `(bits: list[int]) -> list[dict]` | Feed bits into the engine. Returns list of completed frame dicts |
|
||||||
|
| `reset` | `() -> None` | Reset to SEARCH state, clear all buffers |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `state_name` | `str` | Current state: `"SEARCH"`, `"VERIFY"`, or `"LOCKED"` |
|
||||||
|
| `state` | `int` | Numeric state: `0` (SEARCH), `1` (VERIFY), `2` (LOCKED) |
|
||||||
|
| `bits_per_frame` | `int` | Total bits per frame (1024 for high rate, 1600 for low rate) |
|
||||||
|
| `words_per_frame` | `int` | Words per frame (128 or 200) |
|
||||||
|
|
||||||
|
#### Output Frame Dict
|
||||||
|
|
||||||
|
Each frame returned by `process_bits` contains:
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| `frame_id` | `int` | Frame number within subframe (1-50) |
|
||||||
|
| `odd_frame` | `bool` | Whether the frame has a complemented sync core |
|
||||||
|
| `sync_confidence` | `int` | Number of correct bits in the 32-bit sync word (32 - hamming_distance) |
|
||||||
|
| `timestamp` | `float` | `time.time()` when the frame was emitted |
|
||||||
|
| `state` | `str` | Engine state name when frame was emitted |
|
||||||
|
| `frame_bytes` | `bytes` | Complete frame packed as bytes |
|
||||||
|
| `frame_bits` | `list[int]` | Complete frame as bit list |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="GNU Radio Block">
|
||||||
|
|
||||||
|
**Module:** `apollo.pcm_frame_sync`
|
||||||
|
**Type:** `gr.basic_block`
|
||||||
|
**Purpose:** GNU Radio wrapper around `FrameSyncEngine`. Consumes byte stream, emits PDU messages.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_frame_sync import pcm_frame_sync
|
||||||
|
|
||||||
|
blk = pcm_frame_sync(bit_rate=51_200, max_bit_errors=3)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `byte` (streaming) | NRZ bit stream from BPSK demodulator |
|
||||||
|
| `"frames"` | Output | Message (PDU) | Complete frame PDUs |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` or `1600` |
|
||||||
|
| `max_bit_errors` | `int` | `3` | Maximum Hamming distance for sync match |
|
||||||
|
|
||||||
|
#### Output PDU Format
|
||||||
|
|
||||||
|
Each PDU is a `pmt.cons(metadata, payload)`:
|
||||||
|
|
||||||
|
**Metadata dict:**
|
||||||
|
| Key | PMT Type | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `frame_id` | `pmt.from_long` | Frame number (1-50) |
|
||||||
|
| `odd_frame` | `pmt.from_bool` | Complemented sync core |
|
||||||
|
| `sync_confidence` | `pmt.from_long` | Correct sync bits (out of 32) |
|
||||||
|
| `timestamp` | `pmt.from_double` | Emission timestamp |
|
||||||
|
|
||||||
|
**Payload:** `pmt.init_u8vector` containing frame bytes.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DemuxEngine` / `pcm_demux`
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Pure Python Engine">
|
||||||
|
|
||||||
|
**Module:** `apollo.pcm_demux`
|
||||||
|
**Type:** Pure-Python class
|
||||||
|
**Purpose:** Demultiplex PCM frames into individual telemetry words, applying A/D voltage scaling and identifying AGC downlink channels.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_demux import DemuxEngine
|
||||||
|
|
||||||
|
engine = DemuxEngine(output_format="scaled")
|
||||||
|
result = engine.process_frame(frame_bytes, meta={"frame_id": 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `output_format` | `str` | `"raw"` | One of `"raw"` (8-bit integers), `"scaled"` (voltage-converted), `"engineering"` (future: named fields with units) |
|
||||||
|
| `words_per_frame` | `int` | `128` | Frame length: `128` (high rate) or `200` (low rate) |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `process_frame` | `(frame_bytes: bytes, meta: dict \| None) -> dict` | Demultiplex a complete frame |
|
||||||
|
| `extract_word` | `(frame_bytes: bytes, word_position: int) -> dict` | Extract a single word by 1-indexed position (1-128 or 1-200) |
|
||||||
|
|
||||||
|
#### `process_frame` Return Value
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| `sync` | `dict` | Parsed sync word fields: `a_bits`, `core`, `b_bits`, `frame_id`, `word` |
|
||||||
|
| `words` | `list[dict]` | Per-word dicts with `position` (1-indexed), `raw_value`, and optionally `voltage`, `voltage_low_level` |
|
||||||
|
| `agc_data` | `list[dict]` | AGC channel entries with `channel`, `channel_octal`, `raw_value`, `word_position`, optionally `voltage` |
|
||||||
|
| `raw_frame` | `bytes` | Original frame bytes |
|
||||||
|
| `meta` | `dict` | Pass-through metadata from frame sync |
|
||||||
|
|
||||||
|
#### AGC Word Positions (High-Rate Frame)
|
||||||
|
|
||||||
|
| AGC Channel | Octal | Word Position (1-indexed) | Description |
|
||||||
|
|-------------|-------|---------------------------|-------------|
|
||||||
|
| 28 | 034 | 34 | DNTM1 -- telemetry word high byte |
|
||||||
|
| 29 | 035 | 35 | DNTM2 -- telemetry word low byte |
|
||||||
|
| 47 | 057 | 57 | OUTLINK -- digital downlink data |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="GNU Radio Block">
|
||||||
|
|
||||||
|
**Module:** `apollo.pcm_demux`
|
||||||
|
**Type:** `gr.basic_block`
|
||||||
|
**Purpose:** GNU Radio message-port wrapper. Receives frame PDUs, emits demultiplexed data on three output ports.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.pcm_demux import pcm_demux
|
||||||
|
|
||||||
|
blk = pcm_demux(output_format="scaled")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `"frames"` | Input | Message (PDU) | Complete frame PDUs from `pcm_frame_sync` |
|
||||||
|
| `"telemetry"` | Output | Message (PDU) | Individual word PDUs with channel metadata |
|
||||||
|
| `"agc_data"` | Output | Message (PDU) | AGC channel data (ch 34/35/57) |
|
||||||
|
| `"raw_frame"` | Output | Message (PDU) | Full frame passthrough |
|
||||||
|
|
||||||
|
No streaming I/O. This is a message-only block.
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `output_format` | `str` | `"raw"` | `"raw"`, `"scaled"`, or `"engineering"` |
|
||||||
|
| `words_per_frame` | `int` | `128` | `128` (high rate) or `200` (low rate) |
|
||||||
|
|
||||||
|
#### Output PDU Formats
|
||||||
|
|
||||||
|
**`telemetry` port metadata:**
|
||||||
|
| Key | PMT Type | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `position` | `pmt.from_long` | 1-indexed word number |
|
||||||
|
| `raw_value` | `pmt.from_long` | 8-bit raw value |
|
||||||
|
| `voltage` | `pmt.from_double` | Scaled voltage (if format is `"scaled"`) |
|
||||||
|
|
||||||
|
**`agc_data` port metadata:**
|
||||||
|
| Key | PMT Type | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `channel` | `pmt.from_long` | AGC channel number (decimal) |
|
||||||
|
| `word_position` | `pmt.from_long` | 1-indexed frame word number |
|
||||||
|
| `raw_value` | `pmt.from_long` | 8-bit raw value |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DownlinkEngine` / `downlink_decoder`
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Pure Python Engine">
|
||||||
|
|
||||||
|
**Module:** `apollo.downlink_decoder`
|
||||||
|
**Type:** Pure-Python class
|
||||||
|
**Purpose:** Reassemble 15-bit AGC words from DNTM1/DNTM2 channel pairs and identify downlink list types.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.downlink_decoder import DownlinkEngine
|
||||||
|
|
||||||
|
engine = DownlinkEngine(buffer_size=400)
|
||||||
|
snapshot = engine.feed_agc_word(channel=28, raw_value=0x3F)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `buffer_size` | `int` | `400` | Number of 15-bit words per downlink buffer before finalization |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `feed_agc_word` | `(channel: int, raw_value: int) -> dict \| None` | Process one AGC channel byte. Returns a snapshot dict when a buffer fills, else `None` |
|
||||||
|
| `force_flush` | `() -> dict \| None` | Force-finalize the current buffer (for end-of-data). Returns snapshot or `None` if empty |
|
||||||
|
| `reset` | `() -> None` | Clear all internal state |
|
||||||
|
|
||||||
|
#### `feed_agc_word` Channel Behavior
|
||||||
|
|
||||||
|
| Channel | Decimal | Action |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| DNTM1 | 28 | Stores as pending high byte, waits for matching DNTM2 |
|
||||||
|
| DNTM2 | 29 | Combines with pending DNTM1 into a 15-bit word via `reassemble_agc_word` |
|
||||||
|
| OUTLINK | 47 | Appended to separate outlink buffer |
|
||||||
|
| Other | -- | Ignored, returns `None` |
|
||||||
|
|
||||||
|
#### Snapshot Dict
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| `list_type_id` | `int` | Downlink list type ID (lower 4 bits of first word) |
|
||||||
|
| `list_name` | `str` | Human-readable list name or `"Unknown (ID=N)"` |
|
||||||
|
| `word_count` | `int` | Number of 15-bit words in this snapshot |
|
||||||
|
| `words` | `list[int]` | The 15-bit AGC words |
|
||||||
|
| `outlink_data` | `list[int]` | Accumulated OUTLINK channel bytes |
|
||||||
|
|
||||||
|
#### Downlink List Types
|
||||||
|
|
||||||
|
| ID | Constant | Name |
|
||||||
|
|----|----------|------|
|
||||||
|
| 0 | `DL_CM_POWERED_LIST` | CM Powered Flight |
|
||||||
|
| 1 | `DL_LM_ORBITAL_MANEUVERS` | LM Orbital Maneuvers |
|
||||||
|
| 2 | `DL_CM_COAST_ALIGN` | CM Coast/Alignment |
|
||||||
|
| 3 | `DL_LM_COAST_ALIGN` | LM Coast/Alignment |
|
||||||
|
| 7 | `DL_LM_DESCENT_ASCENT` | LM Descent/Ascent |
|
||||||
|
| 8 | `DL_LM_LUNAR_SURFACE_ALIGN` | LM Lunar Surface Alignment |
|
||||||
|
| 9 | `DL_CM_ENTRY_UPDATE` | CM Entry Update |
|
||||||
|
|
||||||
|
#### Supporting Functions
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `reassemble_agc_word` | `(dntm1_byte: int, dntm2_byte: int) -> int` | Combine two 8-bit channel bytes into one 15-bit AGC word. DNTM1 provides bits 14-8 (lower 7 bits of byte), DNTM2 provides bits 7-0 |
|
||||||
|
| `identify_list_type` | `(first_word: int) -> tuple[int, str]` | Extract list type ID from lower 4 bits of first word. Returns `(id, name)` |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="GNU Radio Block">
|
||||||
|
|
||||||
|
**Module:** `apollo.downlink_decoder`
|
||||||
|
**Type:** `gr.basic_block`
|
||||||
|
**Purpose:** GNU Radio message-port wrapper. Receives AGC data PDUs from `pcm_demux`, emits decoded downlink snapshots.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.downlink_decoder import downlink_decoder
|
||||||
|
|
||||||
|
blk = downlink_decoder(buffer_size=400)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `"agc_data"` | Input | Message (PDU) | AGC channel data from `pcm_demux` |
|
||||||
|
| `"downlink"` | Output | Message (PDU) | Decoded downlink list snapshots |
|
||||||
|
|
||||||
|
No streaming I/O.
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `buffer_size` | `int` | `400` | Words per downlink buffer |
|
||||||
|
|
||||||
|
#### Output PDU Format
|
||||||
|
|
||||||
|
**Metadata dict:**
|
||||||
|
| Key | PMT Type | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `list_type_id` | `pmt.from_long` | Downlink list type ID |
|
||||||
|
| `list_name` | `pmt.intern` | Human-readable list name |
|
||||||
|
| `word_count` | `pmt.from_long` | Number of 15-bit words |
|
||||||
|
|
||||||
|
**Payload:** `pmt.init_u8vector` containing 15-bit words packed as big-endian byte pairs (2 bytes per word).
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice & Analog
|
||||||
|
|
||||||
|
### `voice_subcarrier_demod`
|
||||||
|
|
||||||
|
**Module:** `apollo.voice_subcarrier_demod`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Extract and demodulate the 1.25 MHz FM voice subcarrier to 300-3000 Hz audio.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||||
|
|
||||||
|
blk = voice_subcarrier_demod(sample_rate=5_120_000, audio_rate=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `float` | PM demodulator output (composite subcarrier signal) |
|
||||||
|
| `out0` | Output | `float` | Demodulated audio at `audio_rate` Hz |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
| `audio_rate` | `int` | `8000` | Target output audio sample rate in Hz |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `output_sample_rate` | `float` | Actual output sample rate (equals `audio_rate`) |
|
||||||
|
| `extracted_rate` | `float` | Intermediate rate after subcarrier extraction |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
input -> subcarrier_extract(1.25 MHz, BW=58 kHz, decimation=auto)
|
||||||
|
-> quadrature_demod_cf(gain)
|
||||||
|
-> band_pass_filter(300-3000 Hz)
|
||||||
|
-> rational_resampler_fff -> output
|
||||||
|
```
|
||||||
|
|
||||||
|
The decimation factor is computed automatically: `int(sample_rate / (58000 * 2.2))`. At the default 5.12 MHz input rate, this yields decimation=40 and an intermediate rate of 128 kHz. The FM discriminator gain is `extracted_rate / (2 * pi * 29000)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `sco_demod`
|
||||||
|
|
||||||
|
**Module:** `apollo.sco_demod`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Demodulate one of the 9 Subcarrier Oscillator channels to recover a 0-5V analog sensor reading. Valid in FM downlink mode only.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.sco_demod import sco_demod
|
||||||
|
|
||||||
|
blk = sco_demod(sco_number=5, sample_rate=5_120_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `float` | PM demodulator output (composite subcarrier signal) |
|
||||||
|
| `out0` | Output | `float` | Recovered sensor voltage (0.0 to 5.0 V) |
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `sco_number` | `int` | `1` | SCO channel number (1-9). See constants reference for frequencies |
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `center_freq` | `float` | Center frequency of the selected SCO channel (Hz) |
|
||||||
|
| `deviation_hz` | `float` | FM deviation in Hz (7.5% of center frequency) |
|
||||||
|
| `output_sample_rate` | `float` | Sample rate of the output stream |
|
||||||
|
|
||||||
|
#### Internal Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
input -> subcarrier_extract(sco_freq, BW=15% of center, decimation=auto)
|
||||||
|
-> quadrature_demod_cf(gain)
|
||||||
|
-> multiply_const_ff(2.5)
|
||||||
|
-> add_const_ff(2.5) -> output
|
||||||
|
```
|
||||||
|
|
||||||
|
The voltage mapping is linear: demod output of -1.0 maps to 0V, 0.0 maps to 2.5V, +1.0 maps to 5V.
|
||||||
|
|
||||||
|
#### Valid SCO Channels
|
||||||
|
|
||||||
|
| SCO Number | Center Frequency | Deviation (+/-) | Bandwidth (15%) |
|
||||||
|
|------------|-----------------|------------------|-----------------|
|
||||||
|
| 1 | 14,500 Hz | 1,087.5 Hz | 2,175 Hz |
|
||||||
|
| 2 | 22,000 Hz | 1,650 Hz | 3,300 Hz |
|
||||||
|
| 3 | 30,000 Hz | 2,250 Hz | 4,500 Hz |
|
||||||
|
| 4 | 40,000 Hz | 3,000 Hz | 6,000 Hz |
|
||||||
|
| 5 | 52,500 Hz | 3,937.5 Hz | 7,875 Hz |
|
||||||
|
| 6 | 70,000 Hz | 5,250 Hz | 10,500 Hz |
|
||||||
|
| 7 | 95,000 Hz | 7,125 Hz | 14,250 Hz |
|
||||||
|
| 8 | 125,000 Hz | 9,375 Hz | 18,750 Hz |
|
||||||
|
| 9 | 165,000 Hz | 12,375 Hz | 24,750 Hz |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AGC Integration
|
||||||
|
|
||||||
|
### `AGCBridgeClient` / `agc_bridge`
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Pure Python Client">
|
||||||
|
|
||||||
|
**Module:** `apollo.agc_bridge`
|
||||||
|
**Type:** Pure-Python class (threaded TCP client)
|
||||||
|
**Purpose:** Connect to a running Virtual AGC (yaAGC) emulator over TCP, receive telemetry packets, and send uplink commands.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.agc_bridge import AGCBridgeClient
|
||||||
|
|
||||||
|
def on_packet(channel, value):
|
||||||
|
print(f"Ch {channel}: {value}")
|
||||||
|
|
||||||
|
client = AGCBridgeClient(host="localhost", port=19697, on_packet=on_packet)
|
||||||
|
client.start()
|
||||||
|
# ... later ...
|
||||||
|
client.send(channel=37, value=0x1234)
|
||||||
|
client.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `host` | `str` | `"localhost"` | yaAGC hostname or IP address |
|
||||||
|
| `port` | `int` | `19697` | yaAGC TCP port |
|
||||||
|
| `channel_filter` | `frozenset[int] \| None` | `AGC_TELECOM_CHANNELS` | Set of channel numbers to pass through. `None` accepts all channels |
|
||||||
|
| `on_packet` | `Callable[[int, int], None] \| None` | `None` | Callback for received packets: `on_packet(channel, value)`. Called from the rx thread |
|
||||||
|
| `on_status` | `Callable[[str], None] \| None` | `None` | Callback for connection state changes: `on_status(state_str)`. States: `"disconnected"`, `"connecting"`, `"connected"` |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `start` | `() -> None` | Launch background receive thread. Auto-reconnects on disconnect with exponential backoff (0.5s to 30s) |
|
||||||
|
| `stop` | `() -> None` | Signal thread to stop, close socket, join thread (5s timeout) |
|
||||||
|
| `send` | `(channel: int, value: int) -> bool` | Send a 4-byte I/O packet to yaAGC. Returns `True` on success, `False` if not connected |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `state` | `str` | Current connection state: `"disconnected"`, `"connecting"`, or `"connected"` |
|
||||||
|
| `connected` | `bool` | `True` if state is `"connected"` |
|
||||||
|
|
||||||
|
#### Reconnection Behavior
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Base delay | 0.5 seconds |
|
||||||
|
| Max delay | 30.0 seconds |
|
||||||
|
| Backoff factor | 2.0x |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="GNU Radio Block">
|
||||||
|
|
||||||
|
**Module:** `apollo.agc_bridge`
|
||||||
|
**Type:** `gr.basic_block`
|
||||||
|
**Purpose:** GNU Radio wrapper bridging PDU message ports to a Virtual AGC TCP connection.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.agc_bridge import agc_bridge
|
||||||
|
|
||||||
|
blk = agc_bridge(host="localhost", port=19697)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `"uplink_data"` | Input | Message (PDU) | `pmt.cons(meta, pmt.cons(channel, value))` to send to AGC |
|
||||||
|
| `"downlink_data"` | Output | Message (PDU) | Received AGC packets as `pmt.cons(meta_dict, pmt.cons(channel, value))` |
|
||||||
|
| `"status"` | Output | Message | `pmt.intern(state_str)` on connection state changes |
|
||||||
|
|
||||||
|
No streaming I/O.
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `host` | `str` | `"localhost"` | yaAGC hostname |
|
||||||
|
| `port` | `int` | `19697` | yaAGC TCP port |
|
||||||
|
|
||||||
|
#### Lifecycle
|
||||||
|
|
||||||
|
The block calls `client.start()` on GR `start()` and `client.stop()` on GR `stop()`.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UplinkEncoder` / `uplink_encoder`
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Pure Python Encoder">
|
||||||
|
|
||||||
|
**Module:** `apollo.uplink_encoder`
|
||||||
|
**Type:** Pure-Python class
|
||||||
|
**Purpose:** Encode ground-station commands (VERB, NOUN, DATA, PROCEED) into AGC INLINK channel (045 octal) word sequences.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.uplink_encoder import UplinkEncoder
|
||||||
|
|
||||||
|
enc = UplinkEncoder()
|
||||||
|
pairs = enc.encode_verb_noun(verb=37, noun=1)
|
||||||
|
# Returns: [(37, 17408), (37, 1024), (37, 7168), ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `channel` | `int` | `37` | AGC I/O channel for uplink. Default is channel 045 octal (37 decimal, INLINK) |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `encode_keycode` | `(keycode: int) -> tuple[int, int]` | Encode a single DSKY keycode. Returns `(channel, value)` with keycode in bits 14-10 |
|
||||||
|
| `encode_digit` | `(digit: int) -> tuple[int, int]` | Encode a decimal digit 0-9 |
|
||||||
|
| `encode_verb` | `(verb_number: int) -> list[tuple[int, int]]` | Encode VERB + 2 digit keys. `verb_number` range: 0-99 |
|
||||||
|
| `encode_noun` | `(noun_number: int) -> list[tuple[int, int]]` | Encode NOUN + 2 digit keys. `noun_number` range: 0-99 |
|
||||||
|
| `encode_data` | `(value: int, signed: bool = True) -> list[tuple[int, int]]` | Encode a 5-digit data entry with optional sign. Range: -99999 to +99999 (signed) or 0 to 99999 (unsigned) |
|
||||||
|
| `encode_proceed` | `() -> list[tuple[int, int]]` | Encode a PROCEED (ENTER) keystroke |
|
||||||
|
| `encode_verb_noun` | `(verb: int, noun: int) -> list[tuple[int, int]]` | Convenience: full VERB + NOUN + ENTER sequence |
|
||||||
|
| `encode_command` | `(command_type: str, data: int \| None) -> list[tuple[int, int]]` | Dispatch by type string: `"VERB"`, `"NOUN"`, `"DATA"`, `"PROCEED"` |
|
||||||
|
|
||||||
|
#### DSKY Key Codes
|
||||||
|
|
||||||
|
| Key | Octal | Decimal | Constant |
|
||||||
|
|-----|-------|---------|----------|
|
||||||
|
| VERB | 021 | 17 | `KEYCODE_VERB` |
|
||||||
|
| NOUN | 037 | 31 | `KEYCODE_NOUN` |
|
||||||
|
| ENTER/PROCEED | 034 | 28 | `KEYCODE_ENTER` |
|
||||||
|
| RESET/KEY RELEASE | 022 | 18 | `KEYCODE_RESET` |
|
||||||
|
| CLEAR | 036 | 30 | `KEYCODE_CLEAR` |
|
||||||
|
| + | 032 | 26 | `KEYCODE_PLUS` |
|
||||||
|
| - | 033 | 27 | `KEYCODE_MINUS` |
|
||||||
|
| 0 | 020 | 16 | `KEYCODE_DIGITS[0]` |
|
||||||
|
| 1-7 | 001-007 | 1-7 | `KEYCODE_DIGITS[1-7]` |
|
||||||
|
| 8 | 010 | 8 | `KEYCODE_DIGITS[8]` |
|
||||||
|
| 9 | 011 | 9 | `KEYCODE_DIGITS[9]` |
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="GNU Radio Block">
|
||||||
|
|
||||||
|
**Module:** `apollo.uplink_encoder`
|
||||||
|
**Type:** `gr.basic_block`
|
||||||
|
**Purpose:** GNU Radio message-port wrapper. Receives command PDUs, emits individual uplink word PDUs.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.uplink_encoder import uplink_encoder
|
||||||
|
|
||||||
|
blk = uplink_encoder(channel=37)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `"command"` | Input | Message (PDU) | Command PDU with metadata dict |
|
||||||
|
| `"uplink_words"` | Output | Message (PDU) | One PDU per (channel, value) pair |
|
||||||
|
|
||||||
|
No streaming I/O.
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `channel` | `int` | `37` | AGC INLINK channel (045 octal) |
|
||||||
|
|
||||||
|
#### Input PDU Format
|
||||||
|
|
||||||
|
The input PDU metadata dict must contain:
|
||||||
|
|
||||||
|
| Key | PMT Type | Required | Description |
|
||||||
|
|-----|----------|----------|-------------|
|
||||||
|
| `"type"` | `pmt.intern` | Yes | `"VERB"`, `"NOUN"`, `"DATA"`, or `"PROCEED"` |
|
||||||
|
| `"data"` | `pmt.from_long` | For VERB/NOUN/DATA | Verb number, noun number, or data value |
|
||||||
|
|
||||||
|
#### Output PDU Format
|
||||||
|
|
||||||
|
Each output PDU contains:
|
||||||
|
|
||||||
|
**Metadata:**
|
||||||
|
| Key | PMT Type | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `"channel"` | `pmt.from_long` | AGC channel number |
|
||||||
|
| `"value"` | `pmt.from_long` | 15-bit value |
|
||||||
|
|
||||||
|
**Payload:** `pmt.cons(pmt.from_long(channel), pmt.from_long(value))`
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-Level
|
||||||
|
|
||||||
|
### `usb_downlink_receiver`
|
||||||
|
|
||||||
|
**Module:** `apollo.usb_downlink_receiver`
|
||||||
|
**Type:** `gr.hier_block2`
|
||||||
|
**Purpose:** Complete Apollo USB downlink receiver chain in a single block -- complex baseband input to telemetry PDU outputs.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||||
|
|
||||||
|
blk = usb_downlink_receiver(sample_rate=5_120_000, output_format="scaled")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### I/O Signature
|
||||||
|
|
||||||
|
| Port | Direction | Type | Description |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| `in0` | Input | `complex` (streaming) | Baseband IQ samples at `sample_rate` |
|
||||||
|
| `"frames"` | Output | Message (PDU) | Complete PCM frame PDUs (from frame sync) |
|
||||||
|
| `"telemetry"` | Output | Message (PDU) | Individual word PDUs with channel metadata |
|
||||||
|
| `"agc_data"` | Output | Message (PDU) | AGC channel data (ch 34/35/57) |
|
||||||
|
| `"raw_frame"` | Output | Message (PDU) | Full frame passthrough |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
This block has one streaming input and zero streaming outputs. All output is via message ports. The `"frames"` port receives PDUs from both the frame sync (directly) and via the demux forwarding path.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
#### Constructor Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
|
||||||
|
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high) or `1600` (low) |
|
||||||
|
| `carrier_pll_bw` | `float` | `0.02` | PM demodulator PLL bandwidth (rad/sample) |
|
||||||
|
| `subcarrier_bw` | `float` | `150000` | PCM subcarrier bandpass filter width (Hz) |
|
||||||
|
| `bpsk_loop_bw` | `float` | `0.045` | BPSK Costas loop bandwidth (rad/sample) |
|
||||||
|
| `max_bit_errors` | `int` | `3` | Frame sync Hamming distance threshold |
|
||||||
|
| `output_format` | `str` | `"raw"` | Demux output: `"raw"`, `"scaled"`, or `"engineering"` |
|
||||||
|
|
||||||
|
#### Internal Signal Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
complex in -> pm_demod -> subcarrier_extract(1.024 MHz)
|
||||||
|
-> bpsk_demod -> pcm_frame_sync
|
||||||
|
|
|
||||||
|
msg: "frames"
|
||||||
|
|
|
||||||
|
pcm_demux
|
||||||
|
/ | \
|
||||||
|
"telemetry" "agc_data" "raw_frame"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sub-Block Access
|
||||||
|
|
||||||
|
The internal blocks are exposed as instance attributes for runtime inspection or parameter adjustment:
|
||||||
|
|
||||||
|
| Attribute | Type | Block |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `self.pm` | `pm_demod` | PM demodulator |
|
||||||
|
| `self.sc_extract` | `subcarrier_extract` | Subcarrier extractor |
|
||||||
|
| `self.bpsk` | `bpsk_demod` | BPSK demodulator |
|
||||||
|
| `self.frame_sync` | `pcm_frame_sync` | Frame synchronizer |
|
||||||
|
| `self.demux` | `pcm_demux` | Frame demultiplexer |
|
||||||
310
docs/src/content/docs/reference/constants.mdx
Normal file
310
docs/src/content/docs/reference/constants.mdx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
---
|
||||||
|
title: "Constants & Parameters"
|
||||||
|
description: "Complete reference for all Apollo Unified S-Band system constants defined in gr-apollo"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
All constants are defined in `apollo.constants` and trace directly to the 1965 NAA Telecommunication Systems Study Guide (Course A-624) via the IMPLEMENTATION_SPEC.md section references noted in each table.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.constants import DOWNLINK_FREQ_HZ, PCM_HIGH_BIT_RATE, SCO_FREQUENCIES
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RF Carrier Frequencies
|
||||||
|
|
||||||
|
IMPL_SPEC section 2.1.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `DOWNLINK_FREQ_HZ` | 2,287,500,000 | Hz | Downlink carrier: spacecraft to ground (2287.5 MHz) |
|
||||||
|
| `UPLINK_FREQ_HZ` | 2,106,406,250 | Hz | Uplink carrier: ground to spacecraft (2106.40625 MHz) |
|
||||||
|
| `COHERENT_RATIO` | (240, 221) | -- | Coherent turnaround ratio: Tx = Rx x 240/221 |
|
||||||
|
| `VCO_REFERENCE_HZ` | 19,062,500 | Hz | Master oscillator reference (19.0625 MHz) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modulation Parameters
|
||||||
|
|
||||||
|
IMPL_SPEC section 2.3.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PM_PEAK_DEVIATION_RAD` | 0.133 | rad | Peak phase deviation (7.6 degrees) |
|
||||||
|
| `PM_SENSITIVITY_RAD_PER_V` | 0.033 | rad/V | PM sensitivity at 1 kHz |
|
||||||
|
| `FM_VCO_SENSITIVITY_HZ_PER_V` | 1,500,000 | Hz/V | FM VCO sensitivity (1.5 MHz peak / V peak) |
|
||||||
|
| `FM_MODULATION_BW_HZ` | 1,500,000 | Hz | FM modulation bandwidth (5 Hz to 1.5 MHz) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subcarrier Frequencies
|
||||||
|
|
||||||
|
IMPL_SPEC section 4.2.
|
||||||
|
|
||||||
|
### Downlink Subcarriers
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PCM_SUBCARRIER_HZ` | 1,024,000 | Hz | PCM telemetry subcarrier (1.024 MHz, BPSK modulated) |
|
||||||
|
| `VOICE_SUBCARRIER_HZ` | 1,250,000 | Hz | Voice subcarrier (1.25 MHz, FM modulated) |
|
||||||
|
| `EMERGENCY_KEY_HZ` | 512,000 | Hz | Emergency keyed carrier (512 kHz) |
|
||||||
|
|
||||||
|
### PCM Bandpass Filter
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PCM_BPF_LOW_HZ` | 949,000 | Hz | Lower edge of PCM bandpass filter |
|
||||||
|
| `PCM_BPF_HIGH_HZ` | 1,099,000 | Hz | Upper edge of PCM bandpass filter |
|
||||||
|
| `PCM_BPF_BW_HZ` | 150,000 | Hz | PCM bandpass filter bandwidth (derived: HIGH - LOW) |
|
||||||
|
|
||||||
|
### Voice Channel
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `VOICE_FM_DEVIATION_HZ` | 29,000 | Hz | Voice FM deviation (+/-29 kHz) |
|
||||||
|
| `VOICE_AUDIO_LOW_HZ` | 300 | Hz | Voice audio passband lower edge |
|
||||||
|
| `VOICE_AUDIO_HIGH_HZ` | 3,000 | Hz | Voice audio passband upper edge |
|
||||||
|
|
||||||
|
### Uplink Subcarriers
|
||||||
|
|
||||||
|
IMPL_SPEC section 2.2.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `UPLINK_VOICE_SUBCARRIER_HZ` | 30,000 | Hz | Uplink voice subcarrier (30 kHz FM) |
|
||||||
|
| `UPLINK_DATA_SUBCARRIER_HZ` | 70,000 | Hz | Uplink data subcarrier (70 kHz FM) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Master Clock & Timing
|
||||||
|
|
||||||
|
IMPL_SPEC section 5.5.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `MASTER_CLOCK_HZ` | 512,000 | Hz | CTE master clock (512 kHz). All timing derived from this |
|
||||||
|
|
||||||
|
The master clock divides to produce both bit rates:
|
||||||
|
- High rate: 512 kHz / 10 = 51.2 kHz
|
||||||
|
- Low rate: 512 kHz / 320 = 1.6 kHz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PCM Telemetry Parameters
|
||||||
|
|
||||||
|
IMPL_SPEC sections 5.1, 5.2.
|
||||||
|
|
||||||
|
### High Bit Rate
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PCM_HIGH_BIT_RATE` | 51,200 | bps | High bit rate (51.2 kbps NRZ, MSB first) |
|
||||||
|
| `PCM_HIGH_CLOCK_DIVIDER` | 10 | -- | Master clock divisor: 512 kHz / 10 |
|
||||||
|
| `PCM_HIGH_WORD_RATE` | 6,400 | words/s | Word rate at high bit rate |
|
||||||
|
| `PCM_HIGH_WORDS_PER_FRAME` | 128 | words | Words per frame at high rate |
|
||||||
|
| `PCM_HIGH_FRAMES_PER_SEC` | 50 | fps | Frame rate at high bit rate |
|
||||||
|
| `PCM_HIGH_FRAME_PERIOD_US` | 19,968 | us | Frame period in microseconds |
|
||||||
|
|
||||||
|
### Low Bit Rate
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PCM_LOW_BIT_RATE` | 1,600 | bps | Low bit rate (1.6 kbps) |
|
||||||
|
| `PCM_LOW_CLOCK_DIVIDER` | 320 | -- | Master clock divisor: 512 kHz / 320 |
|
||||||
|
| `PCM_LOW_WORD_RATE` | 200 | words/s | Word rate at low bit rate |
|
||||||
|
| `PCM_LOW_WORDS_PER_FRAME` | 200 | words | Words per frame at low rate |
|
||||||
|
| `PCM_LOW_FRAMES_PER_SEC` | 1 | fps | Frame rate at low bit rate |
|
||||||
|
|
||||||
|
### Frame Structure
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `PCM_WORD_LENGTH` | 8 | bits | Bits per telemetry word |
|
||||||
|
| `PCM_SYNC_WORD_LENGTH` | 32 | bits | Sync pattern length (4 words) |
|
||||||
|
| `PCM_SYNC_A_LENGTH` | 5 | bits | Selectable A field width |
|
||||||
|
| `PCM_SYNC_CORE_LENGTH` | 15 | bits | Fixed core pattern width (complemented on odd frames) |
|
||||||
|
| `PCM_SYNC_B_LENGTH` | 6 | bits | Selectable B field width |
|
||||||
|
| `PCM_SYNC_FRAME_ID_LENGTH` | 6 | bits | Frame ID field width (encodes 1-50) |
|
||||||
|
|
||||||
|
### Subframe Timing
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `SUBFRAME_FRAMES` | 50 | frames | Frames per subframe (high rate) |
|
||||||
|
| `SUBFRAME_PERIOD_S` | 1.0 | s | Subframe period |
|
||||||
|
|
||||||
|
### Default Sync Word Fields
|
||||||
|
|
||||||
|
These are the patchboard-configurable values used as defaults. On real hardware, these were set by wiring jumpers on the PCM encoder.
|
||||||
|
|
||||||
|
| Constant | Value (binary) | Value (decimal) | Description |
|
||||||
|
|----------|---------------|-----------------|-------------|
|
||||||
|
| `DEFAULT_SYNC_A` | `10101` | 21 | 5-bit A field |
|
||||||
|
| `DEFAULT_SYNC_CORE` | `111001101011100` | 29404 | 15-bit fixed core (even-frame value) |
|
||||||
|
| `DEFAULT_SYNC_B` | `110100` | 52 | 6-bit B field |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The sync word bit layout is `[5-bit A][15-bit core][6-bit B][6-bit frame_id]` = 32 bits total. The 15-bit core is bitwise-complemented on odd-numbered frames. See the protocol reference for generation and parsing functions.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A/D Converter (Coder)
|
||||||
|
|
||||||
|
IMPL_SPEC section 5.3.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `ADC_BITS` | 8 | bits | ADC resolution |
|
||||||
|
| `ADC_ZERO_CODE` | 1 | -- | Code for 0V input (`00000001`) |
|
||||||
|
| `ADC_FULLSCALE_CODE` | 254 | -- | Code for 4.98V input (`11111110`) |
|
||||||
|
| `ADC_OVERFLOW_CODE` | 255 | -- | Code for >5V input (`11111111`) |
|
||||||
|
| `ADC_FULLSCALE_VOLTAGE` | 4.98 | V | Full-scale input voltage |
|
||||||
|
| `ADC_STEP_MV` | 19.7 | mV | Voltage per least-significant bit |
|
||||||
|
| `ADC_LOW_LEVEL_GAIN` | 125 | -- | Gain multiplier for low-level inputs (0-40 mV range) |
|
||||||
|
|
||||||
|
The conversion formula is:
|
||||||
|
|
||||||
|
```
|
||||||
|
voltage = (code - 1) * 4.98 / 253
|
||||||
|
```
|
||||||
|
|
||||||
|
For low-level inputs (0-40 mV), the amplifier applies x125 gain before digitization. To recover the actual input voltage, divide by 125.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subcarrier Oscillators (FM Mode)
|
||||||
|
|
||||||
|
IMPL_SPEC section 4.3. These 9 SCO channels are present only in FM downlink mode.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `SCO_DEVIATION_PERCENT` | 7.5 | % | Deviation as percentage of center frequency (+/-) |
|
||||||
|
| `SCO_INPUT_RANGE_V` | (0.0, 5.0) | V | DC input voltage range |
|
||||||
|
| `SCO_OUTPUT_LEVEL_V` | 0.707 | V | Peak output level into 5.11 kOhm |
|
||||||
|
|
||||||
|
### SCO Channel Frequencies
|
||||||
|
|
||||||
|
`SCO_FREQUENCIES` is a dict mapping channel number to center frequency:
|
||||||
|
|
||||||
|
| SCO Channel | Center Frequency (Hz) | Deviation +/- (Hz) | Low Freq (Hz) | High Freq (Hz) |
|
||||||
|
|-------------|----------------------|---------------------|---------------|-----------------|
|
||||||
|
| 1 | 14,500 | 1,087.5 | 13,412.5 | 15,587.5 |
|
||||||
|
| 2 | 22,000 | 1,650.0 | 20,350.0 | 23,650.0 |
|
||||||
|
| 3 | 30,000 | 2,250.0 | 27,750.0 | 32,250.0 |
|
||||||
|
| 4 | 40,000 | 3,000.0 | 37,000.0 | 43,000.0 |
|
||||||
|
| 5 | 52,500 | 3,937.5 | 48,562.5 | 56,437.5 |
|
||||||
|
| 6 | 70,000 | 5,250.0 | 64,750.0 | 75,250.0 |
|
||||||
|
| 7 | 95,000 | 7,125.0 | 87,875.0 | 102,125.0 |
|
||||||
|
| 8 | 125,000 | 9,375.0 | 115,625.0 | 134,375.0 |
|
||||||
|
| 9 | 165,000 | 12,375.0 | 152,625.0 | 177,375.0 |
|
||||||
|
|
||||||
|
All SCOs map 0-5V DC input linearly to the frequency deviation range. 0V corresponds to the low frequency limit, 2.5V to the center, and 5V to the high frequency limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Virtual AGC Interface
|
||||||
|
|
||||||
|
IMPL_SPEC section 1.
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `AGC_PORT_BASE` | 19697 | -- | TCP port base for yaAGC socket connections |
|
||||||
|
| `AGC_MAX_CLIENTS` | 10 | -- | Maximum concurrent client connections |
|
||||||
|
|
||||||
|
### AGC I/O Channels
|
||||||
|
|
||||||
|
Channels are defined in octal in the original AGC documentation. The constants store decimal equivalents.
|
||||||
|
|
||||||
|
| Constant | Octal | Decimal | Description |
|
||||||
|
|----------|-------|---------|-------------|
|
||||||
|
| `AGC_CH_INLINK` | 045 | 37 | Uplink data input (ground to AGC) |
|
||||||
|
| `AGC_CH_OUTLINK` | 057 | 47 | Downlink data output (AGC to ground) |
|
||||||
|
| `AGC_CH_DNTM1` | 034 | 28 | Telemetry word 1 (high byte) |
|
||||||
|
| `AGC_CH_DNTM2` | 035 | 29 | Telemetry word 2 (low byte) |
|
||||||
|
| `AGC_CH_OUT0` | 010 | 8 | Relay rows |
|
||||||
|
| `AGC_CH_DSALMOUT` | 011 | 9 | DSKY alarms |
|
||||||
|
| `AGC_CH_CHAN13` | 013 | 11 | Radar activity |
|
||||||
|
| `AGC_CH_CHAN30` | 030 | 24 | Status/alarm bits |
|
||||||
|
| `AGC_CH_CHAN33` | 033 | 27 | AGC warning input |
|
||||||
|
|
||||||
|
### Telecom Channel Set
|
||||||
|
|
||||||
|
`AGC_TELECOM_CHANNELS` is a `frozenset` containing the four primary telecom channels used by the bridge filter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
AGC_TELECOM_CHANNELS = frozenset({
|
||||||
|
AGC_CH_INLINK, # 37 (045 octal)
|
||||||
|
AGC_CH_OUTLINK, # 47 (057 octal)
|
||||||
|
AGC_CH_DNTM1, # 28 (034 octal)
|
||||||
|
AGC_CH_DNTM2, # 29 (035 octal)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Downlink Buffer
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `AGC_DOWNLINK_BUFFER_WORDS` | 400 | 15-bit words | Size of one complete downlink data snapshot |
|
||||||
|
|
||||||
|
### Downlink List Type IDs
|
||||||
|
|
||||||
|
From `DecodeDigitalDownlink.c` in the Virtual AGC project:
|
||||||
|
|
||||||
|
| Constant | Value | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `DL_CM_POWERED_LIST` | 0 | CM Powered Flight |
|
||||||
|
| `DL_LM_ORBITAL_MANEUVERS` | 1 | LM Orbital Maneuvers |
|
||||||
|
| `DL_CM_COAST_ALIGN` | 2 | CM Coast/Alignment |
|
||||||
|
| `DL_LM_COAST_ALIGN` | 3 | LM Coast/Alignment |
|
||||||
|
| `DL_LM_DESCENT_ASCENT` | 7 | LM Descent/Ascent |
|
||||||
|
| `DL_LM_LUNAR_SURFACE_ALIGN` | 8 | LM Lunar Surface Alignment |
|
||||||
|
| `DL_CM_ENTRY_UPDATE` | 9 | CM Entry Update |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Sample Rates
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `SAMPLE_RATE_BASEBAND` | 5,120,000 | Hz | Baseband rate: 10x master clock (5.12 MHz) |
|
||||||
|
| `SAMPLE_RATE_RF` | 10,240,000 | Hz | RF rate: 20x master clock (10.24 MHz) |
|
||||||
|
|
||||||
|
Both rates are integer multiples of the 512 kHz master clock, ensuring clean relationships with all PCM timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receiver PLL Parameters
|
||||||
|
|
||||||
|
IMPL_SPEC section 2.2.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `RX_PLL_BW_HZ` | 318 | Hz | PLL bandwidth at threshold |
|
||||||
|
| `RX_STATIC_PHASE_ERROR_DEG` | 6.0 | degrees | Maximum static phase error |
|
||||||
|
| `RX_AGC_RANGE_DB` | 80 | dB | AGC dynamic range (-132 to -52 dBm) |
|
||||||
|
| `RX_AGC_TIME_CONSTANT_S` | 5.7 | s | AGC time constant |
|
||||||
|
| `RX_THRESHOLD_DBM` | -132.5 | dBm | Receiver sensitivity threshold |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transmitter Parameters
|
||||||
|
|
||||||
|
IMPL_SPEC section 2.3 and 3.1.
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `TX_POWER_MW` | 300 | mW | Transmitter power (250-400 mW typical) |
|
||||||
|
| `TX_IMPEDANCE_OHM` | 50 | Ohm | Output impedance |
|
||||||
|
|
||||||
|
### Traveling Wave Tube (TWT) Amplifier
|
||||||
|
|
||||||
|
| Constant | Value | Unit | Description |
|
||||||
|
|----------|-------|------|-------------|
|
||||||
|
| `TWT_LOW_POWER_W` | 5 | W | TWT low power mode |
|
||||||
|
| `TWT_HIGH_POWER_W` | 20 | W | TWT high power mode |
|
||||||
|
| `TWT_WARMUP_S` | 90 | s | TWT warmup time |
|
||||||
506
docs/src/content/docs/reference/protocol.mdx
Normal file
506
docs/src/content/docs/reference/protocol.mdx
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
---
|
||||||
|
title: "Protocol Specification"
|
||||||
|
description: "Complete reference for PCM sync word generation, Virtual AGC socket protocol, and A/D conversion utilities"
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
The `apollo.protocol` module provides three categories of utility functions:
|
||||||
|
|
||||||
|
1. **Sync word functions** -- generate, parse, and convert the 32-bit PCM frame sync pattern
|
||||||
|
2. **AGC I/O packet functions** -- encode and decode the 4-byte Virtual AGC socket protocol
|
||||||
|
3. **A/D conversion functions** -- translate between 8-bit ADC codes and voltages
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import (
|
||||||
|
generate_sync_word, parse_sync_word, sync_word_to_bytes,
|
||||||
|
sync_word_to_bits, bits_to_sync_word,
|
||||||
|
form_io_packet, parse_io_packet,
|
||||||
|
adc_to_voltage, voltage_to_adc,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Word Functions
|
||||||
|
|
||||||
|
The PCM frame sync word is a 32-bit pattern (4 words) at the start of every telemetry frame. Its bit layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bit 31 27 26 12 11 6 5 0
|
||||||
|
┌──────────────┬──────────────┬───────────┬───────────┐
|
||||||
|
│ A field (5) │ Core (15) │ B field(6)│Frame ID(6)│
|
||||||
|
└──────────────┴──────────────┴───────────┴───────────┘
|
||||||
|
MSB LSB
|
||||||
|
```
|
||||||
|
|
||||||
|
The 15-bit core is bitwise-complemented on odd-numbered frames, providing a built-in frame parity indicator.
|
||||||
|
|
||||||
|
### `generate_sync_word`
|
||||||
|
|
||||||
|
Generate a 32-bit PCM frame sync word as an integer.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import generate_sync_word
|
||||||
|
|
||||||
|
word = generate_sync_word(frame_id=1, odd=False)
|
||||||
|
# Returns: 0xACEB4001 (with default field values)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_sync_word(
|
||||||
|
frame_id: int,
|
||||||
|
odd: bool = False,
|
||||||
|
a_bits: int = DEFAULT_SYNC_A, # 0b10101 = 21
|
||||||
|
core: int = DEFAULT_SYNC_CORE, # 0b111001101011100 = 29404
|
||||||
|
b_bits: int = DEFAULT_SYNC_B, # 0b110100 = 52
|
||||||
|
) -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `frame_id` | `int` | -- (required) | Frame number within the subframe. Range: 1-50 |
|
||||||
|
| `odd` | `bool` | `False` | If `True`, the 15-bit core is bitwise complemented |
|
||||||
|
| `a_bits` | `int` | `0b10101` (21) | 5-bit patchboard-selectable A field. Only lower 5 bits used |
|
||||||
|
| `core` | `int` | `0b111001101011100` (29404) | 15-bit fixed core pattern (even-frame value). Only lower 15 bits used |
|
||||||
|
| `b_bits` | `int` | `0b110100` (52) | 6-bit patchboard-selectable B field. Only lower 6 bits used |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
A 32-bit integer: `(a << 27) | (core << 12) | (b << 6) | frame_id`
|
||||||
|
|
||||||
|
When `odd=True`, the core value is replaced with `(~core) & 0x7FFF` before assembly.
|
||||||
|
|
||||||
|
#### Raises
|
||||||
|
|
||||||
|
- `ValueError` if `frame_id` is not in range 1-50.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `parse_sync_word`
|
||||||
|
|
||||||
|
Parse a 32-bit sync word integer into its component fields.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import parse_sync_word
|
||||||
|
|
||||||
|
fields = parse_sync_word(0xACEB4001)
|
||||||
|
# Returns: {'a_bits': 21, 'core': 29404, 'b_bits': 52, 'frame_id': 1, 'word': 0xACEB4001}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_sync_word(word: int) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `word` | `int` | 32-bit sync word value |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
| Key | Type | Bit Range | Description |
|
||||||
|
|-----|------|-----------|-------------|
|
||||||
|
| `a_bits` | `int` | 31-27 | 5-bit A field |
|
||||||
|
| `core` | `int` | 26-12 | 15-bit core (as-is, not un-complemented) |
|
||||||
|
| `b_bits` | `int` | 11-6 | 6-bit B field |
|
||||||
|
| `frame_id` | `int` | 5-0 | 6-bit frame ID |
|
||||||
|
| `word` | `int` | -- | Original 32-bit word (pass-through) |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The `core` field is returned as extracted from the word. For odd frames, this will be the complemented value. To recover the original even-frame core, compute `(~core) & 0x7FFF`.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `sync_word_to_bytes`
|
||||||
|
|
||||||
|
Convert a 32-bit sync word to 4 bytes, MSB first (matching NRZ serial output order).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import sync_word_to_bytes
|
||||||
|
|
||||||
|
raw = sync_word_to_bytes(0xACEB4001)
|
||||||
|
# Returns: b'\xac\xeb\x40\x01'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def sync_word_to_bytes(word: int) -> bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `word` | `int` | 32-bit sync word value |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
4 bytes in big-endian (MSB first) order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `sync_word_to_bits`
|
||||||
|
|
||||||
|
Convert a 32-bit sync word to a list of 32 individual bit values, MSB first.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import sync_word_to_bits
|
||||||
|
|
||||||
|
bits = sync_word_to_bits(0xACEB4001)
|
||||||
|
# Returns: [1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 1, ...] (32 elements)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def sync_word_to_bits(word: int) -> list[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `word` | `int` | 32-bit sync word value |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
List of 32 integers (each 0 or 1), bit 31 first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `bits_to_sync_word`
|
||||||
|
|
||||||
|
Convert a list of 32 bit values back to a 32-bit integer. Inverse of `sync_word_to_bits`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import bits_to_sync_word
|
||||||
|
|
||||||
|
word = bits_to_sync_word([1, 0, 1, 0, ...]) # 32 bits
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def bits_to_sync_word(bits: list[int]) -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `bits` | `list[int]` | Exactly 32 bit values (0 or 1), MSB first |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
32-bit integer.
|
||||||
|
|
||||||
|
#### Raises
|
||||||
|
|
||||||
|
- `ValueError` if `len(bits) != 32`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Virtual AGC I/O Packet Protocol
|
||||||
|
|
||||||
|
The Apollo Guidance Computer emulator (yaAGC) communicates over TCP using a 4-byte packet format. Each packet carries one I/O channel update: a 9-bit channel number and a 15-bit data value.
|
||||||
|
|
||||||
|
These functions are direct ports of `FormIoPacket()` and `ParseIoPacket()` from `yaAGC/SocketAPI.c`.
|
||||||
|
|
||||||
|
### Packet Bit Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Byte 0 Byte 1 Byte 2 Byte 3
|
||||||
|
7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0
|
||||||
|
┌─┬─┬─────────┐ ┌─┬─┬─────┬─────┐ ┌─┬─┬───────────┐ ┌─┬─┬───────────┐
|
||||||
|
│0│0│Ch[8:3] │ │0│1│Ch[2:0]│V[14:12]│ │1│0│ V[11:6] │ │1│1│ V[5:0] │
|
||||||
|
└─┴─┴─────────┘ └─┴─┴─────┴─────┘ └─┴─┴───────────┘ └─┴─┴───────────┘
|
||||||
|
sig=0x00 sig=0x40 sig=0x80 sig=0xC0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Byte | Signature (bits 7-6) | Data Bits | Content |
|
||||||
|
|------|---------------------|-----------|---------|
|
||||||
|
| 0 | `00` | bits 5-0 | Channel bits 8-3 |
|
||||||
|
| 1 | `01` | bits 5-3: channel 2-0, bits 2-0: value 14-12 | Channel low bits + value high bits |
|
||||||
|
| 2 | `10` | bits 5-0 | Value bits 11-6 |
|
||||||
|
| 3 | `11` | bits 5-0 | Value bits 5-0 |
|
||||||
|
|
||||||
|
The 2-bit signature prefix on each byte enables packet resynchronization after data loss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `form_io_packet`
|
||||||
|
|
||||||
|
Encode a channel/value pair into a 4-byte Virtual AGC I/O packet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import form_io_packet
|
||||||
|
|
||||||
|
packet = form_io_packet(channel=0o45, value=0x1234)
|
||||||
|
# Returns: b'\x04\x49\x88\xf4' (4 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def form_io_packet(channel: int, value: int, u_bit: bool = False) -> bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Range | Description |
|
||||||
|
|-----------|------|---------|-------|-------------|
|
||||||
|
| `channel` | `int` | -- (required) | 0-511 (9 bits) | I/O channel number. Values beyond 9 bits are masked |
|
||||||
|
| `value` | `int` | -- (required) | 0-32767 (15 bits) | Data value. Values beyond 15 bits are masked |
|
||||||
|
| `u_bit` | `bool` | `False` | -- | If `True`, sets bit 5 in byte 3 data field, marking this as a mask update rather than data. This is a yaAGC extension |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
4 bytes following the packet format above.
|
||||||
|
|
||||||
|
#### Encoding Details
|
||||||
|
|
||||||
|
```
|
||||||
|
b0 = (channel >> 3) & 0x3F
|
||||||
|
b1 = 0x40 | ((channel & 0x07) << 3) | ((value >> 12) & 0x07)
|
||||||
|
b2 = 0x80 | ((value >> 6) & 0x3F)
|
||||||
|
b3 = 0xC0 | (value & 0x3F)
|
||||||
|
if u_bit: b3 |= 0x20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `parse_io_packet`
|
||||||
|
|
||||||
|
Decode a 4-byte Virtual AGC I/O packet into channel, value, and u_bit.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import parse_io_packet
|
||||||
|
|
||||||
|
channel, value, u_bit = parse_io_packet(b'\x04\x49\x88\xf4')
|
||||||
|
# Returns: (37, 4660, False)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_io_packet(packet: bytes) -> tuple[int, int, bool]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `packet` | `bytes` | Exactly 4 bytes |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
A tuple of:
|
||||||
|
|
||||||
|
| Index | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| 0 | `int` | Channel number (0-511) |
|
||||||
|
| 1 | `int` | Data value (0-32767) |
|
||||||
|
| 2 | `bool` | u_bit flag (always `False` in current implementation -- standard data packets) |
|
||||||
|
|
||||||
|
#### Raises
|
||||||
|
|
||||||
|
- `ValueError` if `len(packet) != 4`
|
||||||
|
- `ValueError` if any byte has an invalid signature prefix (bits 7-6 must follow the `00`, `01`, `10`, `11` sequence)
|
||||||
|
|
||||||
|
#### Decoding Details
|
||||||
|
|
||||||
|
```
|
||||||
|
channel = ((b0 & 0x3F) << 3) | ((b1 >> 3) & 0x07)
|
||||||
|
value = ((b1 & 0x07) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AGC Telecom Channel Reference
|
||||||
|
|
||||||
|
Complete table of AGC I/O channels relevant to the telecommunications system:
|
||||||
|
|
||||||
|
| Octal | Decimal | Constant | Direction | Purpose |
|
||||||
|
|-------|---------|----------|-----------|---------|
|
||||||
|
| 010 | 8 | `AGC_CH_OUT0` | Output | Relay rows |
|
||||||
|
| 011 | 9 | `AGC_CH_DSALMOUT` | Output | DSKY alarm indicators |
|
||||||
|
| 013 | 11 | `AGC_CH_CHAN13` | Output | Radar activity |
|
||||||
|
| 030 | 24 | `AGC_CH_CHAN30` | Input | Status and alarm bits |
|
||||||
|
| 033 | 27 | `AGC_CH_CHAN33` | Input | AGC warning input |
|
||||||
|
| 034 | 28 | `AGC_CH_DNTM1` | Output | Downlink telemetry word 1 (high 7 bits) |
|
||||||
|
| 035 | 29 | `AGC_CH_DNTM2` | Output | Downlink telemetry word 2 (low 8 bits) |
|
||||||
|
| 045 | 37 | `AGC_CH_INLINK` | Input | Uplink data from ground (triggers UPRUPT) |
|
||||||
|
| 057 | 47 | `AGC_CH_OUTLINK` | Output | Digital downlink data |
|
||||||
|
|
||||||
|
The `AGC_TELECOM_CHANNELS` frozenset contains channels 034, 035, 045, and 057 (the four primary telecom channels). The `AGCBridgeClient` uses this set as its default channel filter.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The AGC uses 15-bit words internally. Telemetry channels 034 and 035 together carry one 15-bit word: DNTM1 provides bits 14-8 (high 7 bits in the lower 7 bits of its byte) and DNTM2 provides bits 7-0. The `DownlinkEngine.feed_agc_word` method handles this reassembly automatically.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A/D Conversion Functions
|
||||||
|
|
||||||
|
The Apollo PCM encoder uses an 8-bit analog-to-digital converter with a non-standard code mapping. Per IMPL_SPEC section 5.3:
|
||||||
|
|
||||||
|
| Input Voltage | ADC Code | Binary |
|
||||||
|
|---------------|----------|--------|
|
||||||
|
| Below range | 0 | `00000000` |
|
||||||
|
| 0V | 1 | `00000001` |
|
||||||
|
| 4.98V (full scale) | 254 | `11111110` |
|
||||||
|
| >5V (overflow) | 255 | `11111111` |
|
||||||
|
|
||||||
|
Step size: 4.98V / 253 = 19.7 mV per LSB.
|
||||||
|
|
||||||
|
For low-level analog inputs (0-40 mV range), a x125 gain amplifier is applied before the ADC. The conversion functions handle this gain transparently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `adc_to_voltage`
|
||||||
|
|
||||||
|
Convert an 8-bit ADC code to a voltage.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import adc_to_voltage
|
||||||
|
|
||||||
|
v = adc_to_voltage(128)
|
||||||
|
# Returns: 2.4980... (approximately 2.5V, midscale)
|
||||||
|
|
||||||
|
v_low = adc_to_voltage(128, low_level=True)
|
||||||
|
# Returns: 0.01998... (approximately 20 mV, after removing x125 gain)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def adc_to_voltage(code: int, low_level: bool = False) -> float
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `code` | `int` | -- (required) | 8-bit ADC value (0-255) |
|
||||||
|
| `low_level` | `bool` | `False` | If `True`, divide result by 125 to recover actual input voltage for low-level (0-40 mV) channels |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
Voltage in volts (float).
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
|
||||||
|
| Code | Returns | Reason |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 0 | 0.0 | Below range |
|
||||||
|
| ≥ 255 | 5.0 | Overflow |
|
||||||
|
| 1-254 | `(code - 1) * 4.98 / 253` | Normal conversion |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `voltage_to_adc`
|
||||||
|
|
||||||
|
Convert a voltage to an 8-bit ADC code. Inverse of `adc_to_voltage`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import voltage_to_adc
|
||||||
|
|
||||||
|
code = voltage_to_adc(2.5)
|
||||||
|
# Returns: 128
|
||||||
|
|
||||||
|
code = voltage_to_adc(0.020, low_level=True)
|
||||||
|
# Returns: 128 (0.020V * 125 = 2.5V internal)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def voltage_to_adc(voltage: float, low_level: bool = False) -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `voltage` | `float` | -- (required) | Input voltage in volts |
|
||||||
|
| `low_level` | `bool` | `False` | If `True`, apply x125 gain before conversion (for 0-40 mV inputs) |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
8-bit ADC code (int), clamped to range 1-254.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
|
||||||
|
| Voltage | Returns | Reason |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| ≤ 0.0 | 1 | Zero code |
|
||||||
|
| ≥ 4.98 | 254 | Full-scale code |
|
||||||
|
| Between | `round(voltage * 253 / 4.98) + 1` | Normal conversion, clamped to 1-254 |
|
||||||
|
|
||||||
|
#### Conversion Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
If low_level: voltage = voltage * 125
|
||||||
|
code = round(voltage * 253 / 4.98) + 1
|
||||||
|
code = clamp(code, 1, 254)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Patterns
|
||||||
|
|
||||||
|
### Round-Trip Sync Word Test
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import generate_sync_word, parse_sync_word, sync_word_to_bits, bits_to_sync_word
|
||||||
|
|
||||||
|
# Generate even frame 1
|
||||||
|
word = generate_sync_word(frame_id=1, odd=False)
|
||||||
|
fields = parse_sync_word(word)
|
||||||
|
assert fields["frame_id"] == 1
|
||||||
|
|
||||||
|
# Round-trip through bits
|
||||||
|
bits = sync_word_to_bits(word)
|
||||||
|
assert len(bits) == 32
|
||||||
|
recovered = bits_to_sync_word(bits)
|
||||||
|
assert recovered == word
|
||||||
|
```
|
||||||
|
|
||||||
|
### AGC Packet Round-Trip
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import form_io_packet, parse_io_packet
|
||||||
|
|
||||||
|
# Encode an uplink command to INLINK channel
|
||||||
|
packet = form_io_packet(channel=0o45, value=0x5A00)
|
||||||
|
channel, value, u_bit = parse_io_packet(packet)
|
||||||
|
assert channel == 0o45 # 37 decimal
|
||||||
|
assert value == 0x5A00
|
||||||
|
```
|
||||||
|
|
||||||
|
### ADC Voltage Scaling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apollo.protocol import adc_to_voltage, voltage_to_adc
|
||||||
|
|
||||||
|
# Standard high-level channel
|
||||||
|
for code in [1, 64, 128, 192, 254]:
|
||||||
|
v = adc_to_voltage(code)
|
||||||
|
back = voltage_to_adc(v)
|
||||||
|
assert abs(back - code) <= 1 # rounding tolerance
|
||||||
|
|
||||||
|
# Low-level channel (0-40 mV)
|
||||||
|
v = adc_to_voltage(128, low_level=True) # ~20 mV
|
||||||
|
code = voltage_to_adc(v, low_level=True)
|
||||||
|
assert code == 128
|
||||||
|
```
|
||||||
5
docs/tsconfig.json
Normal file
5
docs/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user