Compare commits
No commits in common. "50060e48e9fcc32e6870de6908d5e3523d6328d7" and "12fb284d5fafe18ef202741dce879c2e38324a35" have entirely different histories.
50060e48e9
...
12fb284d5f
@ -1,8 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
Makefile
|
||||
docker-compose.yml
|
||||
@ -1,2 +0,0 @@
|
||||
COMPOSE_PROJECT=gr-apollo-docs
|
||||
DOMAIN=gr-apollo.l.warehack.ing
|
||||
@ -1,7 +0,0 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
file_server
|
||||
encode gzip zstd
|
||||
header Cache-Control "public, max-age=3600"
|
||||
try_files {path} {path}/ /404.html
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build static site
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Serve with Caddy
|
||||
FROM caddy:2-alpine
|
||||
COPY --from=build /app/dist /srv
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
EXPOSE 80
|
||||
@ -1,18 +0,0 @@
|
||||
.PHONY: up down logs restart dev
|
||||
|
||||
up:
|
||||
docker compose --profile prod up -d --build
|
||||
|
||||
down:
|
||||
docker compose --profile prod down
|
||||
docker compose --profile dev down
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
restart:
|
||||
docker compose --profile prod down
|
||||
docker compose --profile prod up -d --build
|
||||
|
||||
dev:
|
||||
docker compose --profile dev up --build
|
||||
@ -1,22 +1,16 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import icon from 'astro-icon';
|
||||
import rehypeMermaid from 'rehype-mermaid';
|
||||
import opengraphImages from 'astro-opengraph-images';
|
||||
import { render as ogRender } from './src/og-image.tsx';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const interRegular = fs.readFileSync('node_modules/@fontsource/inter/files/inter-latin-400-normal.woff');
|
||||
const interBold = fs.readFileSync('node_modules/@fontsource/inter/files/inter-latin-700-normal.woff');
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://gr-apollo.l.warehack.ing',
|
||||
integrations: [
|
||||
icon(),
|
||||
starlight({
|
||||
title: 'gr-apollo',
|
||||
description: 'Apollo Unified S-Band transmitter and decoder for GNU Radio 3.10+',
|
||||
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',
|
||||
@ -39,10 +33,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ label: 'Tune Demodulator Parameters', slug: 'guides/tuning-parameters' },
|
||||
{ label: 'Generate Test Signals', slug: 'guides/test-signals' },
|
||||
{ label: 'Build a Transmit Signal', slug: 'guides/transmit-signal' },
|
||||
{ label: 'Run the Demos', slug: 'guides/run-demos' },
|
||||
{ label: 'Decode Voice Audio', slug: 'guides/voice-audio' },
|
||||
{ label: 'Modulate SCO Channels', slug: 'guides/sco-modulation' },
|
||||
{ label: 'Connect to Virtual AGC', slug: 'guides/agc-bridge' },
|
||||
{ label: 'Work with PCM Telemetry', slug: 'guides/pcm-telemetry' },
|
||||
],
|
||||
@ -61,28 +52,8 @@ export default defineConfig({
|
||||
},
|
||||
components: {
|
||||
Head: './src/components/Head.astro',
|
||||
SocialIcons: './src/components/SocialIcons.astro',
|
||||
},
|
||||
}),
|
||||
opengraphImages({
|
||||
options: {
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
data: interRegular,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
data: interBold,
|
||||
},
|
||||
],
|
||||
},
|
||||
render: ogRender,
|
||||
}),
|
||||
],
|
||||
markdown: {
|
||||
rehypePlugins: [
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
services:
|
||||
docs:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: ${DOMAIN}
|
||||
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
profiles:
|
||||
- prod
|
||||
|
||||
docs-dev:
|
||||
build:
|
||||
context: .
|
||||
target: deps
|
||||
command: npx astro dev --host 0.0.0.0 --port 4321
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
- ./astro.config.mjs:/app/astro.config.mjs
|
||||
- ./tsconfig.json:/app/tsconfig.json
|
||||
environment:
|
||||
- ASTRO_TELEMETRY_DISABLED=1
|
||||
- VITE_HMR_HOST=${VITE_HMR_HOST:-}
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
2340
docs/package-lock.json
generated
2340
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,16 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.37.6",
|
||||
"@iconify-json/lucide": "^1.2.92",
|
||||
"astro": "^5.6.1",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-opengraph-images": "^1.14.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"rehype-mermaid": "^3.0.0",
|
||||
"sharp": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.s{fill:none;stroke:#0B3D91;stroke-linecap:round;stroke-linejoin:round}
|
||||
.f{fill:#0B3D91}
|
||||
.r{fill:none;stroke:#FC3D21;stroke-linecap:round}
|
||||
@media(prefers-color-scheme:dark){
|
||||
.s{stroke:#7EB8FF}
|
||||
.f{fill:#7EB8FF}
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Dish reflector -->
|
||||
<path class="s" d="M18 78Q64 108 110 78" stroke-width="10"/>
|
||||
<!-- Struts to feed horn -->
|
||||
<line class="s" x1="34" y1="82" x2="64" y2="42" stroke-width="5"/>
|
||||
<line class="s" x1="94" y1="82" x2="64" y2="42" stroke-width="5"/>
|
||||
<!-- Feed horn -->
|
||||
<circle class="f" cx="64" cy="40" r="6"/>
|
||||
<!-- Pedestal -->
|
||||
<line class="s" x1="64" y1="88" x2="64" y2="116" stroke-width="8"/>
|
||||
<!-- Base plate -->
|
||||
<line class="s" x1="44" y1="116" x2="84" y2="116" stroke-width="8"/>
|
||||
<!-- Signal arcs -->
|
||||
<path class="r" d="M56 30Q64 22 72 30" stroke-width="4"/>
|
||||
<path class="r" d="M48 22Q64 10 80 22" stroke-width="4" opacity=".65"/>
|
||||
<path class="r" d="M40 14Q64-2 88 14" stroke-width="4" opacity=".35"/>
|
||||
</svg>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 696 B |
@ -2,11 +2,7 @@
|
||||
import type { Props } from '@astrojs/starlight/props';
|
||||
import Default from '@astrojs/starlight/components/Head.astro';
|
||||
import MermaidInit from './MermaidInit.astro';
|
||||
import { getImagePath } from 'astro-opengraph-images';
|
||||
|
||||
const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site });
|
||||
---
|
||||
|
||||
<Default {...Astro.props}><slot /></Default>
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<MermaidInit />
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
---
|
||||
// Generic Git icon replacing Starlight's GitHub-specific social icon
|
||||
---
|
||||
|
||||
<a href="https://github.com/rpm/gr-apollo" rel="me" class="sl-flex git-link" aria-label="Source code">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.git-link {
|
||||
color: var(--sl-color-text-accent);
|
||||
text-decoration: none;
|
||||
padding: 0.5em;
|
||||
}
|
||||
.git-link:hover {
|
||||
opacity: 0.66;
|
||||
}
|
||||
</style>
|
||||
@ -7,8 +7,6 @@ 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 formats, sync word structure, and A/D converter specifications are from the [NAA Telecommunication Systems Study Guide (Course A-624)](https://archive.org/details/apollo-telecommunications). AGC channel assignments are from the [Virtual AGC project](https://www.ibiblio.org/apollo/).
|
||||
|
||||
## 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.
|
||||
|
||||
@ -7,8 +7,6 @@ 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.
|
||||
|
||||
All parameters in this section are from the [NAA Telecommunication Systems Study Guide (Course A-624)](https://archive.org/details/apollo-telecommunications).
|
||||
|
||||
## 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.
|
||||
@ -50,7 +48,7 @@ At small modulation indices, the relationship between the modulating signal and
|
||||
|
||||
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](https://www.nasa.gov/communicating-with-missions/dsn/) 26-meter dishes made up the difference with raw antenna gain.
|
||||
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.
|
||||
@ -173,56 +171,3 @@ The Apollo USB system maintains a coherent frequency relationship between uplink
|
||||
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.
|
||||
|
||||
## The transmit chain
|
||||
|
||||
gr-apollo's TX blocks mirror the receive path. Where the receiver disassembles the signal layer by layer, the transmitter builds it up. Each TX block has a direct RX counterpart, and the internal architecture reflects this symmetry:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["PCM Frame Source<br/>32-bit sync<br/>128 words/frame"] --> B["NRZ Encoder<br/>0/1 → +1/-1<br/>100 samp/bit"]
|
||||
B --> C["BPSK Mod<br/>× cos(1.024 MHz)"]
|
||||
C --> D["Σ"]
|
||||
|
||||
E["Voice Mod<br/>FM → 1.25 MHz<br/>±29 kHz dev"] --> F["× 0.764"]
|
||||
F --> D
|
||||
|
||||
D --> G["PM Mod<br/>exp(j·φ)"]
|
||||
G --> H["Complex<br/>Baseband<br/>5.12 MHz"]
|
||||
|
||||
style A fill:#2d5016,stroke:#4a8c2a
|
||||
style B fill:#5c3a1a,stroke:#bd7a3a
|
||||
style C fill:#3a1a5c,stroke:#7a3abd
|
||||
style E fill:#2d5016,stroke:#4a8c2a
|
||||
style G fill:#1a3a5c,stroke:#3a7abd
|
||||
style H fill:#1a3a5c,stroke:#3a7abd
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="PCM Path">
|
||||
The PCM transmit path generates telemetry frames and modulates them onto the BPSK subcarrier:
|
||||
|
||||
1. **pcm_frame_source** -- Generates 128-word PCM frames with 32-bit sync words. Frame IDs cycle 1 through 50 (one subframe per second). Odd frames get a complemented sync core automatically. Dynamic payloads can be injected via the `frame_data` message port.
|
||||
2. **nrz_encoder** -- Converts the bit stream (byte values 0/1) to a float NRZ waveform (+1.0/-1.0). Each bit is repeated for 100 samples at the default 5.12 MHz rate.
|
||||
3. **bpsk_subcarrier_mod** -- Multiplies the NRZ data by a 1.024 MHz cosine carrier. This BPSK modulation flips the subcarrier phase 180 degrees on each bit transition -- the inverse of the Costas loop recovery in `bpsk_demod`.
|
||||
</TabItem>
|
||||
<TabItem label="Voice Path">
|
||||
The voice path produces an FM subcarrier that is summed with the PCM subcarrier:
|
||||
|
||||
- **fm_voice_subcarrier_mod** -- Two modes: an internal sine test tone (for testing) or external audio input (for real mission audio). The audio is FM-modulated at baseband and upconverted to 1.25 MHz. The voice level is scaled by 1.68/2.2 (approximately 0.764) relative to the PCM subcarrier, matching the spacecraft's Pre-Modulation Processor power allocation.
|
||||
|
||||
The voice and PCM subcarriers occupy non-overlapping frequency bands (1.024 MHz vs 1.25 MHz), so they can be linearly summed before phase modulation without interference.
|
||||
</TabItem>
|
||||
<TabItem label="Composite">
|
||||
After summing the subcarriers, the composite signal is phase-modulated onto a complex carrier:
|
||||
|
||||
- **pm_mod** -- Applies `exp(j * dev * m(t))` where `dev` is 0.133 rad and `m(t)` is the composite subcarrier signal. The output is a constant-envelope complex baseband signal. The small deviation ensures the modulation is linear (same 0.3% approximation that makes the receiver work).
|
||||
- **Optional AWGN** -- When `snr_db` is set, Gaussian noise is added to the complex output for realistic channel simulation.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The `usb_signal_source` hierarchical block wires the entire TX chain together as a convenience -- the transmit-side counterpart to `usb_downlink_receiver`. For scenarios that need finer control (like injecting external audio), the individual blocks can be assembled manually. The `full_downlink_demo.py` example shows this approach.
|
||||
|
||||
<Aside type="note">
|
||||
The TX and RX blocks are designed to be exact inverses. Connecting `usb_signal_source` directly to `usb_downlink_receiver` creates a perfect digital loopback -- the `loopback_demo.py` script demonstrates this, recovering transmitted frames with 100% fidelity in the clean (no noise) case.
|
||||
</Aside>
|
||||
|
||||
@ -283,9 +283,3 @@ The default TCP port is 19697 (`AGC_PORT_BASE`). If you are running multiple yaA
|
||||
</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.
|
||||
|
||||
## Hardware restoration
|
||||
|
||||
While gr-apollo works with the Virtual AGC *emulator*, others have gotten the real hardware running. [CuriousMarc (Marc Verdiell)](https://www.curiousmarc.com/space), along with Mike Stewart, [Ken Shirriff](https://www.righto.com/search/label/Apollo), and a team of volunteers, have restored actual Apollo Guidance Computers and S-Band telecommunications equipment to operational status. Their work provides invaluable validation that the specifications gr-apollo implements match the behavior of real flight hardware.
|
||||
|
||||
Of particular interest: [Building an Apollo transmit station with Keysight instruments](https://www.youtube.com/watch?v=ctNmYFfxI7w) shows modern Keysight signal generators and analyzers driving the same S-Band uplink chain that gr-apollo models in software. [Inside the WILD Lab of CuriousMarc](https://www.youtube.com/watch?v=qwocVH3_1Eo) is a Keysight-produced tour of the lab where the restoration work happens.
|
||||
|
||||
@ -31,7 +31,7 @@ gr-apollo provides a set of GNU Radio blocks and pure-Python engines that implem
|
||||
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 TB
|
||||
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>"]
|
||||
@ -85,19 +85,6 @@ from apollo import pm_demod, bpsk_demod, pcm_frame_sync, usb_downlink_receiver
|
||||
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>
|
||||
|
||||
## References
|
||||
|
||||
gr-apollo is based on these primary sources:
|
||||
|
||||
- [NAA Telecommunication Systems Study Guide (Course A-624, 1965)](https://archive.org/details/apollo-telecommunications) -- the original North American Aviation training manual for the Apollo USB system. Defines all frequencies, modulation parameters, frame formats, and SCO specifications used in gr-apollo.
|
||||
- [Virtual AGC Project](https://www.ibiblio.org/apollo/) -- Ron Burkey's Apollo Guidance Computer emulator and document archive. The AGC bridge protocol and downlink list decoding are based on the [yaAGC source code](https://github.com/virtualagc/virtualagc).
|
||||
- [NASA Technical Reports Server](https://ntrs.nasa.gov/) -- primary source for Apollo spacecraft telecommunications system descriptions and MSFN ground station specifications.
|
||||
|
||||
### Community
|
||||
|
||||
- [CuriousMarc (Marc Verdiell)](https://www.curiousmarc.com/space) — Marc and his team (Mike Stewart, Ken Shirriff, and others) have been restoring and operating original Apollo S-Band flight hardware. Their [YouTube channel](https://www.youtube.com/@CuriousMarc) documents the restoration process in detail — see [Building an Apollo transmit station with Keysight instruments](https://www.youtube.com/watch?v=ctNmYFfxI7w) for a look at how modern test equipment talks to 1960s flight hardware.
|
||||
- [Ken Shirriff's Blog](https://www.righto.com/search/label/Apollo) — detailed reverse-engineering posts on Apollo-era hardware, from AGC core rope memory to the USB signal processing chains.
|
||||
|
||||
## Next step
|
||||
|
||||
<LinkCard
|
||||
|
||||
@ -200,44 +200,9 @@ flowchart TB
|
||||
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>
|
||||
|
||||
## Streaming loopback
|
||||
|
||||
If you have GNU Radio installed, the streaming TX and RX blocks can be connected directly for a full round-trip test. This is the simplest way to verify the complete chain:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
tx = usb_signal_source(voice_enabled=True, snr_db=30.0)
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * 102400) # 10 frames
|
||||
rx = usb_downlink_receiver(output_format="raw")
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
print(f"Recovered {snk.num_messages()} frames")
|
||||
```
|
||||
|
||||
The `loopback_demo.py` script wraps this pattern with argument parsing and frame analysis. Run it with:
|
||||
|
||||
```bash
|
||||
uv run python examples/loopback_demo.py --frames 20 --voice
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Build a Transmit Signal"
|
||||
description="Compose TX blocks into custom transmit chains."
|
||||
href="/guides/transmit-signal/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Signal Architecture"
|
||||
description="How the Apollo USB system works, from RF to bits."
|
||||
@ -253,11 +218,6 @@ uv run python examples/loopback_demo.py --frames 20 --voice
|
||||
description="Detailed API for every gr-apollo block."
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Run the Demos"
|
||||
description="Loopback, voice, full downlink, and AGC integration demos."
|
||||
href="/guides/run-demos/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Connect to Virtual AGC"
|
||||
description="Bridge decoded telemetry to the Apollo Guidance Computer emulator."
|
||||
|
||||
@ -1,393 +0,0 @@
|
||||
---
|
||||
title: "Run the Demos"
|
||||
description: "How to run gr-apollo's example scripts for loopback testing, voice modulation, full downlink simulation, and AGC integration."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
gr-apollo ships with four demo scripts that exercise different parts of the TX/RX chain. They progress from a self-contained streaming loopback to a full mission simulation with crew voice, and finally to live integration with the Virtual AGC emulator.
|
||||
|
||||
| Demo | Requires | What It Does |
|
||||
|------|----------|-------------|
|
||||
| `loopback_demo.py` | GNU Radio | Streaming TX to RX round-trip |
|
||||
| `voice_subcarrier_demo.py` | GNU Radio, scipy | Real audio through 1.25 MHz FM |
|
||||
| `full_downlink_demo.py` | GNU Radio, scipy | PCM telemetry + crew voice on one carrier |
|
||||
| `agc_loopback_demo.py` | yaAGC (no GR) | Live AGC telemetry over TCP |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<Aside type="note">
|
||||
All demos require gr-apollo installed in development mode:
|
||||
|
||||
```bash
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
The loopback and voice demos need GNU Radio 3.10+. The voice and full-downlink demos also need `scipy` for audio resampling. The AGC demo needs only the pure-Python components (no GNU Radio) but requires a running yaAGC emulator.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Loopback
|
||||
|
||||
**Script:** `examples/loopback_demo.py`
|
||||
**Requires:** GNU Radio
|
||||
|
||||
The loopback demo connects `usb_signal_source` directly to `usb_downlink_receiver` through the GNU Radio scheduler. It transmits PCM frames, receives them back, and displays sync word analysis for each recovered frame.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["usb_signal_source\n(TX chain)"]:::rf --> B["head\n(sample limiter)"]:::timing --> C["usb_downlink_receiver\n(RX chain)"]:::rf
|
||||
C -->|"frames (PDU)"| D["message_debug\n(store)"]:::data
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/loopback_demo.py
|
||||
uv run python examples/loopback_demo.py --voice # include voice subcarrier
|
||||
uv run python examples/loopback_demo.py --snr 20 # add noise at 20 dB SNR
|
||||
uv run python examples/loopback_demo.py --frames 20 # generate 20 frames
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--frames` | 10 | Number of PCM frames to transmit |
|
||||
| `--snr` | None | SNR in dB (None = clean, no noise) |
|
||||
| `--voice` | off | Enable the 1.25 MHz FM voice subcarrier with 1 kHz test tone |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo USB Loopback Demo
|
||||
============================================================
|
||||
Frames to transmit: 10
|
||||
Samples per frame: 102,400
|
||||
Total samples: 1,024,000
|
||||
Duration: 0.200 s
|
||||
SNR: clean (no noise)
|
||||
Voice subcarrier: disabled
|
||||
|
||||
Building flowgraph...
|
||||
Running flowgraph (TX -> RX)...
|
||||
|
||||
Recovered 8 frames from 10 transmitted
|
||||
|
||||
------------------------------------------------------------
|
||||
Frame 1: ID= 3 (odd ), sync=0xAB31D403, 124 words [00 00 00 00 00 00 00 00 ...]
|
||||
Frame 2: ID= 4 (even), sync=0xABCED404, 124 words [00 00 00 00 00 00 00 00 ...]
|
||||
...
|
||||
------------------------------------------------------------
|
||||
|
||||
Recovery rate: 8/10 (80%)
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The first 1--2 frames are typically lost to PLL settling. This is physically correct -- the carrier tracking loop needs signal history to acquire lock. If recovery seems low, increase `--frames` rather than adjusting demod parameters.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Voice Subcarrier
|
||||
|
||||
**Script:** `examples/voice_subcarrier_demo.py`
|
||||
**Requires:** GNU Radio, scipy
|
||||
|
||||
This demo takes a real audio file (such as actual Apollo 11 crew recordings), modulates it onto the 1.25 MHz FM voice subcarrier with +/-29 kHz deviation, then demodulates it back to audio. The round-trip exercises the same signal path the spacecraft and ground station used.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["WAV file\n(any rate)"]:::data --> B["resample\nto 8 kHz"]:::timing --> C["upsample\nto 5.12 MHz"]:::timing
|
||||
C --> D["fm_voice_subcarrier_mod\n(audio_input=True)"]:::rf
|
||||
D --> E["voice_subcarrier_demod\n(8 kHz output)"]:::rf
|
||||
E --> F["recovered\nWAV file"]:::data
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/voice_subcarrier_demo.py examples/audio/apollo11_crew.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --output recovered.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --play
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `input` (positional) | -- | Input WAV file (any sample rate) |
|
||||
| `--output`, `-o` | `<input>_recovered.wav` | Output WAV file path |
|
||||
| `--play` | off | Play recovered audio with `aplay` after processing |
|
||||
| `--sample-rate` | 5,120,000 | Baseband sample rate in Hz |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo Voice Subcarrier Demo
|
||||
============================================================
|
||||
|
||||
Input: examples/audio/apollo11_crew.wav
|
||||
Sample rate: 48000 Hz
|
||||
Duration: 5.23 s
|
||||
Samples: 251,136
|
||||
|
||||
Resampled to 8000 Hz: 41,856 samples
|
||||
Upsampling 8000 Hz -> 5.12 MHz (ratio 640:1)...
|
||||
Upsampled: 26,787,840 samples (2.1s)
|
||||
|
||||
Building flowgraph: FM mod (1.25 MHz) -> FM demod...
|
||||
Running flowgraph...
|
||||
Processed in 3.4s
|
||||
Recovered: 41,790 samples at 8000 Hz
|
||||
Duration: 5.22 s
|
||||
|
||||
Saved: examples/audio/apollo11_crew_recovered.wav
|
||||
Peak amplitude: 0.8234
|
||||
|
||||
Play with: aplay examples/audio/apollo11_crew_recovered.wav
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The input audio is first resampled to 8 kHz (the Apollo voice bandwidth standard), then upsampled by 640x to reach the 5.12 MHz baseband rate. This mirrors what the spacecraft hardware did: the Pre-Modulation Processor accepted telephone-bandwidth audio and modulated it onto the RF subcarrier. The recovered audio has 300--3000 Hz bandwidth, matching the original system.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Full Downlink
|
||||
|
||||
**Script:** `examples/full_downlink_demo.py`
|
||||
**Requires:** GNU Radio, scipy
|
||||
|
||||
The full downlink demo reconstructs the complete Apollo USB downlink: PCM telemetry frames on the 1.024 MHz BPSK subcarrier AND crew voice on the 1.25 MHz FM subcarrier, both phase-modulated onto a single complex carrier. The receiver splits the signal back into decoded frames and audio.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph TX ["TX (spacecraft)"]
|
||||
direction LR
|
||||
A["pcm_frame_source\n→ nrz → bpsk_mod"]:::data --> D["add_ff"]:::rf
|
||||
B["crew audio\n→ fm_voice_mod"]:::data --> C["× 0.764"]:::rf --> D
|
||||
D --> E["pm_mod"]:::rf
|
||||
end
|
||||
|
||||
subgraph RX ["RX (ground station)"]
|
||||
direction LR
|
||||
F["pm_demod"]:::rf --> G["bpsk_demod\n→ frame_sync"]:::rf
|
||||
F --> H["voice_demod"]:::rf
|
||||
G --> I["PCM frames"]:::data
|
||||
H --> J["crew audio"]:::data
|
||||
end
|
||||
|
||||
E --> F
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
This demo builds the TX chain manually (not using `usb_signal_source`) so it can inject external audio into the voice channel. It then runs the RX chain twice: once for PCM frame recovery, once for voice demodulation.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/full_downlink_demo.py examples/audio/apollo11_crew.wav
|
||||
uv run python examples/full_downlink_demo.py input.wav --snr 25
|
||||
uv run python examples/full_downlink_demo.py input.wav --play
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `audio` (positional) | -- | Input crew voice WAV file |
|
||||
| `--output`, `-o` | `<input>_fullchain.wav` | Output WAV path for recovered voice |
|
||||
| `--snr` | None | Add AWGN noise at this SNR in dB |
|
||||
| `--play` | off | Play recovered voice with `aplay` |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo Full Downlink Demo
|
||||
PCM telemetry (1.024 MHz BPSK) + crew voice (1.25 MHz FM)
|
||||
============================================================
|
||||
|
||||
Loading crew voice audio...
|
||||
Source: examples/audio/apollo11_crew.wav (5.23s)
|
||||
Upsampled: 26,787,840 samples at 5.12 MHz
|
||||
|
||||
PCM frames: ~263 at 50 fps
|
||||
Signal: 26,787,840 samples (5.23s)
|
||||
SNR: clean
|
||||
|
||||
TX: Building combined PCM + voice signal...
|
||||
Generated 26,787,840 complex samples (4.2s)
|
||||
PM envelope std: 0.000001 (should be ~0 for clean)
|
||||
|
||||
RX: Decoding PCM telemetry frames...
|
||||
Recovered 260 PCM frames (6.1s)
|
||||
|
||||
Frame 1: ID= 3 (odd), 124 data words
|
||||
Frame 2: ID= 4 (even), 124 data words
|
||||
Frame 3: ID= 5 (odd), 124 data words
|
||||
Frame 4: ID= 6 (even), 124 data words
|
||||
Frame 5: ID= 7 (odd), 124 data words
|
||||
... (255 more frames)
|
||||
|
||||
RX: Demodulating crew voice (1.25 MHz FM)...
|
||||
Recovered 41,790 audio samples (3.8s)
|
||||
Duration: 5.22s at 8000 Hz
|
||||
Saved: examples/audio/apollo11_crew_fullchain.wav
|
||||
|
||||
============================================================
|
||||
TX: 5.23s of combined PCM + voice
|
||||
RX: 260 PCM frames + 5.22s crew voice
|
||||
SNR: clean
|
||||
============================================================
|
||||
|
||||
Play voice: aplay examples/audio/apollo11_crew_fullchain.wav
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
The full downlink demo processes the signal twice (once for PCM, once for voice) because the `usb_downlink_receiver` does not include a voice output path. For a production system, you would split the PM demod output and feed both paths simultaneously, as shown in the [Decode Voice Audio](/guides/voice-audio/#extracting-voice-alongside-pcm-telemetry) guide.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## AGC Integration
|
||||
|
||||
**Script:** `examples/agc_loopback_demo.py`
|
||||
**Requires:** yaAGC emulator (no GNU Radio needed)
|
||||
|
||||
This demo connects directly to a running Virtual AGC emulator over TCP, receives DNTM1/DNTM2 telemetry packets, decodes them into downlink list snapshots, and optionally sends DSKY commands.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["yaAGC\n(Luminary099)"]:::timing -->|"TCP :19697"| B["AGCBridgeClient"]:::rf
|
||||
B --> C["DownlinkEngine\n(reassemble words)"]:::data
|
||||
C --> D["telemetry\nsnapshots"]:::data
|
||||
E["UplinkEncoder\n(V16N36E)"]:::data -->|"INLINK ch 045"| B
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Install Virtual AGC** from [the project website](https://www.ibiblio.org/apollo/). The key binary is `yaAGC`.
|
||||
|
||||
2. **Start the AGC emulator** with a mission flight software image:
|
||||
```bash
|
||||
yaAGC --core=Luminary099.bin --port=19697
|
||||
```
|
||||
|
||||
3. **Optionally start yaDSKY2** for a visual DSKY display:
|
||||
```bash
|
||||
yaDSKY2 --port=19698
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/agc_loopback_demo.py
|
||||
uv run python examples/agc_loopback_demo.py --host 192.168.1.100
|
||||
uv run python examples/agc_loopback_demo.py --send-v16n36
|
||||
uv run python examples/agc_loopback_demo.py --duration 30
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--host` | `localhost` | yaAGC hostname or IP |
|
||||
| `--port` | 19697 | yaAGC TCP port |
|
||||
| `--duration` | 10.0 | Collection duration in seconds |
|
||||
| `--send-v16n36` | off | Send V16N36E (display mission elapsed time) to the AGC |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo AGC Integration Demo
|
||||
============================================================
|
||||
Target: localhost:19697
|
||||
Duration: 10.0 seconds
|
||||
|
||||
Connecting to yaAGC at localhost:19697...
|
||||
Connection: connecting
|
||||
Connection: connected
|
||||
|
||||
Sending V16N36E (display time)...
|
||||
Sent 7 uplink words
|
||||
|
||||
Collecting telemetry for 10.0 seconds...
|
||||
------------------------------------------------------------
|
||||
Telemetry snapshot: CM Coast/Alignment (type 2), 400 words
|
||||
[000] = 00002 (2)
|
||||
[001] = 00000 (0)
|
||||
[002] = 77777 (32767)
|
||||
[003] = 00000 (0)
|
||||
[004] = 00000 (0)
|
||||
... (395 more words)
|
||||
------------------------------------------------------------
|
||||
|
||||
Summary:
|
||||
Total packets received: 1247
|
||||
Telemetry words: 834
|
||||
Telemetry snapshots: 2
|
||||
Duration: 10.1 seconds
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The V16N36E command requests the AGC to display the current mission elapsed time on the DSKY. If yaDSKY2 is running, you will see the time appear on the display. This is a safe read-only command that does not affect the AGC's running program.
|
||||
</Aside>
|
||||
|
||||
<Aside type="note">
|
||||
The `AGCBridgeClient` auto-reconnects with exponential backoff. You can start the demo before yaAGC is running -- it will retry in the background until the emulator comes online. If no connection is established within 10 seconds, the demo exits with instructions for starting yaAGC.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Which Demo to Start With
|
||||
|
||||
If you are new to gr-apollo:
|
||||
|
||||
1. **Start with the loopback demo.** It has no external dependencies beyond GNU Radio and exercises the complete TX/RX round-trip in a self-contained flowgraph.
|
||||
|
||||
2. **Try the voice demo** with an Apollo crew recording to hear the signal processing in action. Audio files in `examples/audio/` are ready to use.
|
||||
|
||||
3. **Run the full downlink** to see both PCM and voice working together on one carrier -- the way it worked on the actual spacecraft.
|
||||
|
||||
4. **Connect to yaAGC** when you are ready to interact with a running Apollo Guidance Computer.
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Build a Transmit Signal"
|
||||
description="Detailed walkthrough of the TX block chain"
|
||||
href="/guides/transmit-signal/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Block Reference"
|
||||
description="Full API docs for all blocks used in the demos"
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
</CardGrid>
|
||||
@ -1,320 +0,0 @@
|
||||
---
|
||||
title: "Modulate SCO Channels"
|
||||
description: "How to generate and demodulate Subcarrier Oscillator (SCO) analog telemetry channels used in FM downlink mode."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
In FM downlink mode, the Pre-Modulation Processor replaces the PCM/voice subcarrier system with 9 Subcarrier Oscillators (SCOs). Each SCO encodes a 0--5V sensor voltage as FM deviation around a fixed center frequency. The `sco_mod` and `sco_demod` blocks handle the transmit and receive sides of this analog telemetry path.
|
||||
|
||||
<Aside type="caution">
|
||||
SCOs are only used in FM downlink mode. During normal PM downlink mode (which carried the vast majority of Apollo mission communications), the 1.024 MHz BPSK subcarrier handles PCM telemetry and the 1.25 MHz FM subcarrier carries voice. The two modes are mutually exclusive.
|
||||
</Aside>
|
||||
|
||||
## SCO Channel Table
|
||||
|
||||
All 9 channels deviate by +/-7.5% of their center frequency:
|
||||
|
||||
| SCO | Center Freq | Deviation (+/-7.5%) | Low Freq | High Freq |
|
||||
|-----|------------|---------------------|----------|-----------|
|
||||
| 1 | 14,500 Hz | 1,087.5 Hz | 13,412.5 Hz | 15,587.5 Hz |
|
||||
| 2 | 22,000 Hz | 1,650 Hz | 20,350 Hz | 23,650 Hz |
|
||||
| 3 | 30,000 Hz | 2,250 Hz | 27,750 Hz | 32,250 Hz |
|
||||
| 4 | 40,000 Hz | 3,000 Hz | 37,000 Hz | 43,000 Hz |
|
||||
| 5 | 52,500 Hz | 3,937.5 Hz | 48,562.5 Hz | 56,437.5 Hz |
|
||||
| 6 | 70,000 Hz | 5,250 Hz | 64,750 Hz | 75,250 Hz |
|
||||
| 7 | 95,000 Hz | 7,125 Hz | 87,875 Hz | 102,125 Hz |
|
||||
| 8 | 125,000 Hz | 9,375 Hz | 115,625 Hz | 134,375 Hz |
|
||||
| 9 | 165,000 Hz | 12,375 Hz | 152,625 Hz | 177,375 Hz |
|
||||
|
||||
The channels are spaced logarithmically, with each roughly 1.35x the previous. This spacing allows them to be frequency-division multiplexed onto a single composite signal without overlap.
|
||||
|
||||
## Basic SCO Modulation
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Create an sco_mod for a single channel**
|
||||
|
||||
The `sco_mod` block accepts a 0--5V float input and produces an FM tone at the selected channel's center frequency. Here we modulate a constant 3.3V sensor reading onto SCO channel 5 (52.5 kHz):
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Simulate a constant 3.3V sensor reading
|
||||
sensor = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.3, 0,
|
||||
)
|
||||
|
||||
# SCO channel 5: 52.5 kHz center, +/-3937.5 Hz deviation
|
||||
mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 0.01)) # 10 ms
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(sensor, mod, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} samples")
|
||||
print(f"SCO 5 center: {mod.center_freq} Hz")
|
||||
print(f"SCO 5 deviation: {mod.deviation_hz} Hz")
|
||||
```
|
||||
|
||||
2. **Verify the output frequency**
|
||||
|
||||
At 3.3V input, the SCO should be above center. The voltage maps linearly: 0V is center minus 7.5%, 2.5V is center, 5V is center plus 7.5%. For 3.3V:
|
||||
|
||||
```
|
||||
offset = (3.3 - 2.5) / 2.5 = 0.32 (normalized)
|
||||
freq = 52500 + 0.32 * 3937.5 = 53760 Hz
|
||||
```
|
||||
|
||||
3. **Inspect properties at runtime**
|
||||
|
||||
The block exposes its configuration as read-only properties:
|
||||
|
||||
```python
|
||||
mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
print(f"Channel: {mod.sco_number}") # 5
|
||||
print(f"Center: {mod.center_freq} Hz") # 52500.0
|
||||
print(f"Deviation: {mod.deviation_hz} Hz") # 3937.5
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Round-Trip: Modulate and Demodulate
|
||||
|
||||
The `sco_demod` block reverses the modulation, recovering the original sensor voltage from the FM subcarrier tone. Connecting `sco_mod` to `sco_demod` creates a complete round-trip:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_demod import sco_demod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Slowly varying sensor: 1 Hz sine wave, 0-5V range
|
||||
sensor = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_SIN_WAVE, 1.0, 2.5, 2.5,
|
||||
)
|
||||
|
||||
# Modulate onto SCO 3 (30 kHz center)
|
||||
mod = sco_mod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Demodulate back to voltage
|
||||
demod = sco_demod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 2)) # 2 seconds
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(sensor, mod, demod, head, snk)
|
||||
tb.run()
|
||||
|
||||
import numpy as np
|
||||
recovered = np.array(snk.data())
|
||||
print(f"Samples: {len(recovered)}")
|
||||
print(f"Mean voltage: {np.mean(recovered):.2f} V (expected ~2.5)")
|
||||
print(f"Voltage range: {np.min(recovered):.2f} - {np.max(recovered):.2f} V")
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The recovered voltage tracks the input with slight latency from the FM discriminator's internal bandpass filter settling. For slowly varying inputs (below ~100 Hz), the tracking is accurate to within a few millivolts. The demodulator's output sample rate depends on the internal decimation -- use the `output_sample_rate` property to check it.
|
||||
</Aside>
|
||||
|
||||
## Multi-Channel Summing
|
||||
|
||||
In a real FM downlink, multiple SCO channels are summed to form a composite signal that drives the PM modulator. Each SCO encodes a different sensor:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.pm_mod import pm_mod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Three sensors with different readings
|
||||
sensor1 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 1.2, 0,
|
||||
) # 1.2V (cabin pressure)
|
||||
sensor5 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.8, 0,
|
||||
) # 3.8V (fuel cell voltage)
|
||||
sensor9 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 2.5, 0,
|
||||
) # 2.5V (temperature)
|
||||
|
||||
# One sco_mod per channel
|
||||
sco1 = sco_mod(sco_number=1, sample_rate=SAMPLE_RATE_BASEBAND) # 14.5 kHz
|
||||
sco5 = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND) # 52.5 kHz
|
||||
sco9 = sco_mod(sco_number=9, sample_rate=SAMPLE_RATE_BASEBAND) # 165 kHz
|
||||
|
||||
# Sum all SCO subcarrier tones
|
||||
adder = blocks.add_ff(1)
|
||||
tb.connect(sensor1, sco1, (adder, 0))
|
||||
tb.connect(sensor5, sco5, (adder, 1))
|
||||
tb.connect(sensor9, sco9, (adder, 2))
|
||||
|
||||
# PM modulate the composite SCO signal
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.1)) # 100 ms
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(adder, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
print(f"SCO channels: {sco1.center_freq}, {sco5.center_freq}, {sco9.center_freq} Hz")
|
||||
```
|
||||
|
||||
The SCO center frequencies (14.5 kHz, 52.5 kHz, 165 kHz) are far enough apart that simple bandpass filtering on the receive side can separate them without interference.
|
||||
|
||||
## Voltage Mapping
|
||||
|
||||
The `sco_mod` block maps input voltage to output frequency linearly:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["0V input"]:::data --> B["center - 7.5%\n(low freq)"]:::rf
|
||||
C["2.5V input"]:::data --> D["center freq\n(nominal)"]:::rf
|
||||
E["5V input"]:::data --> F["center + 7.5%\n(high freq)"]:::rf
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
Internally, the modulation chain is:
|
||||
|
||||
1. **Subtract 2.5V** -- center the signal at zero
|
||||
2. **Scale to +/-1.0** -- divide by 2.5 (half the input range)
|
||||
3. **FM modulate** -- +/-1.0 input produces +/-deviation Hz output
|
||||
4. **Upconvert** -- mix with a local oscillator at the center frequency
|
||||
5. **Extract real part** -- the output is a real-valued float subcarrier tone
|
||||
|
||||
The `sco_demod` block reverses this:
|
||||
|
||||
1. **Bandpass extract** at the channel center frequency (bandwidth = 15% of center)
|
||||
2. **FM discriminate** -- quadrature demod recovers the frequency deviation
|
||||
3. **Scale by 2.5** -- map discriminator output back to voltage swing
|
||||
4. **Add 2.5V offset** -- restore the 0--5V range
|
||||
|
||||
The conversion is symmetric: a constant voltage in produces that same voltage out (within the FM discriminator's noise floor).
|
||||
|
||||
## Choosing SCO Channels
|
||||
|
||||
The 9 SCO channels span from 14.5 kHz to 165 kHz. When selecting which channels to use, consider:
|
||||
|
||||
| Factor | Guidance |
|
||||
|--------|----------|
|
||||
| Bandwidth | Higher channels have wider deviation bands -- better for fast-changing signals |
|
||||
| Filter settling | Lower channels need longer filter settling time due to narrower bandwidth |
|
||||
| Channel spacing | Adjacent channels can interfere if the composite signal is distorted |
|
||||
| Sample rate | All channels work at the default 5.12 MHz baseband rate |
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Low-bandwidth sensor">
|
||||
For slow-moving measurements (temperature, pressure), use lower SCO channels (1--4). The narrow bandwidth provides better noise rejection:
|
||||
|
||||
```python
|
||||
# Temperature sensor: changes slowly, needs precision
|
||||
temp_sco = sco_mod(sco_number=2, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
# SCO 2: 22 kHz center, +/-1650 Hz deviation
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Fast-changing sensor">
|
||||
For vibration monitoring or other fast signals, use higher SCO channels (7--9) where the wider deviation band supports higher-frequency content:
|
||||
|
||||
```python
|
||||
# Vibration sensor: needs higher bandwidth
|
||||
vib_sco = sco_mod(sco_number=8, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
# SCO 8: 125 kHz center, +/-9375 Hz deviation
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Complete FM Downlink Example
|
||||
|
||||
Here is a full example that modulates three sensor channels, transmits them as FM downlink, and recovers all three voltages:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_demod import sco_demod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# --- TX side: three sensors ---
|
||||
sensor_readings = {3: 1.8, 5: 4.2, 7: 2.5} # {sco_channel: voltage}
|
||||
|
||||
mods = {}
|
||||
adder = blocks.add_ff(1)
|
||||
for idx, (ch, voltage) in enumerate(sensor_readings.items()):
|
||||
src = analog.sig_source_f(SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, voltage, 0)
|
||||
mod = sco_mod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
mods[ch] = mod
|
||||
tb.connect(src, mod, (adder, idx))
|
||||
|
||||
# --- RX side: demodulate each channel from composite ---
|
||||
demods = {}
|
||||
sinks = {}
|
||||
duration_samples = int(SAMPLE_RATE_BASEBAND * 0.5) # 500 ms
|
||||
|
||||
for ch in sensor_readings:
|
||||
demod = sco_demod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
head = blocks.head(gr.sizeof_float, duration_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(adder, demod, head, snk)
|
||||
demods[ch] = demod
|
||||
sinks[ch] = snk
|
||||
|
||||
tb.run()
|
||||
|
||||
# Compare input and recovered voltages
|
||||
import numpy as np
|
||||
print("SCO | Input | Recovered (mean) | Error")
|
||||
print("-----|--------|------------------|------")
|
||||
for ch, voltage in sensor_readings.items():
|
||||
data = np.array(sinks[ch].data())
|
||||
# Skip first 10% for filter settling
|
||||
settled = data[len(data) // 10:]
|
||||
mean_v = np.mean(settled)
|
||||
print(f" {ch} | {voltage:.1f} V | {mean_v:.3f} V | {abs(voltage - mean_v):.3f} V")
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
SCO | Input | Recovered (mean) | Error
|
||||
-----|--------|------------------|------
|
||||
3 | 1.8 V | 1.802 V | 0.002 V
|
||||
5 | 4.2 V | 4.198 V | 0.002 V
|
||||
7 | 2.5 V | 2.500 V | 0.000 V
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The first few milliseconds of demodulator output may show transient behavior as the internal filters settle. Skip the initial 10% of samples when computing statistics or making comparisons. The settling time is inversely proportional to the channel bandwidth -- higher SCO numbers settle faster.
|
||||
</Aside>
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="sco_mod Block Reference"
|
||||
description="Full constructor parameters and properties"
|
||||
href="/reference/blocks/#sco_demod"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Constants Reference"
|
||||
description="SCO frequencies, deviation percentages, and input ranges"
|
||||
href="/reference/constants/#subcarrier-oscillators-fm-mode"
|
||||
/>
|
||||
</CardGrid>
|
||||
@ -232,43 +232,6 @@ Apollo USB Signal Generator Demo
|
||||
Envelope std: 0.0712 (noisy = higher variance)
|
||||
```
|
||||
|
||||
## Streaming Signal Generation
|
||||
|
||||
The `generate_usb_baseband` function creates signals in batch (all samples computed at once, returned as a numpy array). For streaming scenarios — where signal generation runs continuously inside a GNU Radio flowgraph — use `usb_signal_source` instead:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Streaming source: generates frames indefinitely
|
||||
tx = usb_signal_source(
|
||||
voice_enabled=True,
|
||||
snr_db=25.0,
|
||||
)
|
||||
|
||||
# Limit output to 10 frames worth of samples
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * 102400)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(tx, head, snk)
|
||||
tb.run()
|
||||
```
|
||||
|
||||
| Approach | `generate_usb_baseband` | `usb_signal_source` |
|
||||
|----------|------------------------|---------------------|
|
||||
| **Runtime** | Pure Python (numpy) | GNU Radio required |
|
||||
| **Output** | numpy array (finite) | Streaming (continuous) |
|
||||
| **Payload** | Per-frame `frame_data` list | `frame_data` message port |
|
||||
| **Use case** | Unit tests, scripting | Flowgraphs, loopback demos |
|
||||
| **Voice** | Internal test tone only | Internal tone or external audio |
|
||||
|
||||
<Aside type="note">
|
||||
Both approaches produce identical signals for the same parameters. The batch function is better for offline analysis; the streaming source is better for real-time flowgraphs and integration testing.
|
||||
</Aside>
|
||||
|
||||
## Signal Structure at a Glance
|
||||
|
||||
The generated baseband signal has this structure:
|
||||
|
||||
@ -1,325 +0,0 @@
|
||||
---
|
||||
title: "Build a Transmit Signal"
|
||||
description: "How to construct an Apollo USB downlink transmit signal using gr-apollo's composable TX blocks."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
gr-apollo provides two approaches to constructing Apollo USB downlink signals:
|
||||
|
||||
- **Batch (pure Python):** `generate_usb_baseband()` produces a complete numpy array in one call. No GNU Radio required. Covered in the [Generate Test Signals](/guides/test-signals/) guide.
|
||||
- **Streaming (GNU Radio blocks):** Six composable TX blocks that mirror the RX chain, producing a continuous complex baseband stream through the GNU Radio scheduler.
|
||||
|
||||
The TX blocks map 1:1 to their RX counterparts -- each modulation stage has a direct demodulation match. If you understand the receive chain, you already understand the transmit chain in reverse.
|
||||
|
||||
## TX/RX Block Pairs
|
||||
|
||||
| TX Block | RX Block | Function |
|
||||
|----------|----------|----------|
|
||||
| `pcm_frame_source` | `pcm_frame_sync` | Frame generation / frame sync |
|
||||
| `nrz_encoder` | (slicer in `bpsk_demod`) | NRZ encoding / NRZ slicing |
|
||||
| `bpsk_subcarrier_mod` | `bpsk_subcarrier_demod` | BPSK modulation / demodulation |
|
||||
| `fm_voice_subcarrier_mod` | `voice_subcarrier_demod` | Voice FM modulation / demodulation |
|
||||
| `pm_mod` | `pm_demod` | PM modulation / demodulation |
|
||||
| `sco_mod` | `sco_demod` | SCO modulation / demodulation |
|
||||
| `usb_signal_source` | `usb_downlink_receiver` | Full chain TX / full chain RX |
|
||||
|
||||
## Signal Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["pcm_frame_source\n32-bit sync + data"]:::data --> B["nrz_encoder\n0/1 → +1/−1"]:::data
|
||||
B --> C["bpsk_subcarrier_mod\n× cos(1.024 MHz)"]:::rf
|
||||
C --> D["add_ff"]:::rf
|
||||
E["fm_voice_subcarrier_mod\nFM → 1.25 MHz"]:::rf --> F["× 0.764\n(1.68/2.2)"]:::rf
|
||||
F --> D
|
||||
D --> G["pm_mod\nexp(j · 0.133 · m(t))"]:::rf
|
||||
G --> H["Complex\nBaseband"]:::rf
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
The PCM path generates frame bits, encodes them as NRZ (+1/-1), and modulates onto a 1.024 MHz BPSK subcarrier. The optional voice path FM-modulates audio onto a 1.25 MHz subcarrier, scaled to maintain the spec power ratio (1.68 Vpp voice / 2.2 Vpp PCM). Both subcarriers are summed, then phase-modulated at 0.133 radians peak deviation to produce complex baseband.
|
||||
|
||||
## Building a Transmit Chain
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Minimal PCM transmitter**
|
||||
|
||||
Connect the four core blocks: frame source, NRZ encoder, BPSK modulator, and PM modulator. This produces a clean complex baseband signal with PCM telemetry only.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_SUBCARRIER_HZ,
|
||||
PCM_WORD_LENGTH,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Stage 1: Generate continuous PCM frame bits (0/1 byte stream)
|
||||
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
|
||||
|
||||
# Stage 2: NRZ encode (byte 0/1 -> float +1/-1 at 5.12 MHz)
|
||||
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Stage 3: BPSK modulate onto 1.024 MHz subcarrier
|
||||
bpsk = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ,
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
|
||||
# Stage 4: Phase modulate to complex baseband
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Limit output to 10 frames worth of samples
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * samples_per_frame)
|
||||
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(frame_src, nrz, bpsk, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
```
|
||||
|
||||
Each bit at 51.2 kbps occupies 100 samples at 5.12 MHz. Each 128-word frame is 1024 bits, so one frame produces 102,400 samples (~20 ms).
|
||||
|
||||
2. **Add voice**
|
||||
|
||||
Insert an FM voice subcarrier with an internal test tone, scale it by the spec ratio, and sum with the PCM subcarrier before PM modulation.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
VOICE_FM_DEVIATION_HZ,
|
||||
VOICE_SUBCARRIER_HZ,
|
||||
)
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# PCM path (same as before)
|
||||
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
|
||||
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
bpsk = bpsk_subcarrier_mod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
tb.connect(frame_src, nrz, bpsk)
|
||||
|
||||
# Voice path: 1 kHz test tone on 1.25 MHz FM subcarrier
|
||||
voice = fm_voice_subcarrier_mod(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
subcarrier_freq=VOICE_SUBCARRIER_HZ,
|
||||
fm_deviation=VOICE_FM_DEVIATION_HZ,
|
||||
tone_freq=1000.0,
|
||||
)
|
||||
# Scale voice to spec ratio: 1.68 Vpp / 2.2 Vpp = 0.764
|
||||
voice_gain = blocks.multiply_const_ff(1.68 / 2.2)
|
||||
tb.connect(voice, voice_gain)
|
||||
|
||||
# Sum subcarriers
|
||||
adder = blocks.add_ff(1)
|
||||
tb.connect(bpsk, (adder, 0))
|
||||
tb.connect(voice_gain, (adder, 1))
|
||||
|
||||
# PM modulate
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.2)) # 200 ms
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(adder, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} samples with PCM + voice")
|
||||
```
|
||||
|
||||
3. **Use external audio**
|
||||
|
||||
Set `audio_input=True` on the voice modulator to accept a float stream instead of the internal tone. This is how you modulate real Apollo crew recordings onto the subcarrier.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from gnuradio import blocks, gr
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
# Load and upsample audio to 5.12 MHz
|
||||
input_rate, audio = wavfile.read("crew_voice.wav")
|
||||
audio_float = audio.astype(np.float32) / 32768.0
|
||||
|
||||
# Resample: input rate -> 8 kHz -> 5.12 MHz (factor 640)
|
||||
from math import gcd
|
||||
g = gcd(8000, input_rate)
|
||||
audio_8k = resample_poly(audio_float, 8000 // g, input_rate // g).astype(np.float32)
|
||||
upsampled = resample_poly(audio_8k, 640, 1).astype(np.float32)
|
||||
|
||||
# Build flowgraph with external audio input
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(upsampled.tolist())
|
||||
|
||||
voice_mod = fm_voice_subcarrier_mod(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
audio_input=True, # accepts float stream input
|
||||
)
|
||||
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, voice_mod, snk)
|
||||
tb.run()
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
When `audio_input=True`, the block changes its input signature from no-input (source) to one float input. The audio must already be at the baseband sample rate (5.12 MHz). The 640:1 upsampling ratio comes from 5,120,000 / 8,000. Use `scipy.signal.resample_poly` for this -- it handles the anti-aliasing filter automatically.
|
||||
</Aside>
|
||||
|
||||
4. **The convenience wrapper**
|
||||
|
||||
The `usb_signal_source` block wires together the full TX chain internally, matching the topology of `usb_downlink_receiver` on the RX side.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Full TX chain in one block
|
||||
tx = usb_signal_source(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=51200,
|
||||
pm_deviation=0.133,
|
||||
voice_enabled=True,
|
||||
voice_tone_hz=1000.0,
|
||||
snr_db=30.0, # add noise for realistic testing
|
||||
)
|
||||
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.5))
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(tx, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
```
|
||||
|
||||
The `usb_signal_source` exposes the same `frame_data` message port as `pcm_frame_source`, allowing dynamic payload injection at runtime.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Dynamic Payloads via Message Port
|
||||
|
||||
The `pcm_frame_source` (and by extension `usb_signal_source`) accepts a `frame_data` message input for injecting custom payload bytes into the next generated frame. This is useful for transmitting specific telemetry patterns, test sequences, or data from an external source.
|
||||
|
||||
```python
|
||||
import pmt
|
||||
|
||||
# Prepare a 124-byte payload (words 5-128 of the PCM frame)
|
||||
payload = bytes([0xDE, 0xAD, 0xBE, 0xEF] * 31) # 124 bytes
|
||||
msg = pmt.init_u8vector(len(payload), list(payload))
|
||||
|
||||
# Post to the frame_data port
|
||||
tx.to_basic_block()._post(pmt.intern("frame_data"), msg)
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Each posted message replaces the payload for the *next* frame only. Subsequent frames revert to zero-fill unless another message arrives. For continuous custom data, post one message per frame period (~20 ms at high rate).
|
||||
</Aside>
|
||||
|
||||
The message port also accepts PDU pairs (`pmt.cons(meta, payload)`) -- the metadata car is ignored, and the payload cdr is used as the frame data bytes.
|
||||
|
||||
## Adding Noise
|
||||
|
||||
Both `usb_signal_source` and the batch `generate_usb_baseband()` support an `snr_db` parameter that adds additive white Gaussian noise to the output:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Streaming (GR)">
|
||||
```python
|
||||
tx = usb_signal_source(
|
||||
snr_db=20.0, # 20 dB SNR
|
||||
)
|
||||
```
|
||||
|
||||
Internally this adds a `noise_source_c` with amplitude computed from the target SNR. The PM signal has unit power (constant envelope), so noise amplitude is `sqrt(1 / (2 * 10^(snr_db/10)))`.
|
||||
</TabItem>
|
||||
<TabItem label="Batch (numpy)">
|
||||
```python
|
||||
from apollo.usb_signal_gen import generate_usb_baseband
|
||||
|
||||
signal, bits = generate_usb_baseband(
|
||||
frames=10,
|
||||
snr_db=20.0,
|
||||
)
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
| SNR | Typical Use |
|
||||
|-----|-------------|
|
||||
| None | Clean signal, verifying block correctness |
|
||||
| 40 dB | Baseline performance measurement |
|
||||
| 30 dB | Realistic strong-signal conditions |
|
||||
| 20 dB | Stress-testing demodulator tracking loops |
|
||||
| 10 dB | Threshold testing, error-rate characterization |
|
||||
|
||||
## Loopback Testing
|
||||
|
||||
The most common use of the TX chain is loopback testing: generate a signal and immediately decode it. This verifies the full modulation/demodulation round-trip:
|
||||
|
||||
```python
|
||||
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_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
tx = usb_signal_source(voice_enabled=True, snr_db=30.0)
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.4))
|
||||
rx = usb_downlink_receiver(output_format="raw")
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
print(f"TX -> RX loopback: recovered {snk.num_messages()} frames")
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
The first 1--2 frames are typically lost to PLL settling in the receiver. This is physically correct behavior -- the carrier tracking loop needs time to acquire lock. Generate at least 5 frames for reliable recovery; the [loopback demo](/guides/run-demos/#loopback) uses 10 by default.
|
||||
</Aside>
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Block Reference"
|
||||
description="Full API documentation for all TX and RX blocks"
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Run the Demos"
|
||||
description="Ready-to-run scripts for loopback, voice, and full downlink testing"
|
||||
href="/guides/run-demos/"
|
||||
/>
|
||||
</CardGrid>
|
||||
@ -227,41 +227,6 @@ print("Wrote voice.wav")
|
||||
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>
|
||||
|
||||
## Modulating Voice (TX Side)
|
||||
|
||||
The transmit-side counterpart to `voice_subcarrier_demod` is `fm_voice_subcarrier_mod`. It FM-modulates audio onto the 1.25 MHz subcarrier — the exact inverse of the receive chain.
|
||||
|
||||
### Internal Test Tone
|
||||
|
||||
For testing without an audio source, the modulator generates a sine tone internally:
|
||||
|
||||
```python
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
# Source block — no input needed
|
||||
voice_mod = fm_voice_subcarrier_mod(tone_freq=1000.0)
|
||||
```
|
||||
|
||||
### External Audio Input
|
||||
|
||||
To modulate real audio (like Apollo mission crew recordings), set `audio_input=True`. This changes the block from a source to a filter that accepts a float input:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
voice_mod = fm_voice_subcarrier_mod(audio_input=True)
|
||||
|
||||
# Audio must be upsampled to the baseband rate (5.12 MHz)
|
||||
# before feeding into the modulator.
|
||||
# See examples/voice_subcarrier_demo.py for the full pipeline.
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The `voice_subcarrier_demo.py` script handles the full pipeline: load WAV, resample to 8 kHz, upsample to 5.12 MHz, modulate, demodulate, and save. Run it with `uv run python examples/voice_subcarrier_demo.py input.wav`.
|
||||
</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:
|
||||
|
||||
@ -3,7 +3,7 @@ title: gr-apollo
|
||||
description: Apollo Unified S-Band decoder for GNU Radio 3.10+
|
||||
template: splash
|
||||
hero:
|
||||
tagline: Generate and decode Apollo-era spacecraft telemetry with modern software-defined radio
|
||||
tagline: Decode Apollo-era spacecraft telemetry with modern software-defined radio
|
||||
actions:
|
||||
- text: Get Started
|
||||
link: /getting-started/introduction/
|
||||
@ -29,8 +29,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
Bridge decoded telemetry directly to the Virtual AGC emulator
|
||||
via TCP socket — send DSKY commands, receive downlink data.
|
||||
</Card>
|
||||
<Card title="Full TX Chain" icon="puzzle">
|
||||
Build complete transmit signals with streaming GNU Radio blocks —
|
||||
PCM telemetry, FM voice, SCO channels, and composite USB carriers.
|
||||
<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>
|
||||
|
||||
@ -923,362 +923,3 @@ The internal blocks are exposed as instance attributes for runtime inspection or
|
||||
| `self.bpsk` | `bpsk_demod` | BPSK demodulator |
|
||||
| `self.frame_sync` | `pcm_frame_sync` | Frame synchronizer |
|
||||
| `self.demux` | `pcm_demux` | Frame demultiplexer |
|
||||
|
||||
---
|
||||
|
||||
## Transmit Chain
|
||||
|
||||
### `FrameSourceEngine` / `pcm_frame_source`
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Pure Python Engine">
|
||||
|
||||
**Module:** `apollo.pcm_frame_source`
|
||||
**Type:** Pure-Python class
|
||||
**Purpose:** Generate PCM telemetry frames as bit lists. Maintains rolling frame counter (1-50), auto-complements sync core on odd frames.
|
||||
|
||||
```python
|
||||
from apollo.pcm_frame_source import FrameSourceEngine
|
||||
|
||||
engine = FrameSourceEngine(bit_rate=51200)
|
||||
bits = engine.next_frame()
|
||||
```
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high, 128 words/frame) or `1600` (low, 200 words/frame) |
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `next_frame` | `(data: bytes \| None) -> list[int]` | Generate next frame as bit list. `data=None` gives zero-fill. Returns list of 0/1 values, length = words_per_frame * 8 |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `bit_rate` | `int` | PCM bit rate in bps |
|
||||
| `words_per_frame` | `int` | 128 (high) or 200 (low) |
|
||||
| `frame_counter` | `int` | Current frame number (1-50) |
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="GNU Radio Block">
|
||||
|
||||
**Module:** `apollo.pcm_frame_source`
|
||||
**Type:** `gr.sync_block`
|
||||
**Purpose:** GNU Radio source block producing continuous PCM frame bit stream. Outputs bytes (0 or 1). Accepts `frame_data` message input for dynamic payload injection.
|
||||
|
||||
```python
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
blk = pcm_frame_source(bit_rate=51200)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| (none) | Input | (none) | Source block -- no streaming input |
|
||||
| `out0` | Output | `byte` | NRZ bit stream (values 0 or 1) |
|
||||
| `"frame_data"` | Input | Message | PMT u8vector or PDU for dynamic payload |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high, 128 words/frame) or `1600` (low, 200 words/frame) |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
Uses `FrameSourceEngine` internally with a `deque` bit buffer to bridge frame-granularity generation and sample-granularity scheduling.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
### `nrz_encoder`
|
||||
|
||||
**Module:** `apollo.nrz_encoder`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Convert PCM bit stream (byte 0/1) to NRZ float waveform (+1.0/-1.0) at the output sample rate.
|
||||
|
||||
```python
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
blk = nrz_encoder(bit_rate=51200, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `byte` | Bit stream from `pcm_frame_source` (values 0 or 1) |
|
||||
| `out0` | Output | `float` | NRZ waveform (+1.0 / -1.0) at `sample_rate` |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate in bps |
|
||||
| `sample_rate` | `float` | `5120000` | Output sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `samples_per_bit` | `int` | Samples per bit period: `sample_rate / bit_rate` |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> char_to_float -> multiply_const_ff(2.0) -> add_const_ff(-1.0) -> repeat(samples_per_bit) -> output`
|
||||
|
||||
---
|
||||
|
||||
### `bpsk_subcarrier_mod`
|
||||
|
||||
**Module:** `apollo.bpsk_subcarrier_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Modulate NRZ float data onto 1.024 MHz cosine subcarrier via multiplication: `output(t) = nrz(t) * cos(2*pi*f_sc*t)`.
|
||||
|
||||
```python
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
blk = bpsk_subcarrier_mod(subcarrier_freq=1_024_000, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | NRZ waveform from `nrz_encoder` |
|
||||
| `out0` | Output | `float` | BPSK modulated subcarrier |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `subcarrier_freq` | `float` | `1024000` | Subcarrier frequency in Hz (`PCM_SUBCARRIER_HZ`) |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `subcarrier_freq` | `float` | Subcarrier frequency in Hz |
|
||||
| `sample_rate` | `float` | Sample rate in Hz |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input (NRZ) -> (mixer, port 0); sig_source_f(cos, subcarrier_freq) -> (mixer, port 1); mixer -> output`
|
||||
|
||||
---
|
||||
|
||||
### `fm_voice_subcarrier_mod`
|
||||
|
||||
**Module:** `apollo.fm_voice_subcarrier_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** FM-modulate audio onto 1.25 MHz subcarrier with +/-29 kHz deviation. Two modes: internal test tone (source block) or external audio input.
|
||||
|
||||
```python
|
||||
# Internal test tone (no input)
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
voice = fm_voice_subcarrier_mod(tone_freq=1000.0)
|
||||
|
||||
# External audio input
|
||||
voice = fm_voice_subcarrier_mod(audio_input=True)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
<Aside type="caution">
|
||||
The I/O signature changes based on the `audio_input` parameter. When `audio_input=False` (default), the block is a source with no streaming input. When `audio_input=True`, it accepts one float input for the external audio stream.
|
||||
</Aside>
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` (or none) | External audio signal when `audio_input=True`; no input when `False` |
|
||||
| `out0` | Output | `float` | FM subcarrier at `subcarrier_freq` |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
| `subcarrier_freq` | `float` | `1250000` | Voice subcarrier center frequency (`VOICE_SUBCARRIER_HZ`) |
|
||||
| `fm_deviation` | `float` | `29000` | FM deviation in Hz (`VOICE_FM_DEVIATION_HZ`) |
|
||||
| `tone_freq` | `float` | `1000.0` | Internal test tone frequency when `audio_input=False` |
|
||||
| `audio_input` | `bool` | `False` | When `True`, accepts external float audio stream |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `tone_freq` | `float` | Test tone frequency in Hz |
|
||||
| `subcarrier_freq` | `float` | Subcarrier center frequency in Hz |
|
||||
| `fm_deviation` | `float` | FM deviation in Hz |
|
||||
| `audio_input` | `bool` | Whether block accepts external audio |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`[audio source or input] -> frequency_modulator_fc(sensitivity) -> (mixer, 0); sig_source_c(subcarrier_freq) -> (mixer, 1); mixer -> complex_to_real -> output`
|
||||
|
||||
---
|
||||
|
||||
### `pm_mod`
|
||||
|
||||
**Module:** `apollo.pm_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Apply phase modulation at 0.133 rad peak deviation, producing complex baseband `exp(j * phi(t))`.
|
||||
|
||||
```python
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
blk = pm_mod(pm_deviation=0.133, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | Composite modulating signal (sum of subcarriers) |
|
||||
| `out0` | Output | `complex` | PM complex baseband |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `pm_deviation` | `float` | `0.133` | Peak PM deviation in radians (`PM_PEAK_DEVIATION_RAD`) |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Runtime Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `get_pm_deviation` | `() -> float` | Read current PM deviation |
|
||||
| `set_pm_deviation` | `(dev: float) -> None` | Update PM deviation at runtime |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> multiply_const_ff(pm_deviation) -> phase_modulator_fc(1.0) -> output`
|
||||
|
||||
<Aside type="note">
|
||||
The sensitivity is split between two stages: `multiply_const_ff` applies the deviation scaling so that `phase_modulator_fc` sees pre-scaled values with a fixed sensitivity of 1.0. This makes runtime deviation changes a simple gain update via `set_pm_deviation`.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
### `sco_mod`
|
||||
|
||||
**Module:** `apollo.sco_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Modulate 0-5V sensor voltage onto an FM subcarrier oscillator tone. Only used in FM downlink mode. 9 channels available (14.5 kHz to 165 kHz).
|
||||
|
||||
```python
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
blk = sco_mod(sco_number=5, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | Sensor voltage (0.0 to 5.0 V) |
|
||||
| `out0` | Output | `float` | FM subcarrier tone |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sco_number` | `int` | `1` | SCO channel number (1-9). Raises `ValueError` if invalid |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `center_freq` | `float` | Center frequency of this SCO channel in Hz |
|
||||
| `deviation_hz` | `float` | FM deviation in Hz (+/- 7.5% of center) |
|
||||
| `sco_number` | `int` | SCO channel number (1-9) |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> add_const_ff(-2.5) -> multiply_const_ff(1/2.5) -> frequency_modulator_fc -> (mixer, 0); sig_source_c(center_freq) -> (mixer, 1); mixer -> complex_to_real -> output`
|
||||
|
||||
#### 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 |
|
||||
|
||||
---
|
||||
|
||||
### `usb_signal_source`
|
||||
|
||||
**Module:** `apollo.usb_signal_source`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Complete Apollo USB transmit chain in one block -- the TX counterpart to `usb_downlink_receiver`.
|
||||
|
||||
```python
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
blk = usb_signal_source(voice_enabled=True, snr_db=20.0)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| (none) | Input | (none) | Source block -- no streaming input |
|
||||
| `out0` | Output | `complex` | PM-modulated complex baseband at `sample_rate` |
|
||||
| `"frame_data"` | Input | Message | Forwarded to `pcm_frame_source` for dynamic payload |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sample_rate` | `float` | `5120000` | Baseband sample rate in Hz |
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate in bps |
|
||||
| `pm_deviation` | `float` | `0.133` | PM peak deviation in radians |
|
||||
| `voice_enabled` | `bool` | `False` | Include 1.25 MHz FM voice subcarrier |
|
||||
| `voice_tone_hz` | `float` | `1000.0` | Voice test tone frequency when `voice_enabled=True` |
|
||||
| `snr_db` | `float \| None` | `None` | Add AWGN at this SNR. `None` means no noise |
|
||||
|
||||
#### Internal Signal Chain
|
||||
|
||||
```
|
||||
pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -+-> add_ff -> pm_mod -> [+AWGN] -> output
|
||||
|
|
||||
fm_voice_subcarrier_mod (x0.764) --+
|
||||
(only when voice_enabled=True)
|
||||
```
|
||||
|
||||
Voice scale factor: `1.68 / 2.2 = 0.764` (per IMPL_SPEC power ratio -- PCM at 2.2 Vpp, voice at 1.68 Vpp).
|
||||
|
||||
#### Sub-Block Access
|
||||
|
||||
The internal blocks are exposed as instance attributes for runtime inspection or parameter adjustment:
|
||||
|
||||
| Attribute | Type | Block |
|
||||
|-----------|------|-------|
|
||||
| `self.frame_src` | `pcm_frame_source` | Frame generator |
|
||||
| `self.nrz` | `nrz_encoder` | NRZ line encoder |
|
||||
| `self.bpsk` | `bpsk_subcarrier_mod` | BPSK modulator |
|
||||
| `self.voice` | `fm_voice_subcarrier_mod` | Voice modulator (when `voice_enabled`) |
|
||||
| `self.voice_gain` | `multiply_const_ff` | Voice level scaling (when `voice_enabled`) |
|
||||
| `self.adder` | `add_ff` | Subcarrier summer (when `voice_enabled`) |
|
||||
| `self.pm` | `pm_mod` | Phase modulator |
|
||||
| `self.noise` | `noise_source_c` | AWGN source (when `snr_db` is set) |
|
||||
|
||||
@ -5,7 +5,7 @@ description: "Complete reference for all Apollo Unified S-Band system constants
|
||||
|
||||
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)](https://archive.org/details/apollo-telecommunications) via the IMPLEMENTATION_SPEC.md section references noted in each table.
|
||||
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
|
||||
@ -253,7 +253,7 @@ AGC_TELECOM_CHANNELS = frozenset({
|
||||
|
||||
### Downlink List Type IDs
|
||||
|
||||
From `DecodeDigitalDownlink.c` in the [Virtual AGC project](https://github.com/virtualagc/virtualagc):
|
||||
From `DecodeDigitalDownlink.c` in the Virtual AGC project:
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
|
||||
@ -214,7 +214,7 @@ def bits_to_sync_word(bits: list[int]) -> int
|
||||
|
||||
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`](https://github.com/virtualagc/virtualagc) in the Virtual AGC project.
|
||||
These functions are direct ports of `FormIoPacket()` and `ParseIoPacket()` from `yaAGC/SocketAPI.c`.
|
||||
|
||||
### Packet Bit Layout
|
||||
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
import React from "react";
|
||||
import type { RenderFunctionInput } from "astro-opengraph-images";
|
||||
|
||||
export async function render({
|
||||
title,
|
||||
description,
|
||||
}: RenderFunctionInput): Promise<React.ReactNode> {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: "#0B1426",
|
||||
padding: "60px 70px",
|
||||
fontFamily: "Inter",
|
||||
}}
|
||||
>
|
||||
{/* Signal arc decorations — top right */}
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
width="200"
|
||||
height="200"
|
||||
style={{ position: "absolute", top: "30", right: "50", opacity: 0.15 }}
|
||||
>
|
||||
<path
|
||||
d="M40 180 Q100 60 160 180"
|
||||
fill="none"
|
||||
stroke="#FC3D21"
|
||||
stroke-width="8"
|
||||
/>
|
||||
<path
|
||||
d="M70 180 Q100 90 130 180"
|
||||
fill="none"
|
||||
stroke="#FC3D21"
|
||||
stroke-width="6"
|
||||
/>
|
||||
<path
|
||||
d="M90 180 Q100 120 110 180"
|
||||
fill="none"
|
||||
stroke="#FC3D21"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Top: site name */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
color: "#7EB8FF",
|
||||
fontSize: 28,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
gr-apollo
|
||||
</div>
|
||||
|
||||
{/* Middle: title + description */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
flexGrow: "1",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#FFFFFF",
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
color: "#8BA4C4",
|
||||
fontSize: 26,
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: tagline */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderTop: "1px solid #1E3A5F",
|
||||
paddingTop: "20px",
|
||||
color: "#4A6A8A",
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
<span>Apollo Unified S-Band Decoder</span>
|
||||
<span>GNU Radio 3.10+</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apollo AGC Integration Demo -- full communications loop with Virtual AGC.
|
||||
|
||||
Demonstrates the complete Apollo unified S-band communications path:
|
||||
|
||||
yaAGC (emulator)
|
||||
| DNTM1/DNTM2 telemetry via TCP
|
||||
v
|
||||
agc_bridge
|
||||
| PDU message
|
||||
v
|
||||
downlink_decoder --> print decoded telemetry
|
||||
^
|
||||
| PDU frames
|
||||
usb_downlink_receiver (RX chain)
|
||||
^
|
||||
| complex baseband
|
||||
usb_signal_source (TX chain)
|
||||
|
||||
And the uplink path:
|
||||
|
||||
DSKY commands
|
||||
|
|
||||
uplink_encoder
|
||||
| (channel, value) pairs
|
||||
v
|
||||
agc_bridge --> yaAGC (INLINK channel 045)
|
||||
|
||||
Prerequisites:
|
||||
1. Install Virtual AGC: https://www.ibiblio.org/apollo/
|
||||
2. Start yaAGC with a mission (e.g., Luminary099 for Apollo 11 LM):
|
||||
$ yaAGC --core=Luminary099.bin --port=19697
|
||||
3. Optionally start yaDSKY2 for visual display:
|
||||
$ yaDSKY2 --port=19698
|
||||
|
||||
Usage:
|
||||
uv run python examples/agc_loopback_demo.py
|
||||
uv run python examples/agc_loopback_demo.py --host 192.168.1.100
|
||||
uv run python examples/agc_loopback_demo.py --port 19697
|
||||
uv run python examples/agc_loopback_demo.py --send-v16n36 # request time display
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from apollo.agc_bridge import AGCBridgeClient
|
||||
from apollo.constants import (
|
||||
AGC_CH_DNTM1,
|
||||
AGC_CH_DNTM2,
|
||||
AGC_CH_OUTLINK,
|
||||
AGC_PORT_BASE,
|
||||
)
|
||||
from apollo.downlink_decoder import DownlinkEngine
|
||||
from apollo.uplink_encoder import UplinkEncoder
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Apollo AGC integration demo -- connect to yaAGC emulator"
|
||||
)
|
||||
parser.add_argument("--host", default="localhost", help="yaAGC host (default: localhost)")
|
||||
parser.add_argument("--port", type=int, default=AGC_PORT_BASE,
|
||||
help="yaAGC port (default: 19697)")
|
||||
parser.add_argument("--duration", type=float, default=10.0, help="Run duration in seconds")
|
||||
parser.add_argument("--send-v16n36", action="store_true",
|
||||
help="Send V16N36E (display time) to AGC")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("Apollo AGC Integration Demo")
|
||||
print("=" * 60)
|
||||
print(f" Target: {args.host}:{args.port}")
|
||||
print(f" Duration: {args.duration} seconds")
|
||||
print()
|
||||
|
||||
# Downlink decoder accumulates telemetry words
|
||||
decoder = DownlinkEngine()
|
||||
packet_count = 0
|
||||
telemetry_words = 0
|
||||
|
||||
def on_packet(channel: int, value: int):
|
||||
nonlocal packet_count, telemetry_words
|
||||
packet_count += 1
|
||||
|
||||
if channel in (AGC_CH_DNTM1, AGC_CH_DNTM2):
|
||||
telemetry_words += 1
|
||||
decoder.feed_agc_word(channel, value)
|
||||
elif channel == AGC_CH_OUTLINK:
|
||||
print(f" OUTLINK: ch={channel:03o} val={value:05o} ({value})")
|
||||
|
||||
def on_status(state: str):
|
||||
print(f" Connection: {state}")
|
||||
|
||||
# Connect to yaAGC
|
||||
client = AGCBridgeClient(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
channel_filter=None, # accept all channels for this demo
|
||||
on_packet=on_packet,
|
||||
on_status=on_status,
|
||||
)
|
||||
|
||||
print(f"Connecting to yaAGC at {args.host}:{args.port}...")
|
||||
client.start()
|
||||
|
||||
# Wait for connection
|
||||
for _ in range(20): # 10 seconds max
|
||||
if client.connected:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
if not client.connected:
|
||||
print()
|
||||
print("Could not connect to yaAGC.")
|
||||
print()
|
||||
print("Make sure yaAGC is running:")
|
||||
print(f" yaAGC --core=Luminary099.bin --port={args.port}")
|
||||
print()
|
||||
print("Or try a different host/port:")
|
||||
print(" python examples/agc_loopback_demo.py --host <ip> --port <port>")
|
||||
client.stop()
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
|
||||
# Optionally send a DSKY command
|
||||
if args.send_v16n36:
|
||||
print("Sending V16N36E (display time)...")
|
||||
encoder = UplinkEncoder()
|
||||
pairs = encoder.encode_verb_noun(verb=16, noun=36)
|
||||
for channel, value in pairs:
|
||||
client.send(channel, value)
|
||||
time.sleep(0.1) # pace for UPRUPT processing
|
||||
print(f" Sent {len(pairs)} uplink words")
|
||||
print()
|
||||
|
||||
# Collect telemetry for the specified duration
|
||||
print(f"Collecting telemetry for {args.duration} seconds...")
|
||||
print("-" * 60)
|
||||
|
||||
start_time = time.time()
|
||||
last_snapshot_count = 0
|
||||
|
||||
try:
|
||||
while time.time() - start_time < args.duration:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Check for new telemetry snapshots
|
||||
snapshots = decoder._completed_snapshots
|
||||
if len(snapshots) > last_snapshot_count:
|
||||
for snap in snapshots[last_snapshot_count:]:
|
||||
list_type = snap.get("list_type_id", "?")
|
||||
list_name = snap.get("list_name", "Unknown")
|
||||
n_words = snap.get("word_count", 0)
|
||||
print(f" Telemetry snapshot: {list_name} "
|
||||
f"(type {list_type}), {n_words} words")
|
||||
|
||||
# Show first few words
|
||||
words = snap.get("words", [])
|
||||
for i, val in enumerate(words[:5]):
|
||||
print(f" [{i:03d}] = {val:05o} ({val})")
|
||||
if len(words) > 5:
|
||||
print(f" ... ({len(words) - 5} more words)")
|
||||
last_snapshot_count = len(snapshots)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print("Interrupted.")
|
||||
|
||||
print("-" * 60)
|
||||
print()
|
||||
print("Summary:")
|
||||
print(f" Total packets received: {packet_count}")
|
||||
print(f" Telemetry words: {telemetry_words}")
|
||||
print(f" Telemetry snapshots: {len(decoder._completed_snapshots)}")
|
||||
print(f" Duration: {time.time() - start_time:.1f} seconds")
|
||||
|
||||
client.stop()
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@ -1,295 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apollo Full Downlink Demo -- PCM telemetry + crew voice on one carrier.
|
||||
|
||||
Reconstructs the complete Apollo USB downlink signal: PCM telemetry frames
|
||||
on the 1.024 MHz BPSK subcarrier PLUS crew voice on the 1.25 MHz FM
|
||||
subcarrier, both phase-modulated onto a single complex carrier.
|
||||
|
||||
Then receives the signal, splitting it into:
|
||||
- Decoded PCM telemetry frames (digital data)
|
||||
- Recovered crew voice audio (saved as WAV)
|
||||
|
||||
This is the full spacecraft-to-ground communications path:
|
||||
|
||||
TX (spacecraft):
|
||||
pcm_frame_source -> nrz -> bpsk_mod (1.024 MHz) ─┐
|
||||
crew_audio -> fm_voice_mod (1.25 MHz, +/-29kHz) ──┤ scale 1.68/2.2
|
||||
├─> add -> pm_mod -> [RF]
|
||||
|
||||
RX (ground station):
|
||||
[RF] -> pm_demod ──> subcarrier_extract -> bpsk_demod -> frame_sync ──> PCM frames
|
||||
└─> voice_subcarrier_demod ──> crew audio (8 kHz)
|
||||
|
||||
Usage:
|
||||
uv run python examples/full_downlink_demo.py examples/audio/apollo11_crew.wav
|
||||
uv run python examples/full_downlink_demo.py input.wav --snr 25 --play
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from math import gcd
|
||||
|
||||
import numpy as np
|
||||
import pmt
|
||||
from gnuradio import blocks, gr
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_SUBCARRIER_HZ,
|
||||
PCM_WORD_LENGTH,
|
||||
PM_PEAK_DEVIATION_RAD,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
VOICE_FM_DEVIATION_HZ,
|
||||
VOICE_SUBCARRIER_HZ,
|
||||
)
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
from apollo.pcm_demux import DemuxEngine
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
from apollo.pm_demod import pm_demod
|
||||
from apollo.pm_mod import pm_mod
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||
|
||||
|
||||
def load_and_upsample_audio(audio_path, sample_rate):
|
||||
"""Load audio file and upsample to baseband rate."""
|
||||
input_rate, audio_data = wavfile.read(audio_path)
|
||||
if audio_data.ndim > 1:
|
||||
audio_data = audio_data[:, 0]
|
||||
|
||||
# Normalize to [-1, 1]
|
||||
if audio_data.dtype == np.int16:
|
||||
audio_float = audio_data.astype(np.float32) / 32768.0
|
||||
else:
|
||||
audio_float = audio_data.astype(np.float32)
|
||||
|
||||
duration = len(audio_float) / input_rate
|
||||
|
||||
# Resample to 8 kHz first
|
||||
audio_rate = 8000
|
||||
if input_rate != audio_rate:
|
||||
g = gcd(audio_rate, input_rate)
|
||||
audio_float = resample_poly(audio_float, audio_rate // g, input_rate // g).astype(
|
||||
np.float32
|
||||
)
|
||||
|
||||
# Upsample to baseband
|
||||
g = gcd(sample_rate, audio_rate)
|
||||
upsampled = resample_poly(audio_float, sample_rate // g, audio_rate // g).astype(np.float32)
|
||||
|
||||
return upsampled, duration, audio_rate
|
||||
|
||||
|
||||
def build_tx_signal(audio_samples, n_samples, sample_rate, snr_db):
|
||||
"""Build the combined TX signal: PCM + voice -> PM modulation.
|
||||
|
||||
Assembles the individual blocks manually (not using usb_signal_source)
|
||||
so we can inject external audio into the voice channel.
|
||||
"""
|
||||
tb = gr.top_block()
|
||||
|
||||
# --- PCM telemetry path ---
|
||||
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
|
||||
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=sample_rate)
|
||||
bpsk = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
tb.connect(frame_src, nrz, bpsk)
|
||||
|
||||
# --- Voice subcarrier path (external audio) ---
|
||||
voice_src = blocks.vector_source_f(audio_samples[:n_samples].tolist())
|
||||
voice_mod = fm_voice_subcarrier_mod(
|
||||
sample_rate=sample_rate,
|
||||
subcarrier_freq=VOICE_SUBCARRIER_HZ,
|
||||
fm_deviation=VOICE_FM_DEVIATION_HZ,
|
||||
audio_input=True,
|
||||
)
|
||||
# Scale voice relative to PCM: 1.68/2.2 per IMPL_SPEC
|
||||
voice_gain = blocks.multiply_const_ff(1.68 / 2.2)
|
||||
tb.connect(voice_src, voice_mod, voice_gain)
|
||||
|
||||
# --- Sum subcarriers ---
|
||||
adder = blocks.add_ff(1)
|
||||
tb.connect(bpsk, (adder, 0))
|
||||
tb.connect(voice_gain, (adder, 1))
|
||||
|
||||
# --- PM modulation ---
|
||||
pm = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
tb.connect(adder, pm, head)
|
||||
|
||||
# --- Optional AWGN ---
|
||||
if snr_db is not None:
|
||||
import math
|
||||
|
||||
noise_power = 1.0 / (10.0 ** (snr_db / 10.0))
|
||||
noise_amp = math.sqrt(noise_power / 2.0)
|
||||
noise = blocks.vector_source_c(
|
||||
(np.random.randn(n_samples) + 1j * np.random.randn(n_samples)).astype(np.complex64)
|
||||
* noise_amp
|
||||
)
|
||||
summer = blocks.add_cc(1)
|
||||
snk = blocks.vector_sink_c()
|
||||
tb.connect(head, (summer, 0))
|
||||
tb.connect(noise, (summer, 1))
|
||||
tb.connect(summer, snk)
|
||||
else:
|
||||
snk = blocks.vector_sink_c()
|
||||
tb.connect(head, snk)
|
||||
|
||||
tb.run()
|
||||
return np.array(snk.data())
|
||||
|
||||
|
||||
def receive_pcm(signal_data, sample_rate):
|
||||
"""Run the PCM receive chain and return decoded frames."""
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_c(signal_data.tolist())
|
||||
rx = usb_downlink_receiver(
|
||||
sample_rate=sample_rate,
|
||||
bit_rate=PCM_HIGH_BIT_RATE,
|
||||
output_format="raw",
|
||||
)
|
||||
snk = blocks.message_debug()
|
||||
tb.connect(src, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
return snk
|
||||
|
||||
|
||||
def receive_voice(signal_data, sample_rate, audio_rate=8000):
|
||||
"""Run the voice receive chain and return recovered audio samples."""
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_c(signal_data.tolist())
|
||||
pm = pm_demod(sample_rate=sample_rate)
|
||||
voice = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=audio_rate)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, pm, voice, snk)
|
||||
tb.run()
|
||||
return np.array(snk.data(), dtype=np.float32)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Full Apollo downlink: PCM telemetry + crew voice")
|
||||
parser.add_argument("audio", help="Input audio WAV file (crew voice)")
|
||||
parser.add_argument(
|
||||
"--output", "-o", default=None, help="Output WAV path (default: <input>_fullchain.wav)"
|
||||
)
|
||||
parser.add_argument("--snr", type=float, default=None, help="Add AWGN noise at this SNR in dB")
|
||||
parser.add_argument("--play", action="store_true", help="Play recovered voice with aplay")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output is None:
|
||||
stem = args.audio.rsplit(".", 1)[0]
|
||||
args.output = f"{stem}_fullchain.wav"
|
||||
|
||||
sample_rate = int(SAMPLE_RATE_BASEBAND)
|
||||
audio_rate = 8000
|
||||
|
||||
print("=" * 60)
|
||||
print("Apollo Full Downlink Demo")
|
||||
print(" PCM telemetry (1.024 MHz BPSK) + crew voice (1.25 MHz FM)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Load and prepare audio
|
||||
print("Loading crew voice audio...")
|
||||
audio_upsampled, duration, _ = load_and_upsample_audio(args.audio, sample_rate)
|
||||
print(f" Source: {args.audio} ({duration:.2f}s)")
|
||||
print(f" Upsampled: {len(audio_upsampled):,} samples at {sample_rate / 1e6:.2f} MHz")
|
||||
print()
|
||||
|
||||
# Calculate frame timing
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * sample_rate / PCM_HIGH_BIT_RATE)
|
||||
n_frames = int(duration * 50) + 2 # 50 fps + margin
|
||||
n_samples = min(len(audio_upsampled), n_frames * samples_per_frame)
|
||||
|
||||
print(f" PCM frames: ~{n_frames} at 50 fps")
|
||||
print(f" Signal: {n_samples:,} samples ({n_samples / sample_rate:.2f}s)")
|
||||
snr_desc = f"{args.snr} dB" if args.snr is not None else "clean"
|
||||
print(f" SNR: {snr_desc}")
|
||||
print()
|
||||
|
||||
# === TRANSMIT ===
|
||||
print("TX: Building combined PCM + voice signal...")
|
||||
t0 = time.time()
|
||||
signal = build_tx_signal(audio_upsampled, n_samples, sample_rate, args.snr)
|
||||
t_tx = time.time() - t0
|
||||
print(f" Generated {len(signal):,} complex samples ({t_tx:.1f}s)")
|
||||
|
||||
# Verify constant envelope (PM property)
|
||||
envelope = np.abs(signal[:10000])
|
||||
if args.snr is None:
|
||||
env_std = np.std(envelope)
|
||||
print(f" PM envelope std: {env_std:.6f} (should be ~0 for clean)")
|
||||
print()
|
||||
|
||||
# === RECEIVE: PCM telemetry ===
|
||||
print("RX: Decoding PCM telemetry frames...")
|
||||
t0 = time.time()
|
||||
frame_sink = receive_pcm(signal, sample_rate)
|
||||
t_pcm = time.time() - t0
|
||||
n_recovered = frame_sink.num_messages()
|
||||
print(f" Recovered {n_recovered} PCM frames ({t_pcm:.1f}s)")
|
||||
|
||||
if n_recovered > 0:
|
||||
demux = DemuxEngine(output_format="raw")
|
||||
print()
|
||||
for i in range(min(n_recovered, 5)):
|
||||
msg = frame_sink.get_message(i)
|
||||
payload = pmt.cdr(msg) if pmt.is_pair(msg) else msg
|
||||
frame_bytes = bytes(pmt.u8vector_elements(payload))
|
||||
result = demux.process_frame(frame_bytes)
|
||||
fid = result.get("sync", {}).get("frame_id", 0)
|
||||
n_words = len(result.get("words", []))
|
||||
parity = "odd" if fid % 2 == 1 else "even"
|
||||
print(f" Frame {i + 1}: ID={fid:>2} ({parity}), {n_words} data words")
|
||||
if n_recovered > 5:
|
||||
print(f" ... ({n_recovered - 5} more frames)")
|
||||
print()
|
||||
|
||||
# === RECEIVE: crew voice ===
|
||||
print("RX: Demodulating crew voice (1.25 MHz FM)...")
|
||||
t0 = time.time()
|
||||
recovered_audio = receive_voice(signal, sample_rate, audio_rate)
|
||||
t_voice = time.time() - t0
|
||||
print(f" Recovered {len(recovered_audio):,} audio samples ({t_voice:.1f}s)")
|
||||
print(f" Duration: {len(recovered_audio) / audio_rate:.2f}s at {audio_rate} Hz")
|
||||
|
||||
# Normalize and save
|
||||
peak = np.max(np.abs(recovered_audio))
|
||||
if peak > 0:
|
||||
recovered_audio = recovered_audio / peak * 0.9
|
||||
recovered_int16 = (recovered_audio * 32767).astype(np.int16)
|
||||
wavfile.write(args.output, audio_rate, recovered_int16)
|
||||
print(f" Saved: {args.output}")
|
||||
print()
|
||||
|
||||
# === SUMMARY ===
|
||||
print("=" * 60)
|
||||
print(f" TX: {n_samples / sample_rate:.2f}s of combined PCM + voice")
|
||||
print(f" RX: {n_recovered} PCM frames + {len(recovered_audio) / audio_rate:.2f}s crew voice")
|
||||
print(f" SNR: {snr_desc}")
|
||||
print("=" * 60)
|
||||
|
||||
if args.play:
|
||||
import subprocess
|
||||
|
||||
print()
|
||||
print("Playing recovered crew voice...")
|
||||
subprocess.run(["aplay", args.output], check=False)
|
||||
else:
|
||||
print()
|
||||
print(f"Play voice: aplay {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apollo USB Loopback Demo -- streaming TX -> RX round-trip.
|
||||
|
||||
Demonstrates the full gr-apollo block chain using GNU Radio streaming blocks:
|
||||
|
||||
TX: pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -> pm_mod
|
||||
RX: pm_demod -> subcarrier_extract -> bpsk_demod -> pcm_frame_sync -> pcm_demux
|
||||
|
||||
All wrapped in the convenience blocks:
|
||||
usb_signal_source -> usb_downlink_receiver
|
||||
|
||||
Prints decoded frames as they arrive, including sync word analysis.
|
||||
|
||||
Usage:
|
||||
uv run python examples/loopback_demo.py
|
||||
uv run python examples/loopback_demo.py --voice # include voice subcarrier
|
||||
uv run python examples/loopback_demo.py --snr 20 # add noise at 20 dB SNR
|
||||
uv run python examples/loopback_demo.py --frames 20 # generate 20 frames
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import pmt
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_WORD_LENGTH,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
from apollo.pcm_demux import DemuxEngine
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Apollo USB loopback demo")
|
||||
parser.add_argument("--frames", type=int, default=10, help="Number of frames to generate")
|
||||
parser.add_argument("--snr", type=float, default=None, help="SNR in dB (None = no noise)")
|
||||
parser.add_argument("--voice", action="store_true", help="Enable voice subcarrier")
|
||||
args = parser.parse_args()
|
||||
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
n_samples = args.frames * samples_per_frame
|
||||
|
||||
print("=" * 60)
|
||||
print("Apollo USB Loopback Demo")
|
||||
print("=" * 60)
|
||||
print(f" Frames to transmit: {args.frames}")
|
||||
print(f" Samples per frame: {samples_per_frame:,}")
|
||||
print(f" Total samples: {n_samples:,}")
|
||||
print(f" Duration: {n_samples / SAMPLE_RATE_BASEBAND:.3f} s")
|
||||
print(f" SNR: {'clean (no noise)' if args.snr is None else f'{args.snr} dB'}")
|
||||
print(f" Voice subcarrier: {'enabled' if args.voice else 'disabled'}")
|
||||
print()
|
||||
|
||||
# Build the flowgraph
|
||||
print("Building flowgraph...")
|
||||
tb = gr.top_block()
|
||||
|
||||
tx = usb_signal_source(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=PCM_HIGH_BIT_RATE,
|
||||
snr_db=args.snr,
|
||||
voice_enabled=args.voice,
|
||||
)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
rx = usb_downlink_receiver(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=PCM_HIGH_BIT_RATE,
|
||||
output_format="raw",
|
||||
)
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
|
||||
print("Running flowgraph (TX -> RX)...")
|
||||
print()
|
||||
tb.run()
|
||||
|
||||
n_recovered = snk.num_messages()
|
||||
print(f"Recovered {n_recovered} frames from {args.frames} transmitted")
|
||||
print()
|
||||
|
||||
if n_recovered == 0:
|
||||
print("No frames recovered. PLL may need more settling time.")
|
||||
print("Try increasing --frames to give the receiver more data.")
|
||||
sys.exit(1)
|
||||
|
||||
# Decode and display each recovered frame
|
||||
demux = DemuxEngine(output_format="raw")
|
||||
|
||||
print("-" * 60)
|
||||
for i in range(n_recovered):
|
||||
msg = snk.get_message(i)
|
||||
payload = pmt.cdr(msg) if pmt.is_pair(msg) else msg
|
||||
|
||||
frame_bytes = bytes(pmt.u8vector_elements(payload))
|
||||
result = demux.process_frame(frame_bytes)
|
||||
|
||||
sync = result.get("sync", {})
|
||||
frame_id = sync.get("frame_id", 0)
|
||||
parity = "odd" if (frame_id % 2 == 1) else "even"
|
||||
|
||||
words = result.get("words", [])
|
||||
n_words = len(words)
|
||||
|
||||
# Show first few data words as hex
|
||||
word_preview = " ".join(
|
||||
f"{w['raw_value']:02X}" for w in words[:8]
|
||||
)
|
||||
|
||||
print(
|
||||
f" Frame {i + 1:3d}: "
|
||||
f"ID={frame_id:>2} ({parity:4s}), "
|
||||
f"sync=0x{sync.get('word', 0):08X}, "
|
||||
f"{n_words} words "
|
||||
f"[{word_preview} ...]"
|
||||
)
|
||||
|
||||
print("-" * 60)
|
||||
print()
|
||||
print(f"Recovery rate: {n_recovered}/{args.frames} "
|
||||
f"({100 * n_recovered / args.frames:.0f}%)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apollo Voice Subcarrier Demo -- modulate real audio onto 1.25 MHz FM subcarrier.
|
||||
|
||||
Takes an audio file (e.g., actual Apollo mission crew recordings), modulates
|
||||
it onto the 1.25 MHz FM voice subcarrier with +/-29 kHz deviation, then
|
||||
demodulates it back to audio. This is exactly what the spacecraft's
|
||||
Pre-Modulation Processor and the ground station receiver did.
|
||||
|
||||
Signal path:
|
||||
audio file (8 kHz)
|
||||
-> upsample to 5.12 MHz
|
||||
-> fm_voice_subcarrier_mod (audio_input=True)
|
||||
[audio -> FM mod -> upconvert to 1.25 MHz -> float subcarrier]
|
||||
-> voice_subcarrier_demod
|
||||
[BPF 1.25 MHz -> FM discriminator -> BPF 300-3000 Hz -> decimate to 8 kHz]
|
||||
-> recovered audio (8 kHz WAV)
|
||||
|
||||
Usage:
|
||||
uv run python examples/voice_subcarrier_demo.py examples/audio/apollo11_crew.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --output recovered.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --play
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from math import gcd
|
||||
|
||||
import numpy as np
|
||||
from gnuradio import blocks, gr
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Modulate audio onto Apollo 1.25 MHz FM voice subcarrier"
|
||||
)
|
||||
parser.add_argument("input", help="Input WAV file (any sample rate)")
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Output WAV file (default: <input>_recovered.wav)")
|
||||
parser.add_argument("--play", action="store_true",
|
||||
help="Play recovered audio with aplay")
|
||||
parser.add_argument("--sample-rate", type=float, default=SAMPLE_RATE_BASEBAND,
|
||||
help="Baseband sample rate (default: 5.12 MHz)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output is None:
|
||||
stem = args.input.rsplit(".", 1)[0]
|
||||
args.output = f"{stem}_recovered.wav"
|
||||
|
||||
# Load input audio
|
||||
print("=" * 60)
|
||||
print("Apollo Voice Subcarrier Demo")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
input_rate, audio_data = wavfile.read(args.input)
|
||||
if audio_data.ndim > 1:
|
||||
audio_data = audio_data[:, 0] # mono
|
||||
|
||||
# Normalize to [-1, 1] float
|
||||
if audio_data.dtype == np.int16:
|
||||
audio_float = audio_data.astype(np.float32) / 32768.0
|
||||
elif audio_data.dtype == np.int32:
|
||||
audio_float = audio_data.astype(np.float32) / 2147483648.0
|
||||
else:
|
||||
audio_float = audio_data.astype(np.float32)
|
||||
|
||||
duration = len(audio_float) / input_rate
|
||||
print(f" Input: {args.input}")
|
||||
print(f" Sample rate: {input_rate} Hz")
|
||||
print(f" Duration: {duration:.2f} s")
|
||||
print(f" Samples: {len(audio_float):,}")
|
||||
print()
|
||||
|
||||
# Upsample to baseband rate
|
||||
sample_rate = int(args.sample_rate)
|
||||
audio_rate = 8000 # output rate from voice demod
|
||||
|
||||
# Resample input to 8 kHz first (the standard Apollo voice bandwidth)
|
||||
if input_rate != audio_rate:
|
||||
g = gcd(audio_rate, input_rate)
|
||||
audio_float = resample_poly(audio_float, audio_rate // g, input_rate // g)
|
||||
audio_float = audio_float.astype(np.float32)
|
||||
print(f" Resampled to {audio_rate} Hz: {len(audio_float):,} samples")
|
||||
|
||||
# Upsample from 8 kHz to baseband (5.12 MHz)
|
||||
# Factor: 5120000 / 8000 = 640
|
||||
g = gcd(sample_rate, audio_rate)
|
||||
up = sample_rate // g
|
||||
down = audio_rate // g
|
||||
print(f" Upsampling {audio_rate} Hz -> {sample_rate / 1e6:.2f} MHz "
|
||||
f"(ratio {up}:{down})...")
|
||||
|
||||
t0 = time.time()
|
||||
upsampled = resample_poly(audio_float, up, down)
|
||||
upsampled = upsampled.astype(np.float32)
|
||||
t_resample = time.time() - t0
|
||||
print(f" Upsampled: {len(upsampled):,} samples ({t_resample:.1f}s)")
|
||||
print()
|
||||
|
||||
# Build GNU Radio flowgraph: voice mod -> voice demod
|
||||
print("Building flowgraph: FM mod (1.25 MHz) -> FM demod...")
|
||||
tb = gr.top_block()
|
||||
|
||||
src = blocks.vector_source_f(upsampled.tolist())
|
||||
voice_mod = fm_voice_subcarrier_mod(
|
||||
sample_rate=sample_rate,
|
||||
audio_input=True,
|
||||
)
|
||||
voice_demod = voice_subcarrier_demod(
|
||||
sample_rate=sample_rate,
|
||||
audio_rate=audio_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, voice_mod, voice_demod, snk)
|
||||
|
||||
print("Running flowgraph...")
|
||||
t0 = time.time()
|
||||
tb.run()
|
||||
t_run = time.time() - t0
|
||||
|
||||
recovered = np.array(snk.data(), dtype=np.float32)
|
||||
print(f" Processed in {t_run:.1f}s")
|
||||
print(f" Recovered: {len(recovered):,} samples at {audio_rate} Hz")
|
||||
print(f" Duration: {len(recovered) / audio_rate:.2f} s")
|
||||
print()
|
||||
|
||||
# Normalize recovered audio for WAV output
|
||||
peak = np.max(np.abs(recovered))
|
||||
if peak > 0:
|
||||
recovered = recovered / peak * 0.9 # normalize to 90% to avoid clipping
|
||||
|
||||
# Save as 16-bit WAV
|
||||
recovered_int16 = (recovered * 32767).astype(np.int16)
|
||||
wavfile.write(args.output, audio_rate, recovered_int16)
|
||||
print(f" Saved: {args.output}")
|
||||
print(f" Peak amplitude: {peak:.4f}")
|
||||
print()
|
||||
|
||||
# Play if requested
|
||||
if args.play:
|
||||
import subprocess
|
||||
print("Playing recovered audio...")
|
||||
subprocess.run(["aplay", args.output], check=False)
|
||||
else:
|
||||
print(f"Play with: aplay {args.output}")
|
||||
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,47 +0,0 @@
|
||||
id: apollo_bpsk_subcarrier_mod
|
||||
label: Apollo BPSK Subcarrier Mod
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: subcarrier_freq
|
||||
label: Subcarrier Frequency (Hz)
|
||||
dtype: real
|
||||
default: '1024000'
|
||||
- id: sample_rate
|
||||
label: Sample Rate (Hz)
|
||||
dtype: real
|
||||
default: '5120000'
|
||||
|
||||
inputs:
|
||||
- label: in
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
templates:
|
||||
imports: from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
make: apollo.bpsk_subcarrier_mod.bpsk_subcarrier_mod(subcarrier_freq=${subcarrier_freq}, sample_rate=${sample_rate})
|
||||
|
||||
documentation: |-
|
||||
Apollo BPSK Subcarrier Modulator
|
||||
|
||||
Multiplies an NRZ baseband waveform (+1/-1) by a 1.024 MHz cosine to produce
|
||||
a BPSK-modulated subcarrier: output(t) = nrz(t) * cos(2*pi*f_sc*t).
|
||||
|
||||
The cosine phase flips 180 degrees at each NRZ sign change, implementing
|
||||
bi-phase shift keying. This is the transmit-side counterpart to the
|
||||
Apollo BPSK Subcarrier Demod block.
|
||||
|
||||
On the real spacecraft, the PCM encoder drives the BPSK subcarrier modulator
|
||||
before summing with the voice subcarrier for PM transmission.
|
||||
|
||||
Parameters:
|
||||
subcarrier_freq: BPSK subcarrier frequency in Hz (default 1.024 MHz)
|
||||
sample_rate: Sample rate in Hz (default 5.12 MHz)
|
||||
|
||||
file_format: 1
|
||||
@ -1,76 +0,0 @@
|
||||
id: apollo_fm_voice_subcarrier_mod
|
||||
label: Apollo FM Voice Subcarrier Mod
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: sample_rate
|
||||
label: Sample Rate (Hz)
|
||||
dtype: real
|
||||
default: '5120000'
|
||||
- id: subcarrier_freq
|
||||
label: Subcarrier Frequency (Hz)
|
||||
dtype: real
|
||||
default: '1250000'
|
||||
- id: fm_deviation
|
||||
label: FM Deviation (Hz)
|
||||
dtype: real
|
||||
default: '29000'
|
||||
- id: tone_freq
|
||||
label: Test Tone Frequency (Hz)
|
||||
dtype: real
|
||||
default: '1000'
|
||||
- id: audio_input
|
||||
label: Audio Source
|
||||
dtype: bool
|
||||
default: 'False'
|
||||
options: ['True', 'False']
|
||||
option_labels: ['External Input', 'Internal Test Tone']
|
||||
|
||||
inputs:
|
||||
- label: audio
|
||||
domain: stream
|
||||
dtype: float
|
||||
optional: true
|
||||
hide: ${ 'all' if not audio_input else 'none' }
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
templates:
|
||||
imports: from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
make: >-
|
||||
apollo.fm_voice_subcarrier_mod.fm_voice_subcarrier_mod(
|
||||
sample_rate=${sample_rate},
|
||||
subcarrier_freq=${subcarrier_freq},
|
||||
fm_deviation=${fm_deviation},
|
||||
tone_freq=${tone_freq},
|
||||
audio_input=${audio_input})
|
||||
|
||||
documentation: |-
|
||||
Apollo FM Voice Subcarrier Modulator
|
||||
|
||||
Generates a 1.25 MHz FM subcarrier for the voice channel. Can operate in
|
||||
two modes:
|
||||
|
||||
Internal mode (default): Uses a built-in sine test tone as the audio source.
|
||||
This is useful for testing and signal validation.
|
||||
|
||||
External mode (audio_input=True): Accepts an external float audio stream as
|
||||
input. Use this to modulate actual Apollo mission voice recordings or live
|
||||
audio onto the 1.25 MHz FM subcarrier with +/-29 kHz deviation.
|
||||
|
||||
On the real spacecraft, voice audio (300-3000 Hz) drives an FM VCO at
|
||||
113 kHz, which is mixed with the 512 kHz master clock and doubled to
|
||||
produce the 1.25 MHz FM subcarrier.
|
||||
|
||||
Parameters:
|
||||
sample_rate: Output sample rate in Hz (default 5.12 MHz)
|
||||
subcarrier_freq: FM subcarrier center frequency in Hz (default 1.25 MHz)
|
||||
fm_deviation: FM deviation in Hz (default +/-29 kHz)
|
||||
tone_freq: Internal test tone frequency in Hz (default 1 kHz, ignored when audio_input=True)
|
||||
audio_input: When True, accept external audio via float input port
|
||||
|
||||
file_format: 1
|
||||
@ -1,52 +0,0 @@
|
||||
id: apollo_nrz_encoder
|
||||
label: Apollo NRZ Encoder
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: bit_rate
|
||||
label: Bit Rate (bps)
|
||||
dtype: int
|
||||
default: '51200'
|
||||
options: ['51200', '1600']
|
||||
option_labels: ['High (51.2 kbps)', 'Low (1.6 kbps)']
|
||||
- id: sample_rate
|
||||
label: Sample Rate
|
||||
dtype: real
|
||||
default: '5120000'
|
||||
|
||||
inputs:
|
||||
- label: in
|
||||
domain: stream
|
||||
dtype: byte
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
templates:
|
||||
imports: from apollo.nrz_encoder import nrz_encoder
|
||||
make: apollo.nrz_encoder.nrz_encoder(bit_rate=${bit_rate}, sample_rate=${sample_rate})
|
||||
|
||||
documentation: |-
|
||||
Apollo NRZ Encoder
|
||||
|
||||
Converts a stream of byte values (0 or 1) to a Non-Return-to-Zero
|
||||
baseband waveform at the specified sample rate.
|
||||
|
||||
Mapping:
|
||||
bit 1 -> +1.0 (held for bit period)
|
||||
bit 0 -> -1.0 (held for bit period)
|
||||
|
||||
Each bit value is upsampled by samples_per_bit = sample_rate / bit_rate.
|
||||
At the default high rate (51.2 kbps, 5.12 MHz), this is 100 samples
|
||||
per bit. At low rate (1.6 kbps), it is 3200 samples per bit.
|
||||
|
||||
This is the transmit-side counterpart to the slicer in bpsk_demod.
|
||||
|
||||
Parameters:
|
||||
bit_rate: PCM bit rate in bps (51200 high, 1600 low)
|
||||
sample_rate: Output sample rate in Hz (default 5.12 MHz)
|
||||
|
||||
file_format: 1
|
||||
@ -1,47 +0,0 @@
|
||||
id: apollo_pcm_frame_source
|
||||
label: Apollo PCM Frame Source
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: bit_rate
|
||||
label: Bit Rate (bps)
|
||||
dtype: int
|
||||
default: '51200'
|
||||
options: ['51200', '1600']
|
||||
option_labels: ['High (51.2 kbps)', 'Low (1.6 kbps)']
|
||||
|
||||
inputs:
|
||||
- label: frame_data
|
||||
domain: message
|
||||
optional: true
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: byte
|
||||
|
||||
templates:
|
||||
imports: from apollo.pcm_frame_source import pcm_frame_source
|
||||
make: apollo.pcm_frame_source.pcm_frame_source(bit_rate=${bit_rate})
|
||||
|
||||
documentation: |-
|
||||
Apollo PCM Frame Source
|
||||
|
||||
Generates a continuous stream of NRZ-encoded PCM telemetry frame bits
|
||||
(byte values 0 or 1). Frame IDs cycle 1 through 50 automatically,
|
||||
with the 15-bit sync core complemented on odd-numbered frames.
|
||||
|
||||
This is the transmit-side counterpart to the PCM Frame Sync block.
|
||||
|
||||
The sync word format is:
|
||||
[5-bit A][15-bit core][6-bit B][6-bit frame ID]
|
||||
|
||||
An optional message input (frame_data) accepts u8vector payloads that
|
||||
will be used as data words for the next generated frame. Without
|
||||
injected data, frames carry zero-fill.
|
||||
|
||||
Parameters:
|
||||
bit_rate: 51200 (128 words/frame, 50 fps) or 1600 (200 words/frame, 1 fps)
|
||||
|
||||
file_format: 1
|
||||
@ -1,44 +0,0 @@
|
||||
id: apollo_pm_mod
|
||||
label: Apollo PM Mod
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: pm_deviation
|
||||
label: PM Deviation (rad)
|
||||
dtype: real
|
||||
default: '0.133'
|
||||
- id: sample_rate
|
||||
label: Sample Rate
|
||||
dtype: real
|
||||
default: '5120000'
|
||||
|
||||
inputs:
|
||||
- label: in
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: complex
|
||||
|
||||
templates:
|
||||
imports: from apollo.pm_mod import pm_mod
|
||||
make: apollo.pm_mod.pm_mod(pm_deviation=${pm_deviation}, sample_rate=${sample_rate})
|
||||
|
||||
documentation: |-
|
||||
Apollo PM Modulator
|
||||
|
||||
Applies phase modulation to produce complex baseband signal.
|
||||
Takes a composite modulating signal (sum of subcarriers) and outputs
|
||||
s(t) = exp(j * pm_deviation * input(t)).
|
||||
|
||||
The spacecraft PM deviation is 0.133 rad (7.6 degrees) peak.
|
||||
This is the transmit-side counterpart to Apollo PM Demod.
|
||||
|
||||
Parameters:
|
||||
pm_deviation: Peak phase deviation in radians (default 0.133)
|
||||
sample_rate: Sample rate in Hz (default 5.12 MHz)
|
||||
|
||||
file_format: 1
|
||||
@ -1,58 +0,0 @@
|
||||
id: apollo_sco_mod
|
||||
label: Apollo SCO Mod
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: sco_number
|
||||
label: SCO Channel (1-9)
|
||||
dtype: int
|
||||
default: '1'
|
||||
- id: sample_rate
|
||||
label: Sample Rate (Hz)
|
||||
dtype: real
|
||||
default: '5120000'
|
||||
|
||||
inputs:
|
||||
- label: in
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: float
|
||||
|
||||
templates:
|
||||
imports: from apollo.sco_mod import sco_mod
|
||||
make: >-
|
||||
apollo.sco_mod.sco_mod(
|
||||
sco_number=${sco_number},
|
||||
sample_rate=${sample_rate})
|
||||
|
||||
documentation: |-
|
||||
Apollo Subcarrier Oscillator (SCO) Modulator
|
||||
|
||||
Generates FM subcarrier oscillator signals for analog telemetry. Takes a
|
||||
0-5V sensor voltage input and produces an FM subcarrier tone at the
|
||||
selected channel frequency with +/-7.5% deviation.
|
||||
|
||||
Transmit-side counterpart to the SCO Demodulator.
|
||||
|
||||
SCO Channels:
|
||||
1: 14,500 Hz 4: 40,000 Hz 7: 95,000 Hz
|
||||
2: 22,000 Hz 5: 52,500 Hz 8: 125,000 Hz
|
||||
3: 30,000 Hz 6: 70,000 Hz 9: 165,000 Hz
|
||||
|
||||
Voltage mapping (linear):
|
||||
0V -> center - 7.5% (low frequency)
|
||||
2.5V -> center (nominal)
|
||||
5V -> center + 7.5% (high frequency)
|
||||
|
||||
Only used in FM downlink mode.
|
||||
|
||||
Parameters:
|
||||
sco_number: SCO channel number (1-9)
|
||||
sample_rate: Sample rate in Hz (default 5.12 MHz)
|
||||
|
||||
file_format: 1
|
||||
@ -1,80 +0,0 @@
|
||||
id: apollo_usb_signal_source
|
||||
label: Apollo USB Signal Source
|
||||
category: '[Apollo USB]'
|
||||
flags: [python]
|
||||
|
||||
parameters:
|
||||
- id: sample_rate
|
||||
label: Sample Rate (Hz)
|
||||
dtype: float
|
||||
default: '5120000'
|
||||
- id: bit_rate
|
||||
label: PCM Bit Rate
|
||||
dtype: int
|
||||
default: '51200'
|
||||
options: ['51200', '1600']
|
||||
option_labels: ['51.2 kbps (high rate)', '1.6 kbps (low rate)']
|
||||
- id: pm_deviation
|
||||
label: PM Deviation (rad)
|
||||
dtype: float
|
||||
default: '0.133'
|
||||
- id: voice_enabled
|
||||
label: Voice Subcarrier
|
||||
dtype: bool
|
||||
default: 'False'
|
||||
options: ['True', 'False']
|
||||
option_labels: ['Enabled', 'Disabled']
|
||||
- id: voice_tone_hz
|
||||
label: Voice Test Tone (Hz)
|
||||
dtype: float
|
||||
default: '1000'
|
||||
- id: snr_db
|
||||
label: SNR (dB)
|
||||
dtype: raw
|
||||
default: 'None'
|
||||
|
||||
inputs:
|
||||
- label: frame_data
|
||||
domain: message
|
||||
optional: true
|
||||
|
||||
outputs:
|
||||
- label: out
|
||||
domain: stream
|
||||
dtype: complex
|
||||
|
||||
templates:
|
||||
imports: from apollo.usb_signal_source import usb_signal_source
|
||||
make: >-
|
||||
apollo.usb_signal_source.usb_signal_source(
|
||||
sample_rate=${sample_rate},
|
||||
bit_rate=${bit_rate},
|
||||
pm_deviation=${pm_deviation},
|
||||
voice_enabled=${voice_enabled},
|
||||
voice_tone_hz=${voice_tone_hz},
|
||||
snr_db=${snr_db})
|
||||
|
||||
documentation: |-
|
||||
Apollo USB Signal Source -- complete transmit chain in one block.
|
||||
|
||||
Generates a PM-modulated complex baseband signal containing:
|
||||
- 1.024 MHz BPSK subcarrier with PCM telemetry frames
|
||||
- Optional 1.25 MHz FM voice subcarrier (test tone)
|
||||
- Optional AWGN noise
|
||||
|
||||
This is the transmit-side counterpart to the USB Downlink Receiver.
|
||||
It mirrors CuriousMarc's bench: individual composable blocks wired
|
||||
together as one convenience wrapper.
|
||||
|
||||
Message input:
|
||||
frame_data -- inject custom payload bytes for the next PCM frame
|
||||
|
||||
Parameters:
|
||||
sample_rate: Output sample rate (default 5.12 MHz)
|
||||
bit_rate: PCM bit rate -- 51200 (high) or 1600 (low)
|
||||
pm_deviation: Peak PM deviation in radians (default 0.133)
|
||||
voice_enabled: Include 1.25 MHz FM voice subcarrier
|
||||
voice_tone_hz: Voice test tone frequency in Hz
|
||||
snr_db: Add AWGN noise at this SNR (None = no noise)
|
||||
|
||||
file_format: 1
|
||||
@ -1,7 +1,7 @@
|
||||
"""
|
||||
gr-apollo: Apollo Unified S-Band for GNU Radio 3.10+
|
||||
gr-apollo: Apollo Unified S-Band decoder for GNU Radio 3.10+
|
||||
|
||||
Receive and transmit Apollo-era Unified S-Band (USB) telecommunications:
|
||||
Decodes Apollo-era Unified S-Band (USB) telecommunications:
|
||||
- 2287.5 MHz downlink with PM/FM modulation
|
||||
- 1.024 MHz BPSK subcarrier (PCM telemetry @ 51.2 kbps)
|
||||
- 1.25 MHz FM subcarrier (voice)
|
||||
@ -10,19 +10,21 @@ Receive and transmit Apollo-era Unified S-Band (USB) telecommunications:
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
# Pure-python modules and engines (always available, no GR dependency)
|
||||
# Pure-python modules (always available)
|
||||
from apollo import constants as constants
|
||||
from apollo import protocol as protocol
|
||||
|
||||
# Pure-python engines (always available, no GR dependency)
|
||||
from apollo.agc_bridge import AGCBridgeClient as AGCBridgeClient
|
||||
from apollo.downlink_decoder import DownlinkEngine as DownlinkEngine
|
||||
from apollo.pcm_demux import DemuxEngine as DemuxEngine
|
||||
from apollo.pcm_frame_source import FrameSourceEngine as FrameSourceEngine
|
||||
from apollo.pcm_frame_sync import FrameSyncEngine as FrameSyncEngine
|
||||
from apollo.uplink_encoder import UplinkEncoder as UplinkEncoder
|
||||
from apollo.usb_signal_gen import generate_usb_baseband as generate_usb_baseband
|
||||
|
||||
# GNU Radio receive-side blocks (require gnuradio runtime)
|
||||
# Imported lazily so the package works without GNU Radio installed.
|
||||
# GNU Radio blocks (require gnuradio runtime)
|
||||
# These are imported lazily to allow the package to be used
|
||||
# for its pure-python utilities without GNU Radio installed.
|
||||
try:
|
||||
from apollo.bpsk_demod import bpsk_demod as bpsk_demod
|
||||
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod as bpsk_subcarrier_demod
|
||||
@ -31,20 +33,8 @@ try:
|
||||
from apollo.subcarrier_extract import subcarrier_extract as subcarrier_extract
|
||||
from apollo.voice_subcarrier_demod import voice_subcarrier_demod as voice_subcarrier_demod
|
||||
except ImportError:
|
||||
pass # GNU Radio not available — receive-side GR blocks won't be importable
|
||||
pass # GNU Radio not available — Phase 1/3 GR blocks won't be importable
|
||||
|
||||
# GNU Radio transmit-side blocks
|
||||
try:
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod as bpsk_subcarrier_mod
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod as fm_voice_subcarrier_mod
|
||||
from apollo.nrz_encoder import nrz_encoder as nrz_encoder
|
||||
from apollo.pcm_frame_source import pcm_frame_source as pcm_frame_source
|
||||
from apollo.pm_mod import pm_mod as pm_mod
|
||||
from apollo.sco_mod import sco_mod as sco_mod
|
||||
except ImportError:
|
||||
pass # GNU Radio not available — transmit-side GR blocks won't be importable
|
||||
|
||||
# GNU Radio composite blocks (depend on individual blocks above)
|
||||
try:
|
||||
from apollo.agc_bridge import agc_bridge as agc_bridge
|
||||
from apollo.downlink_decoder import downlink_decoder as downlink_decoder
|
||||
@ -52,6 +42,5 @@ try:
|
||||
from apollo.pcm_frame_sync import pcm_frame_sync as pcm_frame_sync
|
||||
from apollo.uplink_encoder import uplink_encoder as uplink_encoder
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver as usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source as usb_signal_source
|
||||
except (ImportError, NameError):
|
||||
pass
|
||||
pass # GNU Radio not available — Phase 2/4/5 GR blocks won't be importable
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
"""
|
||||
Apollo BPSK Subcarrier Modulator -- NRZ data onto 1.024 MHz subcarrier.
|
||||
|
||||
The transmit-side counterpart to bpsk_subcarrier_demod. Takes an NRZ baseband
|
||||
waveform (+1/-1) and modulates it onto a 1.024 MHz cosine subcarrier via
|
||||
simple multiplication: output(t) = nrz(t) * cos(2*pi*f_sc*t).
|
||||
|
||||
This is Bi-Phase Shift Keying (BPSK): the cosine phase flips 180 degrees
|
||||
when the NRZ data changes sign.
|
||||
|
||||
On the real spacecraft, a 33522B AWG (or equivalent) generates this
|
||||
BPSK-modulated subcarrier before summing with the voice subcarrier.
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md section 4.2
|
||||
"""
|
||||
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import PCM_SUBCARRIER_HZ, SAMPLE_RATE_BASEBAND
|
||||
|
||||
|
||||
class bpsk_subcarrier_mod(gr.hier_block2):
|
||||
"""BPSK modulator: NRZ float input -> BPSK subcarrier float output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subcarrier_freq: float = PCM_SUBCARRIER_HZ,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
):
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_bpsk_subcarrier_mod",
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
)
|
||||
|
||||
self._subcarrier_freq = subcarrier_freq
|
||||
self._sample_rate = sample_rate
|
||||
|
||||
# 1.024 MHz cosine subcarrier (continuous phase, maintained by sig_source)
|
||||
self.carrier = analog.sig_source_f(
|
||||
sample_rate, analog.GR_COS_WAVE, subcarrier_freq, 1.0, 0,
|
||||
)
|
||||
|
||||
# Multiply NRZ data by subcarrier
|
||||
self.mixer = blocks.multiply_ff(1)
|
||||
|
||||
# Connect: input (NRZ) -> mixer port 0, carrier -> mixer port 1 -> output
|
||||
self.connect(self, (self.mixer, 0))
|
||||
self.connect(self.carrier, (self.mixer, 1))
|
||||
self.connect(self.mixer, self)
|
||||
|
||||
@property
|
||||
def subcarrier_freq(self) -> float:
|
||||
return self._subcarrier_freq
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> float:
|
||||
return self._sample_rate
|
||||
@ -1,115 +0,0 @@
|
||||
"""
|
||||
Apollo FM Voice Subcarrier Modulator -- 1.25 MHz FM with internal tone or external audio.
|
||||
|
||||
Transmit-side counterpart to voice_subcarrier_demod. FM-modulates audio onto a
|
||||
1.25 MHz subcarrier and outputs the real-valued subcarrier signal.
|
||||
|
||||
Two modes of operation:
|
||||
- Internal test tone (default): generates a sine wave for testing.
|
||||
- External audio input: accepts a float stream (e.g., Apollo mission voice
|
||||
recordings) and modulates it onto the subcarrier.
|
||||
|
||||
On the real spacecraft, voice audio (300-3000 Hz) drives an FM VCO at 113 kHz,
|
||||
which is mixed with the 512 kHz master clock and doubled to produce the
|
||||
1.25 MHz FM subcarrier with +/-29 kHz deviation.
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md section 4.2
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import (
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
VOICE_FM_DEVIATION_HZ,
|
||||
VOICE_SUBCARRIER_HZ,
|
||||
)
|
||||
|
||||
|
||||
class fm_voice_subcarrier_mod(gr.hier_block2):
|
||||
"""FM-modulated voice subcarrier (1.25 MHz) with internal test tone or external audio.
|
||||
|
||||
Outputs:
|
||||
float -- real-valued FM subcarrier at subcarrier_freq
|
||||
|
||||
Inputs (when audio_input=True):
|
||||
float -- external audio signal (e.g., mission voice recordings)
|
||||
|
||||
When audio_input=False (default), an internal sine test tone is used.
|
||||
When audio_input=True, the block accepts an external float stream -- for
|
||||
example, actual Apollo mission crew voice recordings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
subcarrier_freq: float = VOICE_SUBCARRIER_HZ,
|
||||
fm_deviation: float = VOICE_FM_DEVIATION_HZ,
|
||||
tone_freq: float = 1000.0,
|
||||
audio_input: bool = False,
|
||||
):
|
||||
# Choose input signature based on mode
|
||||
in_sig = gr.io_signature(1, 1, gr.sizeof_float) if audio_input else gr.io_signature(0, 0, 0)
|
||||
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_fm_voice_subcarrier_mod",
|
||||
in_sig,
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
)
|
||||
|
||||
self._sample_rate = sample_rate
|
||||
self._tone_freq = tone_freq
|
||||
self._subcarrier_freq = subcarrier_freq
|
||||
self._fm_deviation = fm_deviation
|
||||
self._audio_input = audio_input
|
||||
|
||||
# FM modulator: sensitivity = 2*pi*deviation/sample_rate rad/sample
|
||||
# With unit-amplitude sine input this gives +/-fm_deviation Hz.
|
||||
fm_sensitivity = 2.0 * math.pi * fm_deviation / sample_rate
|
||||
self.fm_mod = analog.frequency_modulator_fc(fm_sensitivity)
|
||||
|
||||
# LO at subcarrier frequency for upconversion
|
||||
self.lo = analog.sig_source_c(
|
||||
sample_rate, analog.GR_COS_WAVE, subcarrier_freq, 1.0, 0,
|
||||
)
|
||||
|
||||
# Mixer: FM baseband x LO -> subcarrier
|
||||
self.mixer = blocks.multiply_cc(1)
|
||||
|
||||
# Extract real part for float output
|
||||
self.to_real = blocks.complex_to_real(1)
|
||||
|
||||
if audio_input:
|
||||
# External audio input -> FM mod
|
||||
self.connect(self, self.fm_mod, (self.mixer, 0))
|
||||
else:
|
||||
# Internal test tone -> FM mod
|
||||
self.tone = analog.sig_source_f(
|
||||
sample_rate, analog.GR_SIN_WAVE, tone_freq, 1.0, 0,
|
||||
)
|
||||
self.connect(self.tone, self.fm_mod, (self.mixer, 0))
|
||||
|
||||
self.connect(self.lo, (self.mixer, 1))
|
||||
self.connect(self.mixer, self.to_real, self)
|
||||
|
||||
@property
|
||||
def tone_freq(self) -> float:
|
||||
"""Test tone frequency in Hz."""
|
||||
return self._tone_freq
|
||||
|
||||
@property
|
||||
def subcarrier_freq(self) -> float:
|
||||
"""Subcarrier center frequency in Hz."""
|
||||
return self._subcarrier_freq
|
||||
|
||||
@property
|
||||
def fm_deviation(self) -> float:
|
||||
"""FM deviation in Hz."""
|
||||
return self._fm_deviation
|
||||
|
||||
@property
|
||||
def audio_input(self) -> bool:
|
||||
"""Whether the block accepts external audio input."""
|
||||
return self._audio_input
|
||||
@ -1,61 +0,0 @@
|
||||
"""
|
||||
Apollo NRZ Encoder -- converts PCM bit stream to NRZ baseband waveform.
|
||||
|
||||
Takes a stream of byte values (0 or 1) from pcm_frame_source and produces
|
||||
a float waveform at the output sample rate where bit 1 -> +1.0 and
|
||||
bit 0 -> -1.0, with each bit repeated for samples_per_bit samples.
|
||||
|
||||
This is the transmit-side counterpart to the slicer in bpsk_demod.
|
||||
|
||||
NRZ (Non-Return-to-Zero) encoding maps:
|
||||
bit 1 -> +1.0 (held for bit period)
|
||||
bit 0 -> -1.0 (held for bit period)
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md section 5.1
|
||||
"""
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.constants import PCM_HIGH_BIT_RATE, SAMPLE_RATE_BASEBAND
|
||||
|
||||
|
||||
class nrz_encoder(gr.hier_block2):
|
||||
"""NRZ line encoder: byte (0/1) stream -> float (+1/-1) waveform.
|
||||
|
||||
Input: byte stream (values 0 or 1)
|
||||
Output: float NRZ waveform at sample_rate
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bit_rate: int = PCM_HIGH_BIT_RATE,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
):
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_nrz_encoder",
|
||||
gr.io_signature(1, 1, gr.sizeof_char),
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
)
|
||||
|
||||
self._bit_rate = bit_rate
|
||||
self._sample_rate = sample_rate
|
||||
samples_per_bit = int(sample_rate / bit_rate)
|
||||
|
||||
# byte (0/1) -> float (0.0/1.0)
|
||||
self.to_float = blocks.char_to_float(1, 1)
|
||||
|
||||
# float (0.0/1.0) -> float (0.0/2.0)
|
||||
self.scale = blocks.multiply_const_ff(2.0)
|
||||
|
||||
# float (0.0/2.0) -> float (-1.0/+1.0)
|
||||
self.offset = blocks.add_const_ff(-1.0)
|
||||
|
||||
# Upsample: repeat each value samples_per_bit times
|
||||
self.upsample = blocks.repeat(gr.sizeof_float, samples_per_bit)
|
||||
|
||||
# Connect chain
|
||||
self.connect(self, self.to_float, self.scale, self.offset, self.upsample, self)
|
||||
|
||||
@property
|
||||
def samples_per_bit(self) -> int:
|
||||
return int(self._sample_rate / self._bit_rate)
|
||||
@ -1,153 +0,0 @@
|
||||
"""
|
||||
Apollo PCM Frame Source -- generates a continuous NRZ bit stream of PCM frames.
|
||||
|
||||
The transmit-side counterpart to pcm_frame_sync. Produces a steady stream of
|
||||
128-word (high rate, 51.2 kbps) or 200-word (low rate, 1.6 kbps) PCM frames,
|
||||
each beginning with the standard 32-bit sync word:
|
||||
|
||||
[5-bit A][15-bit core][6-bit B][6-bit frame ID]
|
||||
|
||||
Frame IDs cycle 1 through 50 (one subframe), with the 15-bit core complemented
|
||||
on odd-numbered frames. An optional message input allows dynamic payload
|
||||
injection; otherwise frames carry zero-fill data.
|
||||
|
||||
The core logic lives in FrameSourceEngine (pure Python, testable without GNU
|
||||
Radio). The GR sync_block wrapper bridges frame-granularity generation with
|
||||
GR's sample-granularity scheduler via an internal bit buffer.
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md sections 5.1, 5.2
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_LOW_WORDS_PER_FRAME,
|
||||
)
|
||||
from apollo.usb_signal_gen import generate_pcm_frame
|
||||
|
||||
|
||||
class FrameSourceEngine:
|
||||
"""PCM frame generation engine (pure Python, no GR dependency).
|
||||
|
||||
Maintains a rolling frame counter (1-50) and generates complete frames
|
||||
on demand via next_frame(). Odd-numbered frames get a complemented
|
||||
sync core automatically.
|
||||
|
||||
Args:
|
||||
bit_rate: PCM bit rate in bps (51200 or 1600).
|
||||
"""
|
||||
|
||||
def __init__(self, bit_rate: int = PCM_HIGH_BIT_RATE):
|
||||
self.bit_rate = bit_rate
|
||||
if bit_rate == PCM_HIGH_BIT_RATE:
|
||||
self.words_per_frame = PCM_HIGH_WORDS_PER_FRAME
|
||||
else:
|
||||
self.words_per_frame = PCM_LOW_WORDS_PER_FRAME
|
||||
|
||||
self.frame_counter = 1
|
||||
|
||||
def next_frame(self, data: bytes | None = None) -> list[int]:
|
||||
"""Generate the next PCM frame as a list of bits (0/1 values, MSB first).
|
||||
|
||||
Args:
|
||||
data: Optional payload bytes for data words. If None, the frame
|
||||
carries zero-fill (deterministic, unlike the random fill in
|
||||
generate_pcm_frame when data=None for signal-gen use).
|
||||
|
||||
Returns:
|
||||
List of bit values, length = words_per_frame * 8.
|
||||
"""
|
||||
frame_id = self.frame_counter
|
||||
odd = (frame_id % 2) == 1
|
||||
|
||||
# Default to zero-fill rather than random for a transmit source --
|
||||
# downstream blocks and tests need deterministic output.
|
||||
if data is None:
|
||||
data = bytes(self.words_per_frame)
|
||||
|
||||
bits = generate_pcm_frame(
|
||||
frame_id=frame_id,
|
||||
odd=odd,
|
||||
data=data,
|
||||
words_per_frame=self.words_per_frame,
|
||||
)
|
||||
|
||||
# Advance counter: 1 -> 2 -> ... -> 50 -> 1
|
||||
self.frame_counter = (self.frame_counter % 50) + 1
|
||||
|
||||
return bits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GNU Radio block wrapper (optional -- only if gnuradio is available)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import pmt
|
||||
from gnuradio import gr
|
||||
|
||||
class pcm_frame_source(gr.sync_block):
|
||||
"""GNU Radio source block: continuous PCM frame bit stream.
|
||||
|
||||
Outputs a stream of bytes (values 0 or 1) representing NRZ-encoded
|
||||
PCM telemetry frames. Frame IDs cycle 1-50 automatically.
|
||||
|
||||
An optional ``frame_data`` message input accepts PMT u8vector payloads
|
||||
that will be used as the data words for the next generated frame.
|
||||
|
||||
Parameters:
|
||||
bit_rate: 51200 (128 words/frame) or 1600 (200 words/frame).
|
||||
"""
|
||||
|
||||
def __init__(self, bit_rate: int = PCM_HIGH_BIT_RATE):
|
||||
gr.sync_block.__init__(
|
||||
self,
|
||||
name="apollo_pcm_frame_source",
|
||||
in_sig=None,
|
||||
out_sig=[np.byte],
|
||||
)
|
||||
|
||||
self._engine = FrameSourceEngine(bit_rate=bit_rate)
|
||||
self._bit_buffer: deque[int] = deque()
|
||||
self._pending_data: bytes | None = None
|
||||
|
||||
# Message input for dynamic payload injection
|
||||
self.message_port_register_in(pmt.intern("frame_data"))
|
||||
self.set_msg_handler(
|
||||
pmt.intern("frame_data"), self._handle_frame_data
|
||||
)
|
||||
|
||||
def _handle_frame_data(self, msg):
|
||||
"""Store incoming PMT payload bytes for the next frame."""
|
||||
if pmt.is_u8vector(msg):
|
||||
self._pending_data = bytes(pmt.u8vector_elements(msg))
|
||||
elif pmt.is_pair(msg):
|
||||
# Accept PDU (car=meta, cdr=payload)
|
||||
payload = pmt.cdr(msg)
|
||||
if pmt.is_u8vector(payload):
|
||||
self._pending_data = bytes(pmt.u8vector_elements(payload))
|
||||
|
||||
def work(self, input_items, output_items):
|
||||
out = output_items[0]
|
||||
n_out = len(out)
|
||||
produced = 0
|
||||
|
||||
while produced < n_out:
|
||||
if not self._bit_buffer:
|
||||
frame_bits = self._engine.next_frame(data=self._pending_data)
|
||||
self._pending_data = None
|
||||
self._bit_buffer.extend(frame_bits)
|
||||
|
||||
chunk = min(n_out - produced, len(self._bit_buffer))
|
||||
for i in range(chunk):
|
||||
out[produced + i] = self._bit_buffer.popleft()
|
||||
produced += chunk
|
||||
|
||||
return produced
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
@ -1,53 +0,0 @@
|
||||
"""
|
||||
Apollo PM Modulator — applies phase modulation to produce complex baseband.
|
||||
|
||||
The transmit-side counterpart to pm_demod. Takes a composite modulating signal
|
||||
(sum of subcarriers) and produces PM complex baseband: s(t) = exp(j * phi(t))
|
||||
where phi(t) = pm_deviation * modulating(t).
|
||||
|
||||
The spacecraft transmitter phase-modulates at 0.133 rad peak deviation (7.6 deg).
|
||||
At this small deviation, the modulation is essentially linear.
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md section 2.3
|
||||
"""
|
||||
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import PM_PEAK_DEVIATION_RAD, SAMPLE_RATE_BASEBAND
|
||||
|
||||
|
||||
class pm_mod(gr.hier_block2):
|
||||
"""Phase modulator: float input -> PM complex baseband output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
):
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_pm_mod",
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
gr.io_signature(1, 1, gr.sizeof_gr_complex),
|
||||
)
|
||||
|
||||
self._pm_deviation = pm_deviation
|
||||
|
||||
# Scale input by PM deviation so phase_modulator sees deviation-scaled values
|
||||
self.gain = blocks.multiply_const_ff(pm_deviation)
|
||||
|
||||
# Phase modulate: output = exp(j * 1.0 * input)
|
||||
# Sensitivity is 1.0 because we pre-scale by pm_deviation above
|
||||
self.modulator = analog.phase_modulator_fc(1.0)
|
||||
|
||||
# Connect: input -> gain -> phase_mod -> output
|
||||
self.connect(self, self.gain, self.modulator, self)
|
||||
|
||||
def get_pm_deviation(self) -> float:
|
||||
"""Return current PM deviation in radians."""
|
||||
return self._pm_deviation
|
||||
|
||||
def set_pm_deviation(self, dev: float):
|
||||
"""Update PM deviation at runtime."""
|
||||
self._pm_deviation = dev
|
||||
self.gain.set_k(dev)
|
||||
@ -1,124 +0,0 @@
|
||||
"""
|
||||
Apollo Subcarrier Oscillator (SCO) Modulator — FM analog telemetry.
|
||||
|
||||
In FM downlink mode, the Pre-Modulation Processor generates 9 subcarrier
|
||||
oscillators (SCOs) that encode analog sensor voltages (0-5V DC) as frequency
|
||||
deviations of +/-7.5% around each channel's center frequency.
|
||||
|
||||
This is the transmit-side counterpart to sco_demod.py. It takes a 0-5V sensor
|
||||
voltage input and produces an FM subcarrier tone at the configured SCO channel
|
||||
frequency.
|
||||
|
||||
Transmitter side (this block):
|
||||
0-5V input -> subtract 2.5V -> scale to +/-1.0
|
||||
-> FM modulator -> upconvert to center_freq
|
||||
-> extract real part -> float output
|
||||
|
||||
The mapping is linear:
|
||||
0V input -> center_freq - 7.5% = low frequency
|
||||
2.5V input -> center_freq (nominal)
|
||||
5V input -> center_freq + 7.5% = high frequency
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md section 4.3
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import (
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
SCO_DEVIATION_PERCENT,
|
||||
SCO_FREQUENCIES,
|
||||
SCO_INPUT_RANGE_V,
|
||||
)
|
||||
|
||||
|
||||
class sco_mod(gr.hier_block2):
|
||||
"""Modulate a 0-5V sensor voltage onto an FM subcarrier oscillator tone.
|
||||
|
||||
Only used in FM downlink mode.
|
||||
|
||||
Inputs:
|
||||
float -- sensor voltage (0.0 to 5.0 V)
|
||||
|
||||
Outputs:
|
||||
float -- FM subcarrier tone at the selected SCO channel frequency
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sco_number: int = 1,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
):
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_sco_mod",
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
gr.io_signature(1, 1, gr.sizeof_float),
|
||||
)
|
||||
|
||||
if sco_number not in SCO_FREQUENCIES:
|
||||
raise ValueError(
|
||||
f"SCO number must be 1-9, got {sco_number}. "
|
||||
f"Valid channels: {sorted(SCO_FREQUENCIES.keys())}"
|
||||
)
|
||||
|
||||
self._sco_number = sco_number
|
||||
self._sample_rate = sample_rate
|
||||
|
||||
center_freq = SCO_FREQUENCIES[sco_number]
|
||||
self._center_freq = center_freq
|
||||
|
||||
# Frequency deviation in Hz: +/-7.5% of center
|
||||
deviation_hz = center_freq * (SCO_DEVIATION_PERCENT / 100.0)
|
||||
self._deviation_hz = deviation_hz
|
||||
|
||||
# Voltage range parameters
|
||||
v_min, v_max = SCO_INPUT_RANGE_V
|
||||
v_range = v_max - v_min # 5.0
|
||||
v_mid = (v_max + v_min) / 2.0 # 2.5
|
||||
|
||||
# Stage 1: Offset — subtract midpoint so 2.5V becomes 0
|
||||
self.offset = blocks.add_const_ff(-v_mid)
|
||||
|
||||
# Stage 2: Scale — map +/-2.5V to +/-1.0
|
||||
self.scale = blocks.multiply_const_ff(1.0 / (v_range / 2.0))
|
||||
|
||||
# Stage 3: FM modulator — +/-1.0 input produces +/-deviation_hz
|
||||
fm_sensitivity = 2.0 * math.pi * deviation_hz / sample_rate
|
||||
self.fm_mod = analog.frequency_modulator_fc(fm_sensitivity)
|
||||
|
||||
# Stage 4: Local oscillator at center_freq for upconversion
|
||||
self.lo = analog.sig_source_c(
|
||||
sample_rate, analog.GR_COS_WAVE, center_freq, 1.0, 0,
|
||||
)
|
||||
|
||||
# Stage 5: Mixer — shift baseband FM signal up to center_freq
|
||||
self.mixer = blocks.multiply_cc(1)
|
||||
|
||||
# Stage 6: Extract real part for float output
|
||||
self.to_real = blocks.complex_to_real(1)
|
||||
|
||||
# Connect the chain:
|
||||
# input -> offset -> scale -> fm_mod -> (mixer, 0)
|
||||
# lo -> (mixer, 1)
|
||||
# mixer -> to_real -> output
|
||||
self.connect(self, self.offset, self.scale, self.fm_mod, (self.mixer, 0))
|
||||
self.connect(self.lo, (self.mixer, 1))
|
||||
self.connect(self.mixer, self.to_real, self)
|
||||
|
||||
@property
|
||||
def center_freq(self) -> float:
|
||||
"""Center frequency of this SCO channel in Hz."""
|
||||
return self._center_freq
|
||||
|
||||
@property
|
||||
def deviation_hz(self) -> float:
|
||||
"""FM deviation in Hz (+/- from center)."""
|
||||
return self._deviation_hz
|
||||
|
||||
@property
|
||||
def sco_number(self) -> int:
|
||||
"""SCO channel number (1-9)."""
|
||||
return self._sco_number
|
||||
@ -1,146 +0,0 @@
|
||||
"""
|
||||
Apollo USB Signal Source -- complete transmit chain in one block.
|
||||
|
||||
The transmit-side counterpart to usb_downlink_receiver. Wires together the
|
||||
full modulation chain:
|
||||
|
||||
pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -+-> add_ff -> pm_mod -> [complex out]
|
||||
|
|
||||
fm_voice_subcarrier_mod --------+
|
||||
(optional, scaled by 1.68/2.2)
|
||||
|
||||
This mirrors CuriousMarc's physical bench topology: the individual composable
|
||||
blocks map 1:1 to Keysight instruments (EXG signal generator for PM, two
|
||||
33522B AWGs for subcarrier modulation).
|
||||
|
||||
For finer control, use the individual blocks directly.
|
||||
|
||||
Reference: IMPLEMENTATION_SPEC.md -- full downlink transmit path
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_SUBCARRIER_HZ,
|
||||
PM_PEAK_DEVIATION_RAD,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
VOICE_FM_DEVIATION_HZ,
|
||||
VOICE_SUBCARRIER_HZ,
|
||||
)
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
|
||||
class usb_signal_source(gr.hier_block2):
|
||||
"""Apollo USB downlink signal source -- complex baseband output.
|
||||
|
||||
Outputs:
|
||||
complex -- PM-modulated baseband at sample_rate (default 5.12 MHz)
|
||||
|
||||
Message inputs:
|
||||
frame_data -- forwarded to pcm_frame_source for dynamic payload injection
|
||||
|
||||
The block generates PCM telemetry frames, NRZ-encodes them, BPSK-modulates
|
||||
onto a 1.024 MHz subcarrier, optionally adds a 1.25 MHz FM voice subcarrier,
|
||||
and applies PM modulation to produce complex baseband.
|
||||
|
||||
Optional AWGN noise can be added by setting snr_db to a finite value.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
||||
bit_rate: int = PCM_HIGH_BIT_RATE,
|
||||
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
|
||||
voice_enabled: bool = False,
|
||||
voice_tone_hz: float = 1000.0,
|
||||
snr_db: float | None = None,
|
||||
):
|
||||
gr.hier_block2.__init__(
|
||||
self,
|
||||
"apollo_usb_signal_source",
|
||||
gr.io_signature(0, 0, 0), # source -- no input
|
||||
gr.io_signature(1, 1, gr.sizeof_gr_complex),
|
||||
)
|
||||
|
||||
self._sample_rate = sample_rate
|
||||
self._voice_enabled = voice_enabled
|
||||
|
||||
# Forward the frame_data message port from pcm_frame_source
|
||||
self.message_port_register_hier_in("frame_data")
|
||||
|
||||
# --- PCM telemetry path ---
|
||||
|
||||
# Stage 1: Generate PCM frame bits (0/1 byte stream)
|
||||
self.frame_src = pcm_frame_source(bit_rate=bit_rate)
|
||||
|
||||
# Forward message port: hier input -> pcm_frame_source
|
||||
self.msg_connect(self, "frame_data", self.frame_src, "frame_data")
|
||||
|
||||
# Stage 2: NRZ encode (byte 0/1 -> float +1/-1 at sample_rate)
|
||||
self.nrz = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate)
|
||||
|
||||
# Stage 3: BPSK modulate onto 1.024 MHz subcarrier
|
||||
self.bpsk = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
# Connect PCM chain: frame_src -> nrz -> bpsk
|
||||
self.connect(self.frame_src, self.nrz, self.bpsk)
|
||||
|
||||
# --- Subcarrier summing ---
|
||||
|
||||
if voice_enabled:
|
||||
# Voice subcarrier level relative to PCM:
|
||||
# Per IMPL_SPEC: PCM = 2.2 Vpp, Voice = 1.68 Vpp
|
||||
# The BPSK subcarrier has unity amplitude, so voice is scaled
|
||||
# by 1.68/2.2 to maintain the correct power ratio.
|
||||
voice_scale = 1.68 / 2.2
|
||||
|
||||
self.voice = fm_voice_subcarrier_mod(
|
||||
sample_rate=sample_rate,
|
||||
subcarrier_freq=VOICE_SUBCARRIER_HZ,
|
||||
fm_deviation=VOICE_FM_DEVIATION_HZ,
|
||||
tone_freq=voice_tone_hz,
|
||||
)
|
||||
self.voice_gain = blocks.multiply_const_ff(voice_scale)
|
||||
self.adder = blocks.add_ff(1)
|
||||
|
||||
# PCM subcarrier -> adder port 0
|
||||
self.connect(self.bpsk, (self.adder, 0))
|
||||
# Voice subcarrier (scaled) -> adder port 1
|
||||
self.connect(self.voice, self.voice_gain, (self.adder, 1))
|
||||
|
||||
composite = self.adder
|
||||
else:
|
||||
composite = self.bpsk
|
||||
|
||||
# --- PM modulation ---
|
||||
|
||||
self.pm = pm_mod(pm_deviation=pm_deviation, sample_rate=sample_rate)
|
||||
self.connect(composite, self.pm)
|
||||
|
||||
# --- Optional AWGN ---
|
||||
|
||||
if snr_db is not None:
|
||||
# Signal power is 1.0 (PM constant envelope)
|
||||
noise_power = 1.0 / (10.0 ** (snr_db / 10.0))
|
||||
noise_amplitude = math.sqrt(noise_power / 2.0)
|
||||
|
||||
self.noise = analog.noise_source_c(
|
||||
analog.GR_GAUSSIAN, noise_amplitude, 0,
|
||||
)
|
||||
self.sum_noise = blocks.add_cc(1)
|
||||
|
||||
self.connect(self.pm, (self.sum_noise, 0))
|
||||
self.connect(self.noise, (self.sum_noise, 1))
|
||||
self.connect(self.sum_noise, self)
|
||||
else:
|
||||
self.connect(self.pm, self)
|
||||
@ -1,197 +0,0 @@
|
||||
"""Tests for the BPSK subcarrier modulator block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import PCM_SUBCARRIER_HZ, SAMPLE_RATE_BASEBAND
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestBPSKSubcarrierMod:
|
||||
"""Test BPSK subcarrier modulation with synthetic NRZ inputs."""
|
||||
|
||||
def test_block_instantiation(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
mod = bpsk_subcarrier_mod()
|
||||
assert mod is not None
|
||||
assert mod.subcarrier_freq == PCM_SUBCARRIER_HZ
|
||||
assert mod.sample_rate == SAMPLE_RATE_BASEBAND
|
||||
|
||||
def test_constant_positive_input(self):
|
||||
"""All +1.0 input should produce a pure cosine at 1.024 MHz."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([1.0] * n_samples)
|
||||
mod = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples
|
||||
|
||||
# FFT: spectral energy should concentrate at 1.024 MHz
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
|
||||
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert pcm_power > total_power * 0.1, (
|
||||
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
|
||||
def test_constant_negative_input(self):
|
||||
"""All -1.0 input should produce -cos (inverted cosine) at 1.024 MHz."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([-1.0] * n_samples)
|
||||
mod = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples
|
||||
|
||||
# Inverted cosine still has energy at 1.024 MHz
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
|
||||
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert pcm_power > total_power * 0.1, (
|
||||
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
|
||||
def test_alternating_input_spectrum(self):
|
||||
"""Alternating +1/-1 NRZ should still have spectral peak near subcarrier."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
# Samples per bit at high rate: 5_120_000 / 51_200 = 100
|
||||
samples_per_bit = int(sample_rate / 51_200)
|
||||
n_bits = 512
|
||||
n_samples = n_bits * samples_per_bit
|
||||
|
||||
# Build alternating NRZ: +1 for 100 samples, -1 for 100, ...
|
||||
nrz = []
|
||||
for i in range(n_bits):
|
||||
val = 1.0 if i % 2 == 0 else -1.0
|
||||
nrz.extend([val] * samples_per_bit)
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(nrz)
|
||||
mod = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples
|
||||
|
||||
# BPSK with alternating data spreads energy around subcarrier +/- bit_rate,
|
||||
# but the band near 1.024 MHz should still carry significant power
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
|
||||
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert pcm_power > total_power * 0.1, (
|
||||
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
|
||||
def test_amplitude_bounded(self):
|
||||
"""Output amplitude should be <= 1.0 (product of +/-1 and cos)."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([1.0] * n_samples)
|
||||
mod = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
peak = np.max(np.abs(data))
|
||||
|
||||
# cos has peak 1.0, NRZ is +/-1.0, product peak should be ~1.0
|
||||
assert peak <= 1.0 + 1e-6, (
|
||||
f"Output peak amplitude {peak:.6f} exceeds 1.0"
|
||||
)
|
||||
assert peak > 0.9, (
|
||||
f"Output peak amplitude {peak:.6f} is suspiciously low"
|
||||
)
|
||||
|
||||
def test_custom_subcarrier_freq(self):
|
||||
"""Custom subcarrier frequency should shift spectral peak."""
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
custom_freq = 500_000 # 500 kHz
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([1.0] * n_samples)
|
||||
mod = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=custom_freq, sample_rate=sample_rate,
|
||||
)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
# Energy should be near 500 kHz, not 1.024 MHz
|
||||
custom_mask = (np.abs(freqs) > 450_000) & (np.abs(freqs) < 550_000)
|
||||
custom_power = np.mean(np.abs(fft_vals[custom_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert custom_power > total_power * 0.1, (
|
||||
f"Custom freq band power ({custom_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
@ -1,247 +0,0 @@
|
||||
"""Tests for the FM voice subcarrier modulator block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestFmVoiceModInstantiation:
|
||||
"""Test block creation and parameter handling."""
|
||||
|
||||
def test_default_parameters(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
mod = fm_voice_subcarrier_mod()
|
||||
assert mod is not None
|
||||
|
||||
def test_custom_tone_freq(self):
|
||||
"""Block should accept a custom tone frequency and produce output."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 10240
|
||||
|
||||
tb = gr.top_block()
|
||||
src = fm_voice_subcarrier_mod(sample_rate=sample_rate, tone_freq=2000.0)
|
||||
head = blocks.head(gr.sizeof_float, n_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples
|
||||
assert np.any(data != 0), "Output is all zeros with tone_freq=2000"
|
||||
|
||||
def test_properties(self):
|
||||
"""Properties should reflect constructor arguments."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
mod = fm_voice_subcarrier_mod(
|
||||
tone_freq=1500.0,
|
||||
subcarrier_freq=1_000_000,
|
||||
fm_deviation=20_000,
|
||||
)
|
||||
assert mod.tone_freq == 1500.0
|
||||
assert mod.subcarrier_freq == 1_000_000
|
||||
assert mod.fm_deviation == 20_000
|
||||
|
||||
|
||||
class TestFmVoiceModFunctional:
|
||||
"""Functional tests with signal analysis."""
|
||||
|
||||
def test_produces_output(self):
|
||||
"""Source block should produce non-zero float output."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
|
||||
tb = gr.top_block()
|
||||
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
|
||||
head = blocks.head(gr.sizeof_float, n_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
|
||||
assert np.any(data != 0), "Output is all zeros"
|
||||
|
||||
def test_output_is_float(self):
|
||||
"""Output samples should be real-valued floats."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 1024
|
||||
|
||||
tb = gr.top_block()
|
||||
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
|
||||
head = blocks.head(gr.sizeof_float, n_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert data.dtype in (np.float32, np.float64), (
|
||||
f"Expected float output, got {data.dtype}"
|
||||
)
|
||||
|
||||
def test_spectral_energy_at_subcarrier(self):
|
||||
"""Most spectral energy should be near the 1.25 MHz subcarrier."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200 # ~10 ms
|
||||
|
||||
tb = gr.top_block()
|
||||
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
|
||||
head = blocks.head(gr.sizeof_float, n_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
# Energy in the 1.2-1.3 MHz band (subcarrier +/- deviation margin)
|
||||
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
|
||||
voice_power = np.mean(np.abs(fft_vals[voice_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert voice_power > total_power * 0.1, (
|
||||
f"Subcarrier band power ({voice_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
|
||||
def test_output_bounded(self):
|
||||
"""Output amplitude should stay bounded (not blow up)."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
|
||||
tb = gr.top_block()
|
||||
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
|
||||
head = blocks.head(gr.sizeof_float, n_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
peak = np.max(np.abs(data))
|
||||
# FM on a cosine carrier: peak should be around 1.0, certainly < 2.0
|
||||
assert peak < 2.0, f"Output peak amplitude {peak:.3f} exceeds expected bound"
|
||||
assert peak > 0.1, f"Output peak amplitude {peak:.3f} is suspiciously low"
|
||||
|
||||
|
||||
class TestFmVoiceModExternalAudio:
|
||||
"""Tests for external audio input mode."""
|
||||
|
||||
def test_default_is_source(self):
|
||||
"""Default mode should be source (no input, backward compatible)."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
mod = fm_voice_subcarrier_mod()
|
||||
assert not mod.audio_input
|
||||
|
||||
def test_external_audio_instantiation(self):
|
||||
"""Block with audio_input=True should instantiate."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
mod = fm_voice_subcarrier_mod(audio_input=True)
|
||||
assert mod is not None
|
||||
|
||||
def test_external_audio_property(self):
|
||||
"""audio_input property should reflect constructor arg."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
mod_ext = fm_voice_subcarrier_mod(audio_input=True)
|
||||
assert mod_ext.audio_input is True
|
||||
|
||||
mod_int = fm_voice_subcarrier_mod(audio_input=False)
|
||||
assert mod_int.audio_input is False
|
||||
|
||||
def test_external_audio_produces_output(self):
|
||||
"""Feed a 1 kHz sine wave into external input, verify output."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
t = np.arange(n_samples, dtype=np.float32) / sample_rate
|
||||
audio = np.sin(2 * np.pi * 1000 * t).astype(np.float32)
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(audio.tolist())
|
||||
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
|
||||
assert np.any(data != 0), "Output is all zeros with external audio input"
|
||||
|
||||
def test_external_audio_spectral_energy(self):
|
||||
"""Feed audio, verify spectral energy near 1.25 MHz subcarrier."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
t = np.arange(n_samples, dtype=np.float32) / sample_rate
|
||||
audio = np.sin(2 * np.pi * 1000 * t).astype(np.float32)
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(audio.tolist())
|
||||
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
fft_vals = np.fft.fft(data)
|
||||
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
|
||||
|
||||
# Energy in the 1.2-1.3 MHz band (subcarrier +/- deviation margin)
|
||||
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
|
||||
voice_power = np.mean(np.abs(fft_vals[voice_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft_vals) ** 2)
|
||||
|
||||
assert voice_power > total_power * 0.1, (
|
||||
f"Subcarrier band power ({voice_power:.1f}) is less than 10% of "
|
||||
f"total power ({total_power:.1f})"
|
||||
)
|
||||
|
||||
def test_external_audio_silence(self):
|
||||
"""Feed zeros (silence), verify output still present (carrier only)."""
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
n_samples = 51200
|
||||
silence = np.zeros(n_samples, dtype=np.float32)
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(silence.tolist())
|
||||
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data())
|
||||
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
|
||||
# With silence input, FM deviation is zero so the output is an
|
||||
# unmodulated carrier at the subcarrier frequency -- still non-zero.
|
||||
assert np.any(data != 0), "Output is all zeros with silence input"
|
||||
peak = np.max(np.abs(data))
|
||||
assert peak > 0.1, f"Carrier amplitude {peak:.3f} is suspiciously low"
|
||||
@ -1,144 +0,0 @@
|
||||
"""Loopback test: usb_signal_source -> usb_downlink_receiver round-trip.
|
||||
|
||||
The ultimate validation -- generates a PM-modulated signal with known PCM
|
||||
frames using the transmit chain, feeds it through the complete receive chain,
|
||||
and verifies that frames are recovered correctly.
|
||||
|
||||
This exercises every block in both the transmit and receive paths:
|
||||
|
||||
TX: pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -> pm_mod
|
||||
RX: pm_demod -> subcarrier_extract -> bpsk_demod -> pcm_frame_sync -> pcm_demux
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_WORD_LENGTH,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestLoopback:
|
||||
"""Round-trip: transmit -> receive -> verify."""
|
||||
|
||||
def test_loopback_recovers_frames(self):
|
||||
"""TX signal source -> RX downlink receiver should produce frame PDUs."""
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
# Generate enough samples for several frames so the receiver PLL can settle.
|
||||
# At 51.2 kbps high rate, one frame = 1024 bits = 102400 samples.
|
||||
# Give the receiver 8 frames worth (~0.16 seconds).
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
n_frames = 8
|
||||
n_samples = n_frames * samples_per_frame
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Transmit chain (clean, no noise)
|
||||
tx = usb_signal_source(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=PCM_HIGH_BIT_RATE,
|
||||
snr_db=None,
|
||||
)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
|
||||
# Receive chain
|
||||
rx = usb_downlink_receiver(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=PCM_HIGH_BIT_RATE,
|
||||
)
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
n_recovered = snk.num_messages()
|
||||
# The receiver needs ~1-2 frames for PLL settling, so we expect
|
||||
# at least a few frames from 8 transmitted.
|
||||
assert n_recovered >= 1, (
|
||||
f"Loopback recovered {n_recovered} frames from {n_frames} transmitted, "
|
||||
f"expected >= 1"
|
||||
)
|
||||
|
||||
def test_loopback_frame_structure(self):
|
||||
"""Recovered frames should have valid sync word structure."""
|
||||
from apollo.pcm_demux import DemuxEngine
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
import pmt
|
||||
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
n_samples = 8 * samples_per_frame
|
||||
|
||||
tb = gr.top_block()
|
||||
tx = usb_signal_source(snr_db=None)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
rx = usb_downlink_receiver(output_format="raw")
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
n_recovered = snk.num_messages()
|
||||
if n_recovered == 0:
|
||||
pytest.skip("No frames recovered in loopback -- PLL may need tuning")
|
||||
|
||||
# Validate first recovered frame through the demux engine
|
||||
msg = snk.get_message(0)
|
||||
if pmt.is_pair(msg):
|
||||
payload = pmt.cdr(msg)
|
||||
else:
|
||||
payload = msg
|
||||
|
||||
frame_bytes = bytes(pmt.u8vector_elements(payload))
|
||||
|
||||
demux = DemuxEngine(output_format="raw")
|
||||
result = demux.process_frame(frame_bytes)
|
||||
|
||||
assert "sync" in result
|
||||
assert "words" in result
|
||||
assert result["sync"]["frame_id"] >= 1
|
||||
assert result["sync"]["frame_id"] <= 50
|
||||
|
||||
def test_loopback_with_noise(self):
|
||||
"""Loopback at 30 dB SNR should still recover frames."""
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
n_samples = 10 * samples_per_frame # more frames for noisy recovery
|
||||
|
||||
tb = gr.top_block()
|
||||
tx = usb_signal_source(snr_db=30.0)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
rx = usb_downlink_receiver()
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
n_recovered = snk.num_messages()
|
||||
# At 30 dB SNR with 10 frames, should get at least 1
|
||||
assert n_recovered >= 1, (
|
||||
f"Noisy loopback recovered {n_recovered} frames, expected >= 1"
|
||||
)
|
||||
@ -1,135 +0,0 @@
|
||||
"""Tests for the NRZ encoder block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import PCM_HIGH_BIT_RATE, SAMPLE_RATE_BASEBAND
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestNRZEncoder:
|
||||
"""Test NRZ encoding of bit streams to baseband waveforms."""
|
||||
|
||||
def test_bit_one_maps_to_positive(self):
|
||||
"""A single 1-bit should produce +1.0 repeated for samples_per_bit."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
tb = gr.top_block()
|
||||
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
|
||||
src = blocks.vector_source_b([1])
|
||||
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, enc, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
assert len(output) == samples_per_bit
|
||||
np.testing.assert_allclose(output, 1.0, atol=1e-6)
|
||||
|
||||
def test_bit_zero_maps_to_negative(self):
|
||||
"""A single 0-bit should produce -1.0 repeated for samples_per_bit."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
tb = gr.top_block()
|
||||
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
|
||||
src = blocks.vector_source_b([0])
|
||||
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, enc, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
assert len(output) == samples_per_bit
|
||||
np.testing.assert_allclose(output, -1.0, atol=1e-6)
|
||||
|
||||
def test_alternating_bits(self):
|
||||
"""Alternating [1,0,1,0] should produce +1*N, -1*N, +1*N, -1*N."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
tb = gr.top_block()
|
||||
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
bits = [1, 0, 1, 0]
|
||||
|
||||
src = blocks.vector_source_b(bits)
|
||||
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, enc, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
expected_levels = [1.0, -1.0, 1.0, -1.0]
|
||||
|
||||
for i, level in enumerate(expected_levels):
|
||||
start = i * samples_per_bit
|
||||
end = (i + 1) * samples_per_bit
|
||||
segment = output[start:end]
|
||||
np.testing.assert_allclose(
|
||||
segment, level, atol=1e-6,
|
||||
err_msg=f"Bit {i} (value {bits[i]}): expected {level}",
|
||||
)
|
||||
|
||||
def test_output_length(self):
|
||||
"""4 bits at 51200/5120000 (100 samp/bit) should produce 400 samples."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
tb = gr.top_block()
|
||||
n_bits = 4
|
||||
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE) # 100
|
||||
|
||||
src = blocks.vector_source_b([1, 0, 1, 1])
|
||||
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, enc, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
assert len(output) == n_bits * samples_per_bit
|
||||
|
||||
def test_upsampling_ratio(self):
|
||||
"""Each NRZ level should be held for exactly samples_per_bit samples."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
tb = gr.top_block()
|
||||
# Use a different rate pair to verify generality: 1600 bps at 5.12 MHz
|
||||
# gives 3200 samples per bit
|
||||
bit_rate = 1600
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
samples_per_bit = int(sample_rate / bit_rate) # 3200
|
||||
bits = [1, 0]
|
||||
|
||||
src = blocks.vector_source_b(bits)
|
||||
enc = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, enc, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
assert len(output) == len(bits) * samples_per_bit
|
||||
|
||||
# First bit (1) -> +1.0 held for samples_per_bit
|
||||
np.testing.assert_allclose(output[:samples_per_bit], 1.0, atol=1e-6)
|
||||
# Second bit (0) -> -1.0 held for samples_per_bit
|
||||
np.testing.assert_allclose(output[samples_per_bit:], -1.0, atol=1e-6)
|
||||
|
||||
def test_block_instantiation(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
enc = nrz_encoder()
|
||||
assert enc is not None
|
||||
assert enc.samples_per_bit == int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
@ -1,196 +0,0 @@
|
||||
"""Tests for the PCM frame source block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_LOW_WORDS_PER_FRAME,
|
||||
PCM_SYNC_WORD_LENGTH,
|
||||
PCM_WORD_LENGTH,
|
||||
SUBFRAME_FRAMES,
|
||||
)
|
||||
from apollo.pcm_frame_source import FrameSourceEngine
|
||||
from apollo.protocol import bits_to_sync_word, parse_sync_word
|
||||
|
||||
|
||||
class TestFrameSourceEngine:
|
||||
"""Test the pure-Python frame generation engine (no GR needed)."""
|
||||
|
||||
def test_frame_length(self):
|
||||
"""High-rate frame should be 128 words * 8 bits = 1024 bits."""
|
||||
engine = FrameSourceEngine(bit_rate=51200)
|
||||
bits = engine.next_frame()
|
||||
assert len(bits) == PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
|
||||
def test_frame_length_low_rate(self):
|
||||
"""Low-rate frame should be 200 words * 8 bits = 1600 bits."""
|
||||
engine = FrameSourceEngine(bit_rate=1600)
|
||||
bits = engine.next_frame()
|
||||
assert len(bits) == PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
|
||||
def test_bits_are_binary(self):
|
||||
"""Every output value should be 0 or 1."""
|
||||
engine = FrameSourceEngine()
|
||||
bits = engine.next_frame()
|
||||
assert all(b in (0, 1) for b in bits)
|
||||
|
||||
def test_frame_counter_wraps(self):
|
||||
"""Frame counter should cycle 1 -> 50 -> 1."""
|
||||
engine = FrameSourceEngine()
|
||||
assert engine.frame_counter == 1
|
||||
|
||||
# Generate 50 frames (one full subframe)
|
||||
for expected_id in range(1, SUBFRAME_FRAMES + 1):
|
||||
assert engine.frame_counter == expected_id
|
||||
engine.next_frame()
|
||||
|
||||
# Should wrap back to 1
|
||||
assert engine.frame_counter == 1
|
||||
|
||||
# One more frame to confirm it keeps going
|
||||
engine.next_frame()
|
||||
assert engine.frame_counter == 2
|
||||
|
||||
def test_frame_id_in_sync_word(self):
|
||||
"""The 6-bit frame ID field in the sync word should match the counter."""
|
||||
engine = FrameSourceEngine()
|
||||
for expected_id in range(1, 6):
|
||||
bits = engine.next_frame()
|
||||
sync_word = bits_to_sync_word(bits[:PCM_SYNC_WORD_LENGTH])
|
||||
parsed = parse_sync_word(sync_word)
|
||||
assert parsed["frame_id"] == expected_id
|
||||
|
||||
def test_odd_even_sync(self):
|
||||
"""Odd frames should have complemented sync core vs even frames."""
|
||||
engine = FrameSourceEngine()
|
||||
|
||||
# Frame 1 (odd) and frame 2 (even) should differ in the core field
|
||||
bits_1 = engine.next_frame()
|
||||
bits_2 = engine.next_frame()
|
||||
|
||||
sync_1 = bits_to_sync_word(bits_1[:PCM_SYNC_WORD_LENGTH])
|
||||
sync_2 = bits_to_sync_word(bits_2[:PCM_SYNC_WORD_LENGTH])
|
||||
|
||||
parsed_1 = parse_sync_word(sync_1)
|
||||
parsed_2 = parse_sync_word(sync_2)
|
||||
|
||||
# Cores should be bitwise complements (within 15 bits)
|
||||
assert (parsed_1["core"] ^ parsed_2["core"]) == 0x7FFF
|
||||
|
||||
def test_custom_payload(self):
|
||||
"""Injected data bytes should appear in the data portion of the frame."""
|
||||
engine = FrameSourceEngine()
|
||||
payload = bytes([0xAA, 0x55, 0xDE, 0xAD])
|
||||
bits = engine.next_frame(data=payload)
|
||||
|
||||
# Data starts after the 32-bit sync word
|
||||
data_start = PCM_SYNC_WORD_LENGTH
|
||||
for byte_idx, expected_byte in enumerate(payload):
|
||||
byte_bits = bits[data_start + byte_idx * 8 : data_start + (byte_idx + 1) * 8]
|
||||
recovered = 0
|
||||
for b in byte_bits:
|
||||
recovered = (recovered << 1) | b
|
||||
assert recovered == expected_byte, (
|
||||
f"Byte {byte_idx}: expected 0x{expected_byte:02x}, got 0x{recovered:02x}"
|
||||
)
|
||||
|
||||
def test_default_zero_fill(self):
|
||||
"""Without explicit data, payload should be zero-filled."""
|
||||
engine = FrameSourceEngine()
|
||||
bits = engine.next_frame()
|
||||
|
||||
# All data bits after sync should be zero
|
||||
data_bits = bits[PCM_SYNC_WORD_LENGTH:]
|
||||
assert all(b == 0 for b in data_bits)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
class TestPCMFrameSourceBlock:
|
||||
"""Test the GNU Radio sync_block wrapper."""
|
||||
|
||||
def test_block_instantiation(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
src = pcm_frame_source()
|
||||
assert src is not None
|
||||
|
||||
def test_produces_output(self):
|
||||
"""Source should produce a stream of 0s and 1s."""
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
tb = gr.top_block()
|
||||
n_samples = 2048
|
||||
|
||||
src = pcm_frame_source(bit_rate=51200)
|
||||
head = blocks.head(gr.sizeof_char, n_samples)
|
||||
snk = blocks.vector_sink_b()
|
||||
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = np.array(snk.data(), dtype=np.uint8)
|
||||
assert len(data) == n_samples
|
||||
# All values should be 0 or 1
|
||||
assert np.all((data == 0) | (data == 1))
|
||||
|
||||
def test_frame_boundary(self):
|
||||
"""Getting exactly one frame's worth of bits should work."""
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
tb = gr.top_block()
|
||||
frame_bits = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
|
||||
src = pcm_frame_source(bit_rate=51200)
|
||||
head = blocks.head(gr.sizeof_char, frame_bits)
|
||||
snk = blocks.vector_sink_b()
|
||||
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = snk.data()
|
||||
assert len(data) == frame_bits
|
||||
|
||||
def test_continuous_stream(self):
|
||||
"""Multiple frames should produce the expected total length."""
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
tb = gr.top_block()
|
||||
n_frames = 5
|
||||
frame_bits = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
total_bits = n_frames * frame_bits
|
||||
|
||||
src = pcm_frame_source(bit_rate=51200)
|
||||
head = blocks.head(gr.sizeof_char, total_bits)
|
||||
snk = blocks.vector_sink_b()
|
||||
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = snk.data()
|
||||
assert len(data) == total_bits
|
||||
|
||||
def test_low_rate(self):
|
||||
"""Low-rate source should produce 200-word frames."""
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
tb = gr.top_block()
|
||||
frame_bits = PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
|
||||
src = pcm_frame_source(bit_rate=1600)
|
||||
head = blocks.head(gr.sizeof_char, frame_bits)
|
||||
snk = blocks.vector_sink_b()
|
||||
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
data = snk.data()
|
||||
assert len(data) == frame_bits
|
||||
@ -1,146 +0,0 @@
|
||||
"""Tests for the PM modulator block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import PM_PEAK_DEVIATION_RAD, SAMPLE_RATE_BASEBAND
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestPMMod:
|
||||
"""Test PM modulation with synthetic signals."""
|
||||
|
||||
def test_zero_input_constant_envelope(self):
|
||||
"""Zero input should produce exp(j*0) = 1+0j (unit carrier)."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
n_samples = 10000
|
||||
|
||||
data = [0.0] * n_samples
|
||||
src = blocks.vector_source_f(data)
|
||||
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
assert len(output) == n_samples
|
||||
|
||||
# Magnitude should be 1.0 (constant envelope)
|
||||
magnitudes = np.abs(output)
|
||||
np.testing.assert_allclose(magnitudes, 1.0, atol=1e-6)
|
||||
|
||||
# Phase should be 0 (no modulation)
|
||||
phases = np.angle(output)
|
||||
np.testing.assert_allclose(phases, 0.0, atol=1e-6)
|
||||
|
||||
def test_sine_input_phase_deviation(self):
|
||||
"""Sine wave input should produce phase swinging +/- pm_deviation."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
n_samples = 100000
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
|
||||
# Unit-amplitude sine at 10 kHz as modulating signal
|
||||
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
||||
tone_freq = 10000.0
|
||||
modulating = np.sin(2 * np.pi * tone_freq * t).astype(np.float32)
|
||||
|
||||
src = blocks.vector_source_f(modulating.tolist())
|
||||
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
phases = np.angle(output)
|
||||
|
||||
# Peak phase should be approximately pm_deviation
|
||||
peak_phase = np.max(np.abs(phases))
|
||||
assert peak_phase == pytest.approx(PM_PEAK_DEVIATION_RAD, abs=0.01), (
|
||||
f"Peak phase {peak_phase} doesn't match deviation {PM_PEAK_DEVIATION_RAD}"
|
||||
)
|
||||
|
||||
def test_constant_envelope(self):
|
||||
"""PM output should always have |s(t)| = 1.0 regardless of input."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
n_samples = 50000
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
|
||||
# Arbitrary varying input: sum of two tones
|
||||
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
||||
modulating = (
|
||||
0.7 * np.sin(2 * np.pi * 5000 * t) + 0.3 * np.cos(2 * np.pi * 20000 * t)
|
||||
).astype(np.float32)
|
||||
|
||||
src = blocks.vector_source_f(modulating.tolist())
|
||||
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
magnitudes = np.abs(output)
|
||||
np.testing.assert_allclose(magnitudes, 1.0, atol=1e-6)
|
||||
|
||||
def test_custom_deviation(self):
|
||||
"""Custom pm_deviation should scale output phase accordingly."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
n_samples = 100000
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
custom_dev = 0.5
|
||||
|
||||
# Unit-amplitude sine
|
||||
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
||||
tone_freq = 10000.0
|
||||
modulating = np.sin(2 * np.pi * tone_freq * t).astype(np.float32)
|
||||
|
||||
src = blocks.vector_source_f(modulating.tolist())
|
||||
mod = pm_mod(pm_deviation=custom_dev, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
|
||||
output = np.array(snk.data())
|
||||
phases = np.angle(output)
|
||||
|
||||
peak_phase = np.max(np.abs(phases))
|
||||
assert peak_phase == pytest.approx(custom_dev, abs=0.02), (
|
||||
f"Peak phase {peak_phase} doesn't match custom deviation {custom_dev}"
|
||||
)
|
||||
|
||||
def test_block_instantiation(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
mod = pm_mod()
|
||||
assert mod is not None
|
||||
assert mod.get_pm_deviation() == PM_PEAK_DEVIATION_RAD
|
||||
|
||||
def test_set_pm_deviation(self):
|
||||
"""Runtime deviation update should take effect."""
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
mod = pm_mod()
|
||||
assert mod.get_pm_deviation() == PM_PEAK_DEVIATION_RAD
|
||||
|
||||
mod.set_pm_deviation(0.25)
|
||||
assert mod.get_pm_deviation() == 0.25
|
||||
@ -1,178 +0,0 @@
|
||||
"""Tests for the SCO (Subcarrier Oscillator) modulator block."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import (
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
SCO_FREQUENCIES,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestSCOModInstantiation:
|
||||
"""Test block creation and parameter validation."""
|
||||
|
||||
def test_all_channels(self):
|
||||
"""Should instantiate for each valid SCO channel (1-9)."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
for ch in range(1, 10):
|
||||
mod = sco_mod(sco_number=ch)
|
||||
assert mod is not None
|
||||
assert mod.center_freq == SCO_FREQUENCIES[ch]
|
||||
|
||||
def test_invalid_channel_zero(self):
|
||||
"""Channel 0 should raise ValueError."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
with pytest.raises(ValueError, match="SCO number must be 1-9"):
|
||||
sco_mod(sco_number=0)
|
||||
|
||||
def test_invalid_channel_ten(self):
|
||||
"""Channel 10 should raise ValueError."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
with pytest.raises(ValueError, match="SCO number must be 1-9"):
|
||||
sco_mod(sco_number=10)
|
||||
|
||||
def test_deviation_property(self):
|
||||
"""Deviation should be 7.5% of center frequency."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
for ch in range(1, 10):
|
||||
mod = sco_mod(sco_number=ch)
|
||||
expected = SCO_FREQUENCIES[ch] * 0.075
|
||||
assert abs(mod.deviation_hz - expected) < 0.01
|
||||
|
||||
def test_custom_sample_rate(self):
|
||||
"""Should accept a custom sample rate."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
mod = sco_mod(sco_number=1, sample_rate=10_240_000)
|
||||
assert mod is not None
|
||||
|
||||
|
||||
class TestSCOModFunctional:
|
||||
"""Functional tests with constant-voltage inputs."""
|
||||
|
||||
def _get_output(self, sco_number, voltage, n_samples=None,
|
||||
sample_rate=SAMPLE_RATE_BASEBAND):
|
||||
"""Feed constant voltage through sco_mod and return output samples."""
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
if n_samples is None:
|
||||
n_samples = int(sample_rate * 0.1) # 100ms
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([voltage] * n_samples)
|
||||
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, mod, snk)
|
||||
tb.run()
|
||||
return np.array(snk.data())
|
||||
|
||||
def test_midscale_produces_center_freq(self):
|
||||
"""Feed 2.5V DC, verify spectral peak near center frequency."""
|
||||
sco_ch = 5 # 52,500 Hz
|
||||
sample_rate = SAMPLE_RATE_BASEBAND
|
||||
output = self._get_output(sco_ch, voltage=2.5, sample_rate=sample_rate)
|
||||
|
||||
assert len(output) > 0, "Modulator produced no output"
|
||||
|
||||
# Find dominant frequency via FFT
|
||||
spectrum = np.abs(np.fft.rfft(output))
|
||||
freqs = np.fft.rfftfreq(len(output), d=1.0 / sample_rate)
|
||||
peak_idx = np.argmax(spectrum[1:]) + 1 # skip DC
|
||||
peak_freq = freqs[peak_idx]
|
||||
|
||||
expected = SCO_FREQUENCIES[sco_ch]
|
||||
tolerance = expected * 0.02 # 2% tolerance
|
||||
assert abs(peak_freq - expected) < tolerance, (
|
||||
f"SCO ch{sco_ch} at 2.5V: peak at {peak_freq:.0f} Hz, "
|
||||
f"expected {expected} Hz +/- {tolerance:.0f} Hz"
|
||||
)
|
||||
|
||||
def test_produces_output(self):
|
||||
"""Feed 2.5V, verify non-zero output."""
|
||||
output = self._get_output(sco_number=5, voltage=2.5)
|
||||
assert len(output) > 0, "Modulator produced no output"
|
||||
assert np.any(output != 0.0), "Output is all zeros"
|
||||
|
||||
def test_output_bounded(self):
|
||||
"""Peak amplitude should be reasonable (< 2.0, > 0.1)."""
|
||||
output = self._get_output(sco_number=5, voltage=2.5)
|
||||
peak = np.max(np.abs(output))
|
||||
assert peak > 0.1, f"Output too small: peak amplitude {peak:.4f}"
|
||||
assert peak < 2.0, f"Output too large: peak amplitude {peak:.4f}"
|
||||
|
||||
def test_all_channels_produce_output(self):
|
||||
"""All 9 channels should produce non-zero output with 2.5V input."""
|
||||
for ch in range(1, 10):
|
||||
output = self._get_output(sco_number=ch, voltage=2.5)
|
||||
assert len(output) > 0, f"SCO ch{ch} produced no output"
|
||||
assert np.any(output != 0.0), f"SCO ch{ch} output is all zeros"
|
||||
|
||||
|
||||
class TestSCOModDemodRoundtrip:
|
||||
"""Round-trip tests: sco_mod -> sco_demod should recover the input voltage."""
|
||||
|
||||
def _roundtrip(self, sco_number, voltage, n_samples=None,
|
||||
sample_rate=SAMPLE_RATE_BASEBAND):
|
||||
"""Feed voltage through sco_mod -> sco_demod, return demod output."""
|
||||
from apollo.sco_demod import sco_demod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
if n_samples is None:
|
||||
n_samples = int(sample_rate * 0.2) # 200ms for settling
|
||||
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f([voltage] * n_samples)
|
||||
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
|
||||
demod = sco_demod(sco_number=sco_number, sample_rate=sample_rate)
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(src, mod, demod, snk)
|
||||
tb.run()
|
||||
return np.array(snk.data())
|
||||
|
||||
def test_roundtrip_midscale(self):
|
||||
"""sco_mod(2.5V) -> sco_demod should recover ~2.5V."""
|
||||
sco_ch = 5 # 52,500 Hz
|
||||
output = self._roundtrip(sco_ch, voltage=2.5)
|
||||
|
||||
assert len(output) > 0, "Round-trip produced no output"
|
||||
|
||||
# Skip first 50% for filter settling
|
||||
settled = output[len(output) // 2:]
|
||||
if len(settled) > 10:
|
||||
mean_v = np.mean(settled)
|
||||
assert 1.5 < mean_v < 3.5, (
|
||||
f"SCO ch{sco_ch} round-trip at 2.5V: mean output {mean_v:.2f}V, "
|
||||
f"expected near 2.5V"
|
||||
)
|
||||
|
||||
def test_roundtrip_monotonic(self):
|
||||
"""Feed 0V, 2.5V, 5V through mod->demod; output should be monotonic."""
|
||||
sco_ch = 6 # 70,000 Hz
|
||||
voltages = [0.0, 2.5, 5.0]
|
||||
means = []
|
||||
|
||||
for v_in in voltages:
|
||||
output = self._roundtrip(sco_ch, voltage=v_in)
|
||||
settled = output[len(output) // 2:]
|
||||
mean_v = np.mean(settled) if len(settled) > 10 else float("nan")
|
||||
means.append(mean_v)
|
||||
|
||||
assert means[0] < means[1] < means[2], (
|
||||
f"Non-monotonic round-trip: "
|
||||
f"V_in={voltages}, V_out={[f'{v:.2f}' for v in means]}"
|
||||
)
|
||||
@ -1,113 +0,0 @@
|
||||
"""Tests for the USB signal source (complete transmit chain)."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
HAS_GNURADIO = True
|
||||
except ImportError:
|
||||
HAS_GNURADIO = False
|
||||
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
PCM_HIGH_WORDS_PER_FRAME,
|
||||
PCM_WORD_LENGTH,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
||||
|
||||
|
||||
class TestUSBSignalSource:
|
||||
"""Test the convenience transmit wrapper."""
|
||||
|
||||
def _get_samples(self, n_samples, **kwargs):
|
||||
"""Helper: run usb_signal_source and return complex samples."""
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
src = usb_signal_source(**kwargs)
|
||||
head = blocks.head(gr.sizeof_gr_complex, n_samples)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(src, head, snk)
|
||||
tb.run()
|
||||
|
||||
return np.array(snk.data())
|
||||
|
||||
def test_block_instantiation(self):
|
||||
"""Block should instantiate with default parameters."""
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
src = usb_signal_source()
|
||||
assert src is not None
|
||||
|
||||
def test_produces_complex_output(self):
|
||||
"""Output should be complex-valued samples."""
|
||||
n_samples = 51200 # ~10ms worth
|
||||
data = self._get_samples(n_samples)
|
||||
assert len(data) == n_samples
|
||||
assert data.dtype == np.complex128 or data.dtype == np.complex64
|
||||
|
||||
def test_constant_envelope(self):
|
||||
"""PM signal without noise should have near-constant envelope."""
|
||||
n_samples = 102400 # 1 frame worth
|
||||
data = self._get_samples(n_samples, snr_db=None)
|
||||
envelope = np.abs(data)
|
||||
# PM output: |exp(j*phi)| = 1.0 always
|
||||
np.testing.assert_allclose(envelope, 1.0, atol=1e-4)
|
||||
|
||||
def test_spectral_content_pcm(self):
|
||||
"""FFT of demodulated phase should show energy at 1.024 MHz."""
|
||||
n_samples = 102400
|
||||
data = self._get_samples(n_samples, snr_db=None)
|
||||
|
||||
# Extract phase (equivalent to PM demod)
|
||||
phase = np.angle(data)
|
||||
fft = np.fft.fft(phase)
|
||||
freqs = np.fft.fftfreq(len(phase), 1.0 / SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Energy near 1.024 MHz
|
||||
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
|
||||
pcm_power = np.mean(np.abs(fft[pcm_mask]) ** 2)
|
||||
total_power = np.mean(np.abs(fft) ** 2)
|
||||
assert pcm_power > total_power * 0.01
|
||||
|
||||
def test_with_voice(self):
|
||||
"""With voice enabled, output should still be constant envelope."""
|
||||
n_samples = 51200
|
||||
data = self._get_samples(n_samples, voice_enabled=True, snr_db=None)
|
||||
envelope = np.abs(data)
|
||||
np.testing.assert_allclose(envelope, 1.0, atol=1e-4)
|
||||
|
||||
def test_with_noise(self):
|
||||
"""With noise, envelope should vary (not constant)."""
|
||||
n_samples = 51200
|
||||
data = self._get_samples(n_samples, snr_db=10.0)
|
||||
envelope = np.abs(data)
|
||||
# With noise, std(envelope) should be > 0
|
||||
assert np.std(envelope) > 0.01
|
||||
|
||||
def test_voice_spectral_content(self):
|
||||
"""With voice, phase should contain 1.25 MHz energy."""
|
||||
n_samples = 102400
|
||||
data = self._get_samples(n_samples, voice_enabled=True, snr_db=None)
|
||||
|
||||
phase = np.angle(data)
|
||||
fft = np.fft.fft(phase)
|
||||
freqs = np.fft.fftfreq(len(phase), 1.0 / SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Energy near 1.25 MHz
|
||||
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
|
||||
voice_power = np.mean(np.abs(fft[voice_mask]) ** 2)
|
||||
assert voice_power > 0
|
||||
|
||||
def test_frame_duration(self):
|
||||
"""One frame at 51.2 kbps should produce the right number of samples."""
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
|
||||
data = self._get_samples(samples_per_frame)
|
||||
assert len(data) == samples_per_frame
|
||||
Loading…
x
Reference in New Issue
Block a user