Files
updated-radar/DESIGN.md

16 KiB
Raw Blame History

Radar Simulation — Class Design and File Layout

Author: Mark Allyn


Class Hierarchy

Scope  (abstract base)
├── ExhibitIntro
├── AScope  (abstract)
│   ├── MarineAScope
│   └── ChainHomeAScope
├── PPIScope  (abstract)
│   ├── MarinePPIScope
│   └── ATCPPIScope
└── PARScope

Class Descriptions

Scope (abstract base)

Everything all scopes share:

  • Left panel text rendering
  • s / S key handling (scope advance / reverse)
  • Auto-advance timer reset on any key or control input
  • Pure virtual methods: render(), handleKey(), getDescription()

ExhibitIntro : public Scope

  • Text-only rendering, no radar display
  • Header: "WELCOME TO MUSEUM VINTAGE RADAR EXHIBIT" (all caps)
  • Emphasizes s/S keys and 120-second auto-advance

AScope : public Scope (abstract)

Shared A-scope behavior:

  • Horizontal range axis, vertical amplitude axis
  • Noise floor rendering (rain/wave clutter)
  • Incandescent graticule (three horizontal amplitude lines + vertical range lines)
  • Bearing control with key-hold acceleration
  • Phosphor type as parameter (P1 or P7)

MarineAScope : public AScope

  • P1 phosphor (green)
  • Range settings: 2, 4, 6 miles
    • Max 2: one interim range at 1
    • Max 4: one interim range at 2
    • Max 6: one interim range at 4
  • Keys: c (bearing CW), v (bearing CCW), u (range up), d (range down)
    • u and d are ignored during graticule swap animation

Graticule swap animation: In the period, changing max range required the operator to physically slide the glass graticule panel upward and out from in front of the CRT, then slide the replacement graticule (calibrated for the new range) downward into position. This is simulated.

State machine triggered by u or d key:

NORMAL graticule in place, scope operating normally | | u or d pressed v SLIDING_OUT old graticule translates upward off screen (~0.5 seconds) | v BARE_CRT no graticule rendered; CRT trace and noise floor still running | v SLIDING_IN new graticule slides down into position (~0.5 seconds) | v NORMAL new range now active

  • u and d keys ignored while state != NORMAL
  • Graticule remains incandescent color throughout (edge-lit glass, not CRT-dependent)

ChainHomeAScope : public AScope

  • P7 phosphor (early implementation, slow decay for slow PRF)
  • Goniometer state: H/V mode toggle, azimuth angle, elevation angle
  • PRF toggle: 25 Hz / 12.5 Hz
  • Calibrator stretch/shrink scale factor
  • Fixed 100-mile range (no range change)
  • Keys: [ (goniometer H/V toggle), 9 (tune left), 0 (tune right), . (PRF toggle), n (calibrator shrink), m (calibrator stretch)

PPIScope : public Scope (abstract)

Shared PPI behavior:

  • Clockwise sweep with P7 phosphor persistence
    • Immediate beam strike: blue
    • Persistence: green/yellow, faded by next sweep pass
  • Incandescent bearing graticule:
    • Inner ring with 1-degree tick marks (0359, True North = 0)
    • Text labels every 15 degrees
    • Outer ring
  • Yellow cursor: 10-degree arc section + bearing crossline
  • Cursor range/bearing readout displayed under scope (white text)
  • Bearing offset for boat mode (k = right, j = left)
  • Keys: r (cursor bearing right), l (cursor bearing left), t (cursor range increase), y (cursor range decrease), k (antenna bearing offset right), j (antenna bearing offset left)
  • Cursor range clamped to max range if exceeded

MarinePPIScope : public PPIScope

  • Sweep time: 4 seconds
  • Max range settings: 2, 4, 6 miles
    • Max 2: rings at 1, 2
    • Max 4: rings at 2, 4
    • Max 6: rings at 4, 6
  • Keys: u (range up), d (range down) — affects only this scope

ATCPPIScope : public PPIScope

  • Sweep time: 5 seconds
  • Max range settings: 5, 10, 15, 20 miles
    • Max 5: rings at 2.5, 5
    • Max 10: rings at 2, 4, 6, 8, 10
    • Max 15: rings at 4, 8, 12, 15
    • Max 20: rings at 5, 10, 15, 20
  • Keys: u (range up), d (range down) — affects only this scope

PARScope : public Scope

  • Two vertically stacked sub-scopes (right panel):
    • Top: azimuth (lateral deviation vs. range) — ~1/3 larger
    • Bottom: elevation (vertical deviation vs. range)
  • P7 phosphor; graticules are incandescent etched glass
  • 30 Hz alternating scan between azimuth and elevation planes (each plane scans at ~15 Hz, i.e., 1/15 second per plane)
  • Fixed 10-mile range — no range change control
  • Non-linear horizontal scale: inner 5 miles occupies 70% of width
  • All targets simulated; no cursor or bearing controls
  • Located at south end of Runway 16/34, BLI — active runway 34 (northbound)

Supporting Classes

Class Thread Purpose
ScopeManager 1 Owns scope list; handles s/S switching and 120s auto-advance timer
PhosphorRenderer 1 P1 and P7 decay/persistence simulation; shared dependency
Graticule 1 Draws incandescent graticule lines and text; parameterized per scope
LeftPanel 1 Renders scope description text panel (left side of window)
SharedRenderState 1,2,3 Mutex A — state variables Thread 1 reads each frame to push as shader uniforms
TargetBuffer 2,4 Mutex B — target data handoff between Thread 2 (traffic cop) and Thread 4 (simulator)
TrafficCop 2 Polls Simulator and RPi receivers each beam update; writes targets to SharedRenderState
Simulator 4 Runs fake targets independently; returns data to TrafficCop when polled
KnobPanel 3 Future hardware stub — idles without acquiring Mutex A until Arduino hardware is wired; SharedRenderState holds compile-time defaults from settings.h
RPiReceiver 2 Stub — one instance per Raspberry Pi; called by TrafficCop

Thread Summary

Thread Class(es) Mutex Access
Thread 1 ScopeManager, all Scope subclasses, PhosphorRenderer, Graticule, LeftPanel Reads SharedRenderState under Mutex A
Thread 2 TrafficCop, RPiReceiver Writes SharedRenderState under Mutex A; reads TargetBuffer under Mutex B
Thread 3 KnobPanel Writes SharedRenderState under Mutex A
Thread 4 Simulator Writes TargetBuffer under Mutex B

Keyboard input arrives via GLFW callback (glfwSetKeyCallback) in Thread 1. Thread 1 dispatches s/S to ScopeManager and all other keys to the active Scope.


Proposed File Layout

src/
  main.cpp                           — GLFW init, thread launch, main loop
  scope_manager.h / scope_manager.cpp
  scope.h / scope.cpp                — abstract Scope base class
  scope_intro.h / scope_intro.cpp    — ExhibitIntro
  scope_ascope.h / scope_ascope.cpp  — abstract AScope
  scope_marine_a.h / scope_marine_a.cpp
  scope_chain_home.h / scope_chain_home.cpp
  scope_ppi.h / scope_ppi.cpp        — abstract PPIScope
  scope_marine_ppi.h / scope_marine_ppi.cpp
  scope_atc_ppi.h / scope_atc_ppi.cpp
  scope_par.h / scope_par.cpp
  phosphor.h / phosphor.cpp
  graticule.h / graticule.cpp
  left_panel.h / left_panel.cpp
  shared_render_state.h / shared_render_state.cpp
  target_buffer.h / target_buffer.cpp
  traffic_cop.h / traffic_cop.cpp
  simulator.h / simulator.cpp
  knob_panel.h / knob_panel.cpp
  rpi_receiver.h / rpi_receiver.cpp
  settings.h                             — all tunable constants (no .cpp needed)

shaders/
  phosphor.vert / phosphor.frag      — parameterized for P1 and P7 via uniforms
  graticule.vert / graticule.frag
  text.vert / text.frag
  sweep.vert / sweep.frag

Robustness and Safety

Input Rate Limiting (the mad-child problem)

GLFW key callbacks can fire hundreds of times per second under key-mashing or hardware encoder noise. Each control variable carries a last_event_time timestamp alongside its value. Any input arriving within MIN_INPUT_INTERVAL_MS of the previous accepted input for that control is silently discarded. The KEY_MAX_STEP / KEY_GONIO_MAX_STEP constants cap how far a single accepted input can move a value. Together these make it impossible to slam a bearing to an extreme in under a second regardless of how fast the input fires.

Control Variable Clamping

Every state variable that a control modifies has companion MIN_* and MAX_* constants in settings.h. The clamp is applied at the point of write using std::clamp(), in every code path: keyboard callback, KnobPanel, and any future source. Thread 1 reads already-clean values and applies a second std::clamp() before passing to the shader as a second line of defense. No validation logic is needed at the shader boundary.

Incoming Data Validation

RPiReceiver::parseFrame() and Simulator::poll() both return std::optional<TargetData>. A return of std::nullopt means the frame was malformed or out of bounds. TrafficCop discards nullopt silently — no exception, no abort, no log spam. Fields validated: bearing (0360), range (0radar max), amplitude (01), altitude (060,000 ft), timestamp (not stale, not future), target count (truncated to MAX_SIMULTANEOUS_TARGETS), and frame byte length (rejected above MAX_RPI_FRAME_BYTES).

Array Bounds Safety

  • All fixed-size target storage uses std::array<TargetData, MAX_TARGETS> — never a raw C array.
  • Array views passed between functions use std::span<TargetData> (C++20) — size always travels with the pointer.
  • Ring buffers for phosphor persistence use modulo indexing (index % CAPACITY), never raw pointer arithmetic.
  • static_assert guards validate that MAX_TARGETS and similar constants are within sane limits at compile time.
  • Bounds-checked access (.at()) used in debug builds; validated index used in release.

Thread Safety Rules

  1. Snapshot pattern: Thread 1 acquires Mutex A, copies the entire SharedRenderState struct, releases the lock immediately, then renders from the local copy. No OpenGL calls are ever made while holding a mutex (Core Guideline CP.22).
  2. Always std::scoped_lock: Mutexes are never locked/unlocked manually. std::scoped_lock releases on all exit paths including exceptions (SEI CERT CON51).
  3. Lock ordering: If code ever needs both Mutex A and Mutex B simultaneously, A must be acquired first. Documented here as a project-wide invariant to prevent deadlock (SEI CERT CON53).
  4. Atomic simple flags: Boolean toggles that are written by one thread and read by another (prf_high, goniometer_mode) use std::atomic<bool> — no mutex overhead for single-variable reads.
  5. KnobPanel idle guarantee: Until hardware is connected, KnobPanel's thread loop sleeps and never calls lock() on Mutex A. Zero contention on that mutex from Thread 3.

RAII Requirements

Every resource that is opened must be wrapped in an RAII holder so that it closes on any exit path:

Resource RAII mechanism
OpenGL VAO / VBO / texture / shader Thin GLHandle<T> wrapper; destructor calls glDelete*()
GLFW window unique_ptr<GLFWwindow, decltype(&glfwDestroyWindow)>
FreeType FT_Library / FT_Face Small RAII structs; destructor calls FT_Done_*
Mutex locks std::scoped_lock or std::lock_guard — never bare lock/unlock
Worker threads std::jthread (C++20) — auto-joins on destruction; no detached threads
File handles (shader source) std::ifstream — RAII by default

No raw new or delete anywhere in the codebase. No malloc/free.

SEI CERT C++ Compliance — Key Rules

Rule Enforcement
CON50 Never destroy a mutex while locked — std::scoped_lock makes this structurally impossible
CON51 Release locks on exceptions — std::scoped_lock handles automatically
CON53 Consistent lock ordering (A before B) — documented invariant
ARR50 Array indices always validated before use or bounded by std::array::at()
MEM51 All resources managed by RAII wrappers; no raw new/delete
ERR50 No abort() or exit() in normal operation; bad data is dropped, not fatal
INT30 Bearing arithmetic uses fmod(), not unsigned wraparound subtraction
FLP30 No floating-point loop counters; sweep angle is a double accumulator
OOP50 No virtual method calls in constructors or destructors

C++ Core Guidelines Compliance — Key Rules

Guideline Enforcement
I.6 / I.7 Expects() / Ensures() (GSL) at function entry/exit in debug builds
I.12 gsl::not_null<Scope*> for the active scope pointer in ScopeManager
R.1 All resource management through RAII handles (see table above)
CP.20 std::scoped_lock for all mutex acquisition — no bare lock/unlock
CP.21 std::scoped_lock(mutexA, mutexB) if both must be held simultaneously
CP.22 No OpenGL calls while holding any mutex — snapshot pattern enforces this
Bounds.1 No pointer arithmetic; std::span for array views
Bounds.2 Array access only via .at() or pre-validated index
Bounds.4 No sprintf, strcpy, gets; use std::string and std::format (C++20)

Key Design Notes

  1. ScopeManager sits in Thread 1 and holds the active scope pointer. The GLFW key callback calls ScopeManager::handleKey(), which dispatches s/S to itself and all other keys to the active scope. The auto-advance timer resets on any key event or control input.

  2. SharedRenderState holds two categories: control state (bearing, range, cursor — written by Thread 1 keyboard callbacks and Thread 3 knob panel) and target state (written by Thread 2). Thread 1 reads the whole struct once per frame under Mutex A to push uniforms to the shaders.

  3. TargetBuffer is separate from SharedRenderState — it is the handoff point between Thread 2 (traffic cop) and Thread 4 (simulator) under Mutex B.

  4. PhosphorRenderer is a shared utility. AScope and PPIScope subclasses receive it as a constructor dependency rather than each reimplementing decay logic. Pass phosphor type (P1/P7) and decay constant as parameters.

  5. Shaders are parameterized via uniforms rather than duplicated. A single phosphor shader pair handles both P1 (green, no persistence) and P7 (blue strike, yellow-green decay) by passing color and decay time as uniforms.

  6. Shoreline and terrain geometry is loaded once at Thread 1 startup as a static VBO. It is read-only after load — no mutex needed.

  7. Each scope's max range and cursor state are independent. Changing range on the Marine PPI does not affect the ATC PPI, and vice versa. State is owned by each scope instance.

  8. PAR sub-scopes (azimuth and elevation) can be implemented as private member objects within PARScope rather than as separate Scope subclasses, since they are never displayed independently.

  9. settings.h is a header-only file (no .cpp). It will contain only constexpr constants organized into named sections. Every other source file that needs a tunable value includes this file. No magic numbers anywhere else in the codebase. Add candidate variables here before coding begins; values can be refined during debugging and appearance work. Sections defined: phosphor colors (P1/P7), sweep parameters, graticule geometry and colors, cursor, noise floor, graticule swap animation timing, key-hold acceleration, auto-advance timer, window/layout geometry, text colors/sizes, general operator control defaults (Intensity, Focus, Astigmatism, Gain, Rain Clutter, Wave Clutter, Graticule Intensity), per-scope default state (initial bearing, range, cursor, calibrator scale for each scope), and PAR geometry (non-linear scale breakpoint, runway heading, glide path angle, scan widths).

  10. General operator controls (Intensity, Focus, Astigmatism, Gain, Rain Clutter, Wave Clutter, Graticule Intensity) are placeholders for physical encoders not yet purchased. Each has a DEFAULT_* constant in settings.h and a corresponding variable in SharedRenderState. KnobPanel (Thread 3) has a stub write path for each. The thread starts and idles, but never calls lock() on Mutex A, so it imposes zero contention. When hardware arrives, only KnobPanel changes — Thread 1 and the shaders are already fully wired to consume the values.

  11. MarineAScope graticule swap is a state machine with four states: NORMAL → SLIDING_OUT → BARE_CRT → SLIDING_IN → NORMAL. The u and d keys are blocked during the animation. The new range value is latched when the key is pressed but not applied to the scope state until SLIDING_IN completes. Animation duration is approximately 0.5 seconds per slide (out and in).