405 lines
20 KiB
Markdown
405 lines
20 KiB
Markdown
# 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 (0–359, 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
|
||
db_panel.h / db_panel.cpp — Dear ImGui database management panel
|
||
(active only when --database flag passed;
|
||
no radar rendering in this mode)
|
||
settings.h — all tunable constants (no .cpp needed)
|
||
|
||
imgui/ — Dear ImGui source, compiled into project
|
||
imgui.h / imgui.cpp
|
||
imgui_impl_glfw.h / imgui_impl_glfw.cpp
|
||
imgui_impl_opengl3.h / imgui_impl_opengl3.cpp
|
||
imgui_draw.cpp / imgui_tables.cpp / imgui_widgets.cpp
|
||
|
||
shaders/
|
||
phosphor.vert / phosphor.frag — parameterized for P1 and P7 via uniforms
|
||
graticule.vert / graticule.frag
|
||
text.vert / text.frag
|
||
sweep.vert / sweep.frag
|
||
bloom.vert / bloom.frag — two-pass bloom: render to FBO, Gaussian
|
||
blur bright spots, blend back; used for
|
||
target blooming from radar equation output
|
||
```
|
||
|
||
---
|
||
|
||
## 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 (0–360), range (0–radar max), amplitude (0–1), altitude (0–60,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).
|
||
|
||
12. **All dimensions are stored and computed in meters** throughout the system.
|
||
Any incoming data from Raspberry Pi receivers or the simulator that arrives in
|
||
feet (e.g., altitude from ADS-B in feet, antenna heights) is converted to
|
||
meters at the boundary in `RPiReceiver::parseFrame()` or `Simulator::poll()`
|
||
before the value enters any shared data structure. No feet values appear
|
||
anywhere inside the system after the conversion point.
|
||
Conversion: 1 foot = 0.3048 meters exactly.
|
||
|
||
13. **Default target dimensions** used when a target is first seen with no
|
||
database record. All values in meters. `need_update` is set TRUE for all
|
||
defaults so the operator knows to fill in real data.
|
||
|
||
| Category | Length (m) | Fuselage/Beam width (m) | Material |
|
||
|---|---|---|---|
|
||
| GA aircraft | 4.0 | 1.0 | aluminum |
|
||
| Commercial a/c| 30.0 | 5.0 | aluminum |
|
||
| AIS vessel | 20.0 | 5.0 | steel |
|
||
| Simulator boat| 6.0 | 2.0 | fiberglass |
|
||
|
||
AIS-sourced vessels default to steel (legally required commercial traffic).
|
||
Simulator-sourced boats default to fiberglass (small pleasure craft).
|
||
Aircraft source type does not disambiguate GA vs. commercial — the system
|
||
defaults to GA and lets the operator correct it.
|
||
|
||
14. **Bloom post-processing** uses a dedicated `bloom.vert` / `bloom.frag` shader
|
||
pair. The pipeline is: render targets to an offscreen FBO at full computed
|
||
brightness (from radar equation output), apply a two-pass Gaussian blur to
|
||
pixels above a luminance threshold, then additively blend the blurred result
|
||
back onto the main framebuffer. Bloom threshold and blur radius are
|
||
`constexpr` constants in `settings.h`.
|
||
|
||
15. **Dear ImGui** is used for the database management panel, activated by the
|
||
`--database` command-line flag. In that mode no radar rendering occurs —
|
||
main.cpp skips the scope/shader initialization path entirely and starts the
|
||
ImGui loop instead. ImGui source files live under `src/imgui/` and are
|
||
compiled directly into the project (no separate install step). The panel
|
||
provides: a scrollable target table with `need_update` highlighted, inline
|
||
edit fields for length/width/height/material, a dropdown for target type,
|
||
and a Save button that writes to PostgreSQL via libpq.
|
||
|
||
16. **Chain Home RCS resonance** is modelled with a multiplier constant
|
||
`CHAIN_HOME_RCS_RESONANCE_FACTOR` in `settings.h`. At 30 MHz (λ ≈ 10 m),
|
||
aircraft with wingspans of 10–30 m are in the Mie/resonant scattering
|
||
region; RCS can be 2–5× the geometric cross section. The default value is
|
||
3.0 (a mid-range estimate). This is applied in the radar equation computation
|
||
for Chain Home targets only, before the result is passed to the bloom/
|
||
brightness pipeline.
|