If you work in the defence world you are often accustomed to not being able to talk about what you have designed. This can be a little frustrating at times especially when someone is wrong on the internet.
However, imagine being the first person to implement the microprocessor, only it remains classified for the next 28 years.
As a result everyone thinks the Intel 4004 was the worlds first microprocessor not your design.
This is what happened to Ray Holt, and his team who at Garrett AiResearch working under contract for Gruman on one of the most iconic aircraft, the F14 Tomcat.
The microprocessor developed would be used to create the Central Air Data Computer, it would take as its inputs Static and Dynamic pressure, Temperature, Pitot analogue data and Pitot digital data.
This data is then executed using a stored application to output a Mach number, Attitude, Airspeed and Vertical speed displays. The CADC would also be in charge of driving the F14s signature swing wings and manoeuvre flaps and gloves vanes used to optimise the aircraft for performance given its current attitude and speed.
Where the CADC differs from the Intel 4004 is that the microprocessor is implemented in several discrete devices.
This CADC at a top level consists of several components:
- Timing generator – This generates the timing across the system
- Control ROM and Sequencer – Under control of the timing generator this sequences the operations using instructions stored in the Control ROM
- IO Bridge –Provides external interfacing with the sensors, DACs and Displays
- PMU – Parallel Multiplier Unit, implements a 20x20 bit signed fractional multiplication using Booth’s algorithm.
- PDU – Parallel Divider Unit, implements a 20 bit signed division, using non-restoring division.
- RAS – Random Access Storage – a scratch pad RAM which stores 64 x 20 bit words.
- ROM – Read Only Memory, stores the program and data
- SLF – Special Logic Function, this implements a Algoroithmic Logic Unit.
- SL – Steering Logic, routing logic between the PMU, PDU, SLF and RAS
But how do we know all of this information about the CADC, well thank fully Ray Holt made his personal lab books and design notes available on the internet a few years ago. From this information we are able to start reverse engineering the design of the CADC.
Reverse Engineering.
With Ray’s engineering notebooks available on his website along with several other documents also provided. We are able to understand the design of the CADC and the elements which constitute it.
One issue however, is that the note books are scans of the original format in PDF.
To get started with the conversion and understanding the design, I read the notebooks and sketched out how I thought the design functioned and the blocks interconnected.
Following this I then leveraged AI to also read the documents and define how it determined the system worked and the modules interconnected and communicated.
Thankfully my understanding and the AI understanding was pretty much aligned.
The next step was create specifications for each module and the overall system.
One of the interesting things is the note books often contained the information which could be used for verification of the module.
To help with verification, the tests identified in the specification use unique numbers which are also used in the test bench to cross reference.
The final element of the reverse engineering was, to ensure we understood where design decisions had come from. To do this I created a traceable link from the source material to the requirement.
AI was very important here as under control and supervised I was quickly and easily able to create these specifications and traceability matrix.
CADC Architecture.
There are some interesting architectural elements of the CADC, the first is that to save on IO between the several devices its interfaces are bit serial.
The system works on a 40 clock operation cycle. Which is split into two phases. During the first phase the operands are shifted in to the module for operation PMU, PDU etc. this Phase is called the Wo (IO word) time.
The PDU or PMU then takes 20 clocks to generate the output result, this time is called the Wa (Arithmetic word) time.
The results of the PDU, PDM are shifted out during the next Wo cycle and connected to the next module. The Wo and Wa times are combined to be called an operation cycle (OP).
The main loop of the CADC takes place in 512 operation cycles, e.g. that is the time the address for the ROM resets to 0, this gives the CADC a scan rate of 18 Hz.
Module Requirement
Each module had its requirements recreated from the original note books. This specification included the module input and output. Desired functional behaviour and verification tests to demonstrate the correct implementation.
Each of the module requirements also shows the primary and supporting sources which have been used to recreate the requirements.
For example the PDU has the flowing requirements and verification tests.
RTL Implementation
To recreate the RTL the previously created specifications were used to ensure compliance with the original development and architecture.
Again AI was used heavily here to create the RTL modules. To guide the AI model used for coding, coding rules were provided to guide the RTL creation process. I took a similar approach to working with the AI as I would with a graduate. I accepted there might be issues, trusted but verified and ensured the RTL created not only implemented and passed the identified tests but was also high quality code.
One of the ways I ensured the code was of a good quality was to leverage static analysis using blue pearls visual verification suite.
-------------------------------------------------------------------------------
-- PDU - Parallel Divider Unit (PN 944112)
-- F-14A Central Air Data Computer - FPGA Implementation (Bit-Serial I/O)
--
-- Implements 20-bit signed fractional division (Q1.19 format).
-- Serial data I/O with parallel internal computation using VHDL divide.
--
-- Timing:
-- WO: Operands shift in serially (20 bits), previous quotient shifts out
-- WA: Parallel divide computes new quotient
-------------------------------------------------------------------------------
LIBRARY IEEE;
USE IEEE.STD_LOGIC_1164.ALL;
USE IEEE.NUMERIC_STD.ALL;
ENTITY pdu IS
PORT (
i_clk : IN STD_LOGIC;
i_rst : IN STD_LOGIC;
-- Timing inputs
i_phi2 : IN STD_LOGIC; -- Shift on phi2
i_word_type : IN STD_LOGIC; -- '0'=WA, '1'=WO
i_t0 : IN STD_LOGIC; -- First bit of word
i_t19 : IN STD_LOGIC; -- Last bit of word
-- Serial data inputs
i_dividend_bit: IN STD_LOGIC; -- Dividend serial input
i_divisor_bit : IN STD_LOGIC; -- Divisor serial input
-- Serial data outputs
o_quotient_bit: OUT STD_LOGIC; -- Quotient serial output
o_remainder_bit: OUT STD_LOGIC; -- Remainder serial output
-- Status
o_busy : OUT STD_LOGIC;
o_div_by_zero : OUT STD_LOGIC
);
END ENTITY pdu;
ARCHITECTURE rtl OF pdu IS
-- Input shift registers (shift in during WO)
SIGNAL s_dividend_sr : STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_divisor_sr : STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
-- Output shift registers (shift out during WO)
-- 20 bits: use T0 skip-shift + combinational output compensation (like PMU)
SIGNAL s_quotient_sr : STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_remainder_sr: STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
-- Latched operands for computation
SIGNAL s_dividend_lat: STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_divisor_lat : STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
-- Division state machine
TYPE t_state IS (IDLE, SETUP, DIVIDING, CORRECTION, DONE);
SIGNAL s_state : t_state := IDLE;
SIGNAL s_partial_rem : SIGNED(20 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_div_reg : SIGNED(20 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_quot_reg : STD_LOGIC_VECTOR(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_bit_cnt : UNSIGNED(4 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_dividend_neg : STD_LOGIC := '0';
SIGNAL s_divisor_neg : STD_LOGIC := '0';
SIGNAL s_abs_dividend : UNSIGNED(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_abs_divisor : UNSIGNED(19 DOWNTO 0) := (OTHERS => '0');
SIGNAL s_busy : STD_LOGIC := '0';
SIGNAL s_dbz_reg : STD_LOGIC := '0';
SIGNAL s_compute_done : STD_LOGIC := '0';
BEGIN
-- Serial outputs: T0 outputs bit(0), T1+ outputs bit(1) for same-edge timing
o_quotient_bit <= s_quotient_sr(0) WHEN i_t0 = '1' ELSE s_quotient_sr(1);
o_remainder_bit <= s_remainder_sr(0) WHEN i_t0 = '1' ELSE s_remainder_sr(1);
o_busy <= s_busy;
o_div_by_zero <= s_dbz_reg;
-----------------------------------------------------------------------------
-- Serial shift process - shift on phi2 during WO
-----------------------------------------------------------------------------
shift_proc: PROCESS(i_clk)
BEGIN
IF RISING_EDGE(i_clk) THEN
IF i_rst = '1' THEN
s_dividend_sr <= (OTHERS => '0');
s_divisor_sr <= (OTHERS => '0');
s_quotient_sr <= (OTHERS => '0');
s_remainder_sr <= (OTHERS => '0');
s_dividend_lat <= (OTHERS => '0');
s_divisor_lat <= (OTHERS => '0');
ELSIF i_phi2 = '1' AND i_word_type = '1' THEN
-- WO: Shift in operands (LSB first), shift out results
s_dividend_sr <= i_dividend_bit & s_dividend_sr(19 DOWNTO 1);
s_divisor_sr <= i_divisor_bit & s_divisor_sr(19 DOWNTO 1);
-- Skip shift at T0 for timing compensation (like PMU)
IF i_t0 = '0' THEN
s_quotient_sr <= '0' & s_quotient_sr(19 DOWNTO 1);
s_remainder_sr <= '0' & s_remainder_sr(19 DOWNTO 1);
END IF;
-- At end of WO (T19), latch operands for next computation
IF i_t19 = '1' THEN
s_dividend_lat <= i_dividend_bit & s_dividend_sr(19 DOWNTO 1);
s_divisor_lat <= i_divisor_bit & s_divisor_sr(19 DOWNTO 1);
END IF;
ELSIF s_compute_done = '1' THEN
-- Load computed results into 20-bit output shift registers
s_quotient_sr <= s_quot_reg;
s_remainder_sr <= STD_LOGIC_VECTOR(s_partial_rem(19 DOWNTO 0));
END IF;
END IF;
END PROCESS shift_proc;
-----------------------------------------------------------------------------
-- Division process - runs during WA
-- For Q1.19 fractional: quotient = (dividend * 2^19) / divisor
-----------------------------------------------------------------------------
div_proc: PROCESS(i_clk)
VARIABLE v_dividend_scaled : UNSIGNED(39 DOWNTO 0);
VARIABLE v_quotient_raw : UNSIGNED(39 DOWNTO 0);
VARIABLE v_quot : STD_LOGIC_VECTOR(19 DOWNTO 0);
BEGIN
IF RISING_EDGE(i_clk) THEN
IF i_rst = '1' THEN
s_state <= IDLE;
s_partial_rem <= (OTHERS => '0');
s_div_reg <= (OTHERS => '0');
s_quot_reg <= (OTHERS => '0');
s_bit_cnt <= (OTHERS => '0');
s_busy <= '0';
s_dbz_reg <= '0';
s_compute_done <= '0';
ELSE
s_compute_done <= '0';
CASE s_state IS
WHEN IDLE =>
-- Start computation at beginning of WA
IF i_word_type = '0' AND i_t0 = '1' AND i_phi2 = '1' THEN
IF SIGNED(s_divisor_lat) = 0 THEN
s_dbz_reg <= '1';
s_quot_reg <= (OTHERS => '0'); -- Set via s_quot_reg, not directly
s_partial_rem <= (OTHERS => '0');
s_compute_done <= '1';
ELSE
s_dbz_reg <= '0';
s_dividend_neg <= s_dividend_lat(19);
s_divisor_neg <= s_divisor_lat(19);
IF SIGNED(s_dividend_lat) < 0 THEN
s_abs_dividend <= UNSIGNED(-SIGNED(s_dividend_lat));
ELSE
s_abs_dividend <= UNSIGNED(s_dividend_lat);
END IF;
IF SIGNED(s_divisor_lat) < 0 THEN
s_abs_divisor <= UNSIGNED(-SIGNED(s_divisor_lat));
ELSE
s_abs_divisor <= UNSIGNED(s_divisor_lat);
END IF;
s_busy <= '1';
s_state <= SETUP;
END IF;
END IF;
WHEN SETUP =>
IF i_phi2 = '1' THEN
-- Q1.19 fractional division
-- quotient = (dividend * 2^19) / divisor
-- Scale dividend by 2^19 for fractional result using SHIFT_LEFT
v_dividend_scaled := SHIFT_LEFT(RESIZE(s_abs_dividend, 40), 19);
-- Perform division
IF s_abs_divisor /= 0 THEN
v_quotient_raw := v_dividend_scaled / RESIZE(s_abs_divisor, 40);
v_quot := STD_LOGIC_VECTOR(v_quotient_raw(19 DOWNTO 0));
ELSE
v_quot := (OTHERS => '0');
END IF;
-- Apply sign correction: negate quotient if signs differ
IF s_dividend_neg /= s_divisor_neg THEN
s_quot_reg <= STD_LOGIC_VECTOR(-SIGNED(v_quot));
ELSE
s_quot_reg <= v_quot;
END IF;
s_partial_rem <= (OTHERS => '0');
s_state <= DONE;
END IF;
WHEN DIVIDING =>
-- Not used - division is combinational
s_state <= DONE;
WHEN CORRECTION =>
-- Not used - correction is done in SETUP
s_state <= DONE;
WHEN DONE =>
s_compute_done <= '1';
s_busy <= '0';
s_state <= IDLE;
END CASE;
END IF;
END IF;
END PROCESS div_proc;
END ARCHITECTURE rtl;Test Bench Implementation
For each RTL module a test bench was created, referencing back to the original design specification if possible. Along with common sense tests e.g. reset, basic performance etc, whenever possible original test information from the note books was used.
Each test is identified with an unique ID which linked back to the requirements.
All of the test benches created were self checking, supported by simulation scripts and designed to work with Questa and Questa Visualizer. This enables the architecture of the design to be investigated and explored.
The application which runs on the CADC was sadly not saved, or publicly available.
As such we could verify top level performance by using several simple "top" level calculations found in the note books. These are mostly polynomial calculations which all pass as expected.
I wanted something which would have the same features as the F14 CADC, and as we have no compiler to create the program we would have to write it all at the machine code level.
Well AI would as I asked Claude to generate me a ROM image which would perform the same functions as previously.
This application created used 147 instructions, in Q1, 19 format to calculate the following
▸Sensor acquisition — Read Ps, Qc, TAT; Gray to binary conversion
▸Pressure ratio — r = Qc / Ps via PDU divider
▸Mach — 4th order Horner polynomial from r
▸Altitude — 4th order Horner polynomial from Ps
▸Airspeed — TAS from Mach & TAT with linearized sqrt
▸Vertical speed — Delta altitude scaled by frame rate
▸Wing sweep — 3rd order polynomial, rate limited (uses branching)
▸Maneuver flap — Linear schedule from Mach
▸Glove vane — 2nd order polynomial from Mach
The frame looks as below
Once I was satisfied with the performance of each CADC module and the top level of the CADC I decided to implement it on a Spartan 7 FPGA. In this case I used the Embedded System Tile along with the Tile Carrier Card, which also contains a Raspberry PI Compute Module 5.
My idea was to implement the CADC in the FPGA Tile, while the CM5 would send sensor data to the CADC and read back calculated results. To do this we would use the UART to AXI Protocol we have developed previously. Connected to this would be several AXI GPIO to provide stimulus and capture results.
The complete CADC design looks as below.
I then developed a Python Script which would enable the user to change the input sensor positions and see the output wing movement in a nice GUI.
The python script used is
#!/usr/bin/env python3
"""
CADC Interactive Visualization Tool
Interactive GUI to explore F-14A CADC behavior with real-time visualization
of outputs including wing sweep position.
Displays actual physical units:
- Mach number (0 - 2.5)
- Altitude (0 - 60,000 ft)
- Airspeed (0 - 800 kts)
- Wing sweep (20° - 68°)
- Flap position (0 - 35°)
- Glove vane (-15° to +15°)
- Vertical speed (±20,000 ft/min)
Usage:
python cadc_interactive.py # Simulation mode
python cadc_interactive.py --live # Connect to real FPGA
python cadc_interactive.py --port /dev/ttyAMA2 --live
Controls:
- Ps slider: Static pressure (lower = higher altitude)
- Qc slider: Impact pressure (higher = faster speed)
- TAT slider: Total air temperature
The wing sweep display shows the F-14's variable-sweep wing position
based on flight conditions (Mach number primarily).
"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, RadioButtons
from matplotlib.patches import Polygon, FancyBboxPatch, Circle, Arc
from matplotlib.collections import PatchCollection
import matplotlib.patches as mpatches
import argparse
import sys
import time
import struct
# Try to import serial for live mode
try:
import serial
HAS_SERIAL = True
except ImportError:
HAS_SERIAL = False
# ============================================================================
# Constants and Protocol (from test_cadc_uart.py)
# ============================================================================
GPIO_OUT_0 = 0x40020000 # CH1: mach, CH2: alt
GPIO_OUT_1 = 0x40030000 # CH1: airspd, CH2: vspd
GPIO_OUT_2 = 0x40040000 # CH1: wing, CH2: flap
GPIO_OUT_3 = 0x40050000 # CH1: glove, CH2: status
GPIO_PS = 0x40060000
GPIO_QC = 0x40070000
GPIO_TAT = 0x40080000
CH2_OFFSET = 0x08
CMD_WRITE = 0x09
CMD_READ = 0x05
SCALE = 2**19
Q119_MAX = (1 << 20) - 1
# Physical scaling factors (normalized Q1.19 -> real units)
# Based on F-14 flight envelope
SCALE_MACH = 2.5 # Output 1.0 = Mach 2.5
SCALE_ALT_FT = 60000 # Output 1.0 = 60,000 ft
SCALE_AIRSPD_KTS = 800 # Output 1.0 = 800 knots
SCALE_VSPD_FPM = 20000 # Output 1.0 = 20,000 ft/min
SCALE_FLAP_DEG = 35 # Output 1.0 = 35deg flap
SCALE_GLOVE_DEG = 15 # Output 1.0 = 15deg glove vane
# ============================================================================
# Q1.19 Fixed-Point Helpers
# ============================================================================
def float_to_q119(f: float) -> int:
"""Convert float to Q1.19 fixed-point (20-bit)."""
val = int(round(f * SCALE))
if val < 0:
val = val + (1 << 20)
return val & Q119_MAX
def q119_to_float(h: int) -> float:
"""Convert Q1.19 fixed-point to float."""
h = h & Q119_MAX
if h >= (1 << 19):
return (h - (1 << 20)) / SCALE
return h / SCALE
def bin_to_gray(n: int) -> int:
"""Convert binary to Gray code."""
return n ^ (n >> 1)
def q_mul(a: float, b: float) -> float:
"""Q1.19 signed fractional multiply."""
a_int = int(round(a * SCALE))
b_int = int(round(b * SCALE))
if a_int >= (1 << 19): a_int -= (1 << 20)
if b_int >= (1 << 19): b_int -= (1 << 20)
prod40 = a_int * b_int
result = prod40 >> 19
if (prod40 >> 18) & 1:
result += 1
result = result & Q119_MAX
if result >= (1 << 19):
return (result - (1 << 20)) / SCALE
return result / SCALE
def q_div(a: float, b: float) -> float:
"""Q1.19 fractional divide."""
a_int = int(round(a * SCALE))
b_int = int(round(b * SCALE))
if a_int >= (1 << 19): a_int -= (1 << 20)
if b_int >= (1 << 19): b_int -= (1 << 20)
if b_int == 0:
return 0.0
a_abs = abs(a_int)
b_abs = abs(b_int)
q = (a_abs << 19) // b_abs
q = q & Q119_MAX
if (a_int < 0) != (b_int < 0):
q = (-q) & Q119_MAX
if q >= (1 << 19):
return (q - (1 << 20)) / SCALE
return q / SCALE
def q_add(a: float, b: float) -> float:
"""Q1.19 addition with 20-bit wrap."""
a_int = int(round(a * SCALE))
b_int = int(round(b * SCALE))
if a_int >= (1 << 19): a_int -= (1 << 20)
if b_int >= (1 << 19): b_int -= (1 << 20)
s = (a_int + b_int) & Q119_MAX
if s >= (1 << 19):
return (s - (1 << 20)) / SCALE
return s / SCALE
def horner_q(coeffs: list, x: float) -> float:
"""Horner method using Q1.19 arithmetic."""
acc = coeffs[0]
for c in coeffs[1:]:
acc = q_add(q_mul(acc, x), c)
return acc
# ============================================================================
# CADC Calculation (Simulation Mode)
# ============================================================================
def calculate_outputs(ps: float, qc: float, tat: float, prev_wing: float = 0.0) -> dict:
"""Calculate all CADC outputs from inputs."""
# Ratio = Qc/Ps
ratio = q_div(qc, ps) if ps > 0.001 else 0.0
# Mach = Horner(a, ratio)
a_coeffs = [0.2, -0.2, 0.05, 0.74, 0.0]
mach = horner_q(a_coeffs, ratio)
# Altitude = Horner(b, Ps)
b_coeffs = [0.1, -0.05, 0.2, -0.8, 0.75]
alt = horner_q(b_coeffs, ps)
# Airspeed
ratio2 = q_div(tat, 0.5) if abs(tat) > 0.001 else 0.0
sqrt_est = q_add(q_mul(0.25, ratio2), 0.75)
factor = q_mul(mach, sqrt_est)
airspd = q_mul(0.64, factor)
# Wing sweep with rate limiting
d_coeffs = [-0.25, 0.4, 0.5, -0.25]
wing_cmd = horner_q(d_coeffs, mach)
rate = 0.003
delta = q_add(wing_cmd, -prev_wing)
if delta > rate:
wing = q_add(prev_wing, rate)
elif delta < -rate:
wing = q_add(prev_wing, -rate)
else:
wing = wing_cmd
# Flap = -0.5*M + 0.75
flap = q_add(q_mul(-0.5, mach), 0.75)
# Glove
e_coeffs = [0.25, -0.25, 0.03125]
glove = horner_q(e_coeffs, mach)
# Vertical speed (simplified - would need delta from previous)
vspd = 0.0
return {
'mach': mach,
'alt': alt,
'airspd': airspd,
'vspd': vspd,
'wing': wing,
'wing_cmd': wing_cmd, # Commanded position (before rate limit)
'flap': flap,
'glove': glove
}
# ============================================================================
# Live Hardware Interface
# ============================================================================
class LiveInterface:
"""Interface to real FPGA via UART with logging."""
# Address name mapping for readable logs
ADDR_NAMES = {
GPIO_PS: 'PS', GPIO_QC: 'QC', GPIO_TAT: 'TAT',
GPIO_OUT_0: 'MACH', GPIO_OUT_0 + CH2_OFFSET: 'ALT',
GPIO_OUT_1: 'AIRSPD', GPIO_OUT_1 + CH2_OFFSET: 'VSPD',
GPIO_OUT_2: 'WING', GPIO_OUT_2 + CH2_OFFSET: 'FLAP',
GPIO_OUT_3: 'GLOVE', GPIO_OUT_3 + CH2_OFFSET: 'STATUS',
}
def __init__(self, port: str, baudrate: int = 115200, log_callback=None):
if not HAS_SERIAL:
raise RuntimeError("pyserial not installed. Run: pip install pyserial")
self.ser = serial.Serial(port, baudrate, timeout=0.5,
stopbits=serial.STOPBITS_TWO,
parity=serial.PARITY_ODD)
self.ser.reset_input_buffer()
self.log_callback = log_callback
self.port = port
time.sleep(0.1)
self._log(f"Connected to {port} @ {baudrate} 8O2")
def _log(self, msg: str):
"""Log a message to the callback if set."""
if self.log_callback:
self.log_callback(msg)
def _addr_name(self, addr: int) -> str:
"""Get readable name for address."""
return self.ADDR_NAMES.get(addr, f'0x{addr:08X}')
def close(self):
self._log("Connection closed")
self.ser.close()
def write_reg(self, addr: int, data: int):
cmd = bytes([CMD_WRITE])
addr_bytes = struct.pack('>I', addr)
length = bytes([0x01])
data_bytes = struct.pack('>I', data)
packet = cmd + addr_bytes + length + data_bytes
self.ser.write(packet)
self.ser.flush()
self._log(f"WR {self._addr_name(addr)}: 0x{data:05X}")
def read_reg(self, addr: int) -> int:
cmd = bytes([CMD_READ])
addr_bytes = struct.pack('>I', addr)
length = bytes([0x01])
packet = cmd + addr_bytes + length
self.ser.reset_input_buffer()
self.ser.write(packet)
self.ser.flush()
resp = self.ser.read(4)
if len(resp) != 4:
self._log(f"RD {self._addr_name(addr)}: TIMEOUT ({len(resp)} bytes)")
return 0
value = struct.unpack('<I', resp)[0]
self._log(f"RD {self._addr_name(addr)}: 0x{value:05X} ({q119_to_float(value):+.4f})")
return value
def set_inputs(self, ps: float, qc: float, tat: float):
"""Set sensor inputs on FPGA."""
self._log(f"--- Set Inputs: PS={ps:.3f} QC={qc:.3f} TAT={tat:+.3f} ---")
ps_gray = bin_to_gray(float_to_q119(ps))
qc_gray = bin_to_gray(float_to_q119(qc))
tat_bin = float_to_q119(tat)
self.write_reg(GPIO_PS, ps_gray & Q119_MAX)
self.write_reg(GPIO_QC, qc_gray & Q119_MAX)
self.write_reg(GPIO_TAT, tat_bin & Q119_MAX)
time.sleep(0.05) # Wait for processing
def get_outputs(self) -> dict:
"""Read all outputs from FPGA."""
self._log("--- Read Outputs ---")
return {
'mach': q119_to_float(self.read_reg(GPIO_OUT_0)),
'alt': q119_to_float(self.read_reg(GPIO_OUT_0 + CH2_OFFSET)),
'airspd': q119_to_float(self.read_reg(GPIO_OUT_1)),
'vspd': q119_to_float(self.read_reg(GPIO_OUT_1 + CH2_OFFSET)),
'wing': q119_to_float(self.read_reg(GPIO_OUT_2)),
'flap': q119_to_float(self.read_reg(GPIO_OUT_2 + CH2_OFFSET)),
'glove': q119_to_float(self.read_reg(GPIO_OUT_3)),
'wing_cmd': 0.0 # Not available from hardware
}
# ============================================================================
# Wing Sweep Visualization
# ============================================================================
def draw_f14_planform(ax, wing_sweep: float, wing_cmd: float = None):
"""
Draw F-14 Tomcat planform view with variable sweep wings.
Based on reference 3-view diagram.
wing_sweep: Current wing position (-0.25 to 0.25 mapped to 20 deg to 68 deg)
"""
ax.clear()
# Map wing value to sweep angle
sweep_deg = 44 - wing_sweep * 96
sweep_deg = np.clip(sweep_deg, 20, 68)
sweep_rad = np.radians(sweep_deg)
# Colors
body_color = '#c0c0c0' # Light gray aircraft
body_edge = '#404040' # Dark edge
canopy_color = '#87ceeb' # Blue canopy
detail_color = '#808080' # Medium gray for details
# === MAIN FUSELAGE ===
fuselage = plt.Polygon([
# Nose (pointed)
[-1.15, 0],
# Forward fuselage widens
[-1.0, 0.03], [-0.8, 0.06], [-0.5, 0.09], [-0.2, 0.11],
# Center body at wing root
[0.0, 0.12], [0.3, 0.12],
# Aft fuselage narrows between engines
[0.5, 0.10], [0.7, 0.06], [0.85, 0.03],
# Tail point
[0.92, 0],
# Right side (mirror)
[0.85, -0.03], [0.7, -0.06], [0.5, -0.10],
[0.3, -0.12], [0.0, -0.12],
[-0.2, -0.11], [-0.5, -0.09], [-0.8, -0.06], [-1.0, -0.03],
], color=body_color, ec=body_edge, lw=1.5, zorder=10)
ax.add_patch(fuselage)
# === COCKPIT CANOPY ===
canopy = plt.Polygon([
[-0.72, 0.04], [-0.6, 0.055], [-0.42, 0.055], [-0.38, 0.04],
[-0.38, -0.04], [-0.42, -0.055], [-0.6, -0.055], [-0.72, -0.04],
], color=canopy_color, ec='#2b6cb0', lw=1.2, zorder=11)
ax.add_patch(canopy)
ax.plot([-0.52, -0.52], [-0.05, 0.05], color='#2b6cb0', lw=1.2, zorder=12)
# === WING GLOVES (fixed inner wing section) ===
for side in [1, -1]:
glove = plt.Polygon([
[-0.18, side * 0.11], # Inboard LE
[-0.30, side * 0.32], # Outboard LE (swept)
[0.18, side * 0.32], # Outboard TE
[0.12, side * 0.11], # Inboard TE
], color=body_color, ec=body_edge, lw=1.2, zorder=5)
ax.add_patch(glove)
# === VARIABLE SWEEP WINGS ===
pivot_x = 0.0
pivot_y = 0.32
wing_span = 0.58
chord_root = 0.26
chord_tip = 0.08
for side in [1, -1]:
# Wing tip position based on sweep
tip_x = pivot_x + wing_span * np.sin(sweep_rad)
tip_y = side * (pivot_y + wing_span * np.cos(sweep_rad))
wing = plt.Polygon([
[pivot_x - 0.06, side * pivot_y], # LE root
[tip_x, tip_y], # LE tip
[tip_x + chord_tip, tip_y], # TE tip
[pivot_x + chord_root, side * pivot_y], # TE root
], color=body_color, ec=body_edge, lw=1.5, zorder=4)
ax.add_patch(wing)
# === ENGINE NACELLES (under wings, extend to rear) ===
nacelle_inner = 0.16
nacelle_outer = 0.26
for side in [1, -1]:
# Main nacelle body
nacelle = plt.Polygon([
[-0.05, side * nacelle_inner],
[-0.05, side * nacelle_outer],
[0.95, side * nacelle_outer * 0.85],
[0.95, side * nacelle_inner * 0.9],
], color=detail_color, ec=body_edge, lw=1, zorder=7)
ax.add_patch(nacelle)
# Exhaust nozzle (darker)
nozzle = plt.Polygon([
[0.95, side * nacelle_inner * 0.9],
[0.95, side * nacelle_outer * 0.85],
[1.08, side * nacelle_outer * 0.7],
[1.08, side * nacelle_inner * 0.95],
], color='#303030', ec='#202020', lw=1, zorder=7)
ax.add_patch(nozzle)
# === HORIZONTAL STABILIZERS (tailerons) ===
for side in [1, -1]:
stab = plt.Polygon([
[0.62, side * 0.06], # Root LE
[0.72, side * 0.40], # Tip LE
[0.92, side * 0.35], # Tip TE
[0.88, side * 0.06], # Root TE
], color=body_color, ec=body_edge, lw=1.2, zorder=6)
ax.add_patch(stab)
# === TWIN VERTICAL STABILIZERS (canted outward - key F-14 feature) ===
# These are shown as trapezoids rotated to show cant angle
vstab_root_x = 0.52
vstab_tip_x = 0.82
cant_angle = 0.12 # Outward cant
for side in [1, -1]:
# Vertical stabilizer as a parallelogram (showing cant)
vstab = plt.Polygon([
[vstab_root_x, side * 0.08], # Root LE (bottom)
[vstab_root_x + 0.06, side * (0.08 + cant_angle)], # Tip LE (top, canted out)
[vstab_tip_x + 0.06, side * (0.08 + cant_angle)], # Tip TE
[vstab_tip_x, side * 0.08], # Root TE
], color=body_color, ec=body_edge, lw=1.2, zorder=15)
ax.add_patch(vstab)
# Rudder line
rudder_x = vstab_root_x + 0.22
ax.plot([rudder_x, rudder_x + 0.04],
[side * 0.08, side * (0.08 + cant_angle)],
color=body_edge, lw=0.8, zorder=16)
# === SPEED BRAKE (between vertical stabs) ===
speed_brake = plt.Polygon([
[0.55, 0.06], [0.55, -0.06],
[0.82, -0.05], [0.82, 0.05],
], color=detail_color, ec=body_edge, lw=0.8, zorder=14)
ax.add_patch(speed_brake)
# === PANEL LINES (detail) ===
# Wing fold lines
for side in [1, -1]:
fold_y = side * (pivot_y + 0.15)
ax.plot([pivot_x + 0.05, pivot_x + 0.12], [fold_y, fold_y],
color=body_edge, lw=0.5, ls='--', zorder=5)
# === DISPLAY ===
ax.set_xlim(-1.35, 1.25)
ax.set_ylim(-1.05, 1.05)
ax.set_aspect('equal')
ax.axis('off')
ax.set_facecolor('#f8f8f8')
# Sweep indicator
ax.text(0, -0.92, f'Wing Sweep: {sweep_deg:.1f} deg', fontsize=12,
ha='center', fontweight='bold', color='#2d3748')
# Only show commanded position in simulation mode
if wing_cmd is not None and wing_cmd != 0.0 and abs(wing_cmd - wing_sweep) > 0.001:
cmd_deg = 44 - wing_cmd * 96
cmd_deg = np.clip(cmd_deg, 20, 68)
ax.text(0, -0.80, f'(Cmd: {cmd_deg:.1f} deg)', fontsize=9,
ha='center', style='italic', color='#718096')
ax.set_title('F-14A Tomcat', fontsize=14, fontweight='bold', pad=8, color='#2d3748')
# ============================================================================
# Main Interactive Application
# ============================================================================
class CADCInteractive:
MAX_LOG_LINES = 15 # Max lines in terminal display
def __init__(self, live_interface=None):
self.live = live_interface
self.prev_wing = 0.0 # For rate limiting in simulation
self.log_lines = [] # Terminal log buffer
# Set up log callback if live
if self.live:
self.live.log_callback = self.add_log
# Create figure with subplots
self.fig = plt.figure(figsize=(16, 10))
self.fig.patch.set_facecolor('#f0f0f0')
# Main axes for wing display (top left)
self.ax_wing = self.fig.add_axes([0.02, 0.38, 0.38, 0.58])
# Output displays (top right)
self.ax_outputs = self.fig.add_axes([0.42, 0.38, 0.35, 0.58])
# Terminal display (right side)
self.ax_terminal = self.fig.add_axes([0.78, 0.08, 0.21, 0.88])
self._setup_terminal()
# Slider axes
slider_left = 0.18
slider_width = 0.47
slider_height = 0.03
ax_ps = self.fig.add_axes([slider_left, 0.25, slider_width, slider_height])
ax_qc = self.fig.add_axes([slider_left, 0.18, slider_width, slider_height])
ax_tat = self.fig.add_axes([slider_left, 0.11, slider_width, slider_height])
# Create sliders with physical context
self.slider_ps = Slider(ax_ps, 'Ps (Static Pressure)', 0.05, 0.95,
valinit=0.5, valstep=0.01, color='#3498db')
self.slider_qc = Slider(ax_qc, 'Qc (Impact Pressure)', 0.001, 0.5,
valinit=0.25, valstep=0.005, color='#e74c3c')
self.slider_tat = Slider(ax_tat, 'TAT (Temperature)', -0.5, 0.5,
valinit=0.375, valstep=0.025, color='#2ecc71')
# Mode indicator
mode_text = "LIVE FPGA" if self.live else "SIMULATION"
self.fig.text(0.5, 0.97, mode_text, fontsize=12, ha='center',
fontweight='bold', color='#c0392b' if self.live else '#2980b9')
# Connect events
self.slider_ps.on_changed(self.update)
self.slider_qc.on_changed(self.update)
self.slider_tat.on_changed(self.update)
# Preset buttons
ax_preset1 = self.fig.add_axes([0.02, 0.02, 0.10, 0.04])
ax_preset2 = self.fig.add_axes([0.13, 0.02, 0.10, 0.04])
ax_preset3 = self.fig.add_axes([0.24, 0.02, 0.10, 0.04])
ax_preset4 = self.fig.add_axes([0.35, 0.02, 0.10, 0.04])
ax_reset = self.fig.add_axes([0.55, 0.02, 0.10, 0.04])
ax_clear = self.fig.add_axes([0.66, 0.02, 0.10, 0.04])
self.btn_preset1 = Button(ax_preset1, 'Cruise', color='#ecf0f1', hovercolor='#bdc3c7')
self.btn_preset2 = Button(ax_preset2, 'High Speed', color='#ecf0f1', hovercolor='#bdc3c7')
self.btn_preset3 = Button(ax_preset3, 'Low Alt', color='#ecf0f1', hovercolor='#bdc3c7')
self.btn_preset4 = Button(ax_preset4, 'Ground', color='#ecf0f1', hovercolor='#bdc3c7')
self.btn_reset = Button(ax_reset, 'Reset Wing', color='#f39c12', hovercolor='#e67e22')
self.btn_clear = Button(ax_clear, 'Clear Log', color='#95a5a6', hovercolor='#7f8c8d')
self.btn_preset1.on_clicked(self.preset_cruise)
self.btn_preset2.on_clicked(self.preset_high_speed)
self.btn_preset3.on_clicked(self.preset_low_alt)
self.btn_preset4.on_clicked(self.preset_ground)
self.btn_reset.on_clicked(self.reset_wing)
self.btn_clear.on_clicked(self.clear_log)
# Initial log message
if self.live:
self.add_log("Ready - adjust sliders to send commands")
else:
self.add_log("SIMULATION MODE")
self.add_log("Use --live flag to connect to FPGA")
# Initial update
self.update(None)
def _setup_terminal(self):
"""Set up the terminal display area."""
ax = self.ax_terminal
ax.set_facecolor('#1e1e1e') # Dark background
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_color('#444444')
spine.set_linewidth(2)
ax.set_title('UART Terminal', fontsize=10, color='#2c3e50',
fontweight='bold', pad=5)
def add_log(self, msg: str):
"""Add a message to the terminal log."""
timestamp = time.strftime('%H:%M:%S')
self.log_lines.append(f"{timestamp} {msg}")
# Keep only last N lines
if len(self.log_lines) > self.MAX_LOG_LINES:
self.log_lines = self.log_lines[-self.MAX_LOG_LINES:]
self._update_terminal()
def _update_terminal(self):
"""Refresh the terminal display."""
ax = self.ax_terminal
ax.clear()
ax.set_facecolor('#1e1e1e')
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_color('#444444')
spine.set_linewidth(2)
ax.set_title('UART Terminal', fontsize=10, color='#2c3e50',
fontweight='bold', pad=5)
# Draw log lines (bottom to top, newest at bottom)
line_height = 0.055
y_start = 0.95
for i, line in enumerate(self.log_lines):
y = y_start - i * line_height
if y < 0.02:
break
# Color based on content
if 'WR ' in line:
color = '#4fc3f7' # Light blue for writes
elif 'RD ' in line:
color = '#81c784' # Green for reads
elif 'TIMEOUT' in line or 'ERROR' in line:
color = '#ef5350' # Red for errors
elif '---' in line:
color = '#ffb74d' # Orange for section headers
else:
color = '#e0e0e0' # Light gray for other
ax.text(0.02, y, line, fontsize=7, color=color,
fontfamily='monospace', va='top',
transform=ax.transAxes)
def clear_log(self, event):
"""Clear the terminal log."""
self.log_lines = []
self.add_log("Log cleared")
self.fig.canvas.draw_idle()
def get_outputs(self, ps, qc, tat):
"""Get outputs from simulation or live hardware."""
if self.live:
self.live.set_inputs(ps, qc, tat)
return self.live.get_outputs()
else:
outputs = calculate_outputs(ps, qc, tat, self.prev_wing)
self.prev_wing = outputs['wing']
return outputs
def update(self, val):
"""Update visualization when sliders change."""
ps = self.slider_ps.val
qc = self.slider_qc.val
tat = self.slider_tat.val
outputs = self.get_outputs(ps, qc, tat)
# Update wing display
draw_f14_planform(self.ax_wing, outputs['wing'], outputs.get('wing_cmd'))
# Update output displays
self.draw_outputs(outputs, ps, qc, tat)
self.fig.canvas.draw_idle()
def draw_outputs(self, outputs, ps, qc, tat):
"""Draw output gauges and values."""
ax = self.ax_outputs
ax.clear()
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
# Convert normalized outputs to physical units
# Note: Mach and Airspeed are always positive physical quantities
mach_real = abs(outputs['mach']) * SCALE_MACH
alt_real = outputs['alt'] * SCALE_ALT_FT
airspd_real = abs(outputs['airspd']) * SCALE_AIRSPD_KTS
vspd_real = outputs['vspd'] * SCALE_VSPD_FPM
flap_real = outputs['flap'] * SCALE_FLAP_DEG
glove_real = outputs['glove'] * SCALE_GLOVE_DEG
# Clamp to realistic F-14 ranges
mach_real = min(mach_real, 2.5) # Max Mach 2.5
alt_real = max(0, min(alt_real, 60000)) # 0-60,000 ft
airspd_real = max(0, min(airspd_real, 800)) # 0-800 kts
flap_real = max(0, min(flap_real, 35)) # 0-35°
# Title
ax.text(5, 9.5, 'CADC Outputs (Physical Units)', fontsize=14, ha='center',
fontweight='bold', color='#2c3e50')
# Output bars with physical values
bar_names = ['Mach', 'Altitude', 'Airspeed', 'Flap', 'Glove', 'Vspd']
bar_values_norm = [outputs['mach'], outputs['alt'], outputs['airspd'],
outputs['flap'], outputs['glove'], outputs['vspd']]
bar_values_real = [mach_real, alt_real, airspd_real, flap_real, glove_real, vspd_real]
bar_units = ['', 'ft', 'kts', 'deg', 'deg', 'ft/min']
bar_formats = ['{:.2f}', '{:.0f}', '{:.0f}', '{:.1f}', '{:.1f}', '{:+.0f}']
bar_colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c']
bar_ranges = [(0, 1), (0, 1), (0, 0.5), (0, 1), (-0.1, 0.1), (-0.2, 0.2)]
y_positions = [8, 6.8, 5.6, 4.4, 3.2, 2.0]
for i, (name, val_norm, val_real, unit, fmt, color, (vmin, vmax), y) in enumerate(
zip(bar_names, bar_values_norm, bar_values_real, bar_units,
bar_formats, bar_colors, bar_ranges, y_positions)):
# Background bar
bar_bg = FancyBboxPatch((1.5, y - 0.25), 6, 0.5,
boxstyle="round,pad=0.02",
facecolor='#ecf0f1', edgecolor='#bdc3c7', lw=1)
ax.add_patch(bar_bg)
# Value bar (normalized)
if vmax != vmin:
norm_val = np.clip((val_norm - vmin) / (vmax - vmin), 0, 1)
else:
norm_val = 0.5
bar_width = 6 * norm_val
bar = FancyBboxPatch((1.5, y - 0.2), bar_width, 0.4,
boxstyle="round,pad=0.02",
facecolor=color, edgecolor='none', alpha=0.8)
ax.add_patch(bar)
# Label
ax.text(0.5, y, name, fontsize=11, va='center', ha='left',
fontweight='bold', color='#2c3e50')
# Value text with real units
value_str = fmt.format(val_real) + ' ' + unit
ax.text(8.0, y, value_str, fontsize=10, va='center', ha='left',
fontfamily='monospace', color='#2c3e50', fontweight='bold')
# Input values display (normalized sensor values)
# Show approximate physical interpretation
alt_approx = (1.0 - ps) * 60000 # Higher Ps = lower alt
ax.text(5, 0.8, f'Sensors: Ps={ps:.2f} (~{alt_approx/1000:.0f}kft) Qc={qc:.3f} TAT={tat:+.2f}',
fontsize=9, ha='center', color='#7f8c8d', fontfamily='monospace')
# Wing status box
wing = outputs['wing']
wing_cmd = outputs.get('wing_cmd', wing)
sweep_deg = 44 - wing * 96
sweep_deg = np.clip(sweep_deg, 20, 68)
wing_box = FancyBboxPatch((0.2, 0.2), 9.6, 0.4,
boxstyle="round,pad=0.02",
facecolor='#2c3e50', edgecolor='none')
ax.add_patch(wing_box)
ax.text(5, 0.4, f'Wing Sweep: {sweep_deg:.1f} deg (20=forward, 68=swept)',
fontsize=10, ha='center', va='center', color='white',
fontfamily='monospace', fontweight='bold')
def preset_cruise(self, event):
"""Set cruise flight conditions."""
self.slider_ps.set_val(0.5)
self.slider_qc.set_val(0.25)
self.slider_tat.set_val(0.375)
def preset_high_speed(self, event):
"""Set high Mach conditions (wings swept back)."""
self.slider_ps.set_val(0.4)
self.slider_qc.set_val(0.35)
self.slider_tat.set_val(0.4)
def preset_low_alt(self, event):
"""Set low altitude conditions."""
self.slider_ps.set_val(0.8)
self.slider_qc.set_val(0.3)
self.slider_tat.set_val(0.5)
def preset_ground(self, event):
"""Set ground/takeoff conditions (wings forward)."""
self.slider_ps.set_val(0.9)
self.slider_qc.set_val(0.001)
self.slider_tat.set_val(0.5)
def reset_wing(self, event):
"""Reset wing position memory (for simulation mode)."""
self.prev_wing = 0.0
self.update(None)
def run(self):
"""Show the interactive display."""
plt.show()
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description='F-14A CADC Interactive Visualization',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python cadc_interactive.py # Simulation mode
python cadc_interactive.py --live # Live FPGA on default port
python cadc_interactive.py --live --port COM3 # Live FPGA on COM3
""")
parser.add_argument('--live', action='store_true',
help='Connect to real FPGA hardware')
parser.add_argument('--port', default='/dev/ttyAMA2',
help='Serial port (default: /dev/ttyAMA2)')
parser.add_argument('--baud', type=int, default=115200,
help='Baud rate (default: 115200)')
args = parser.parse_args()
live_interface = None
if args.live:
if not HAS_SERIAL:
print("ERROR: pyserial not installed. Run: pip install pyserial")
sys.exit(1)
try:
print(f"Connecting to FPGA on {args.port}...")
live_interface = LiveInterface(args.port, args.baud)
print("Connected!")
except Exception as e:
print(f"ERROR: Could not connect to FPGA: {e}")
print("Running in simulation mode instead.")
live_interface = None
print("Starting CADC Interactive Visualization...")
print("Use sliders to adjust flight conditions.")
print("Watch the wing sweep change with Mach number!")
app = CADCInteractive(live_interface)
app.run()
if live_interface:
live_interface.close()
if __name__ == '__main__':
main()Wrap UpThis has been an interesting project which has combined my love of aircraft and FPGAs. The end result is an excellent demo I can take to conferences and I am currently working on a scale model of the F14 with servos so we can actually move its wings.
This also highlights a very useful application of AI when working on legacy and historical projects. We can leverage AI and our engineering knowledge to quickly create documents and information for legacy designs which are not fully documented. Either because we lost the documentation over time or it was never generated in the first place.
You can find the git repo of the design and all sources here https://github.com/ATaylorCEngFIET/f14_CADC







Comments