From 05df12bd54fda0b34c545d6f28494073274b3942 Mon Sep 17 00:00:00 2001 From: Mickey Slaven Date: Sun, 9 Aug 2026 11:36:57 -0400 Subject: [PATCH] Add multi-channel KrakenSDR Suite V2 support --- CMakeLists.txt | 74 ++++- Dockerfile | 7 +- README.md | 77 ++++- config/config-kraken.yml | 39 ++- docker/Dockerfile-kraken | 25 +- lib/vcpkg-kraken.json | 19 ++ lib/vcpkg.json | 4 +- src/blah2.cpp | 284 +++++++++++++++--- src/capture/Capture.cpp | 45 ++- src/capture/Capture.h | 12 +- src/capture/Source.cpp | 21 +- src/capture/Source.h | 12 +- src/capture/kraken/HeimdallFrame.cpp | 122 ++++++++ src/capture/kraken/HeimdallFrame.h | 41 +++ src/capture/kraken/Kraken.cpp | 281 +++++++++++------ src/capture/kraken/Kraken.h | 96 ++---- src/data/IqData.cpp | 50 ++- src/data/IqData.h | 21 +- src/process/ambiguity/Ambiguity.cpp | 28 +- src/process/ambiguity/Ambiguity.h | 4 + src/process/clutter/WienerHopf.cpp | 6 +- src/process/clutter/WienerHopf.h | 7 +- .../ArrayReferenceSynthesizer.cpp | 135 +++++++++ .../conditioning/ArrayReferenceSynthesizer.h | 42 +++ src/process/fusion/Noncoherent.cpp | 28 ++ src/process/fusion/Noncoherent.h | 17 ++ src/process/spectrum/SpectrumAnalyser.cpp | 6 +- src/process/spectrum/SpectrumAnalyser.h | 7 +- .../unit/capture/kraken/TestHeimdallFrame.cpp | 121 ++++++++ .../TestArrayReferenceSynthesizer.cpp | 39 +++ test/unit/process/fusion/TestNoncoherent.cpp | 22 ++ 31 files changed, 1407 insertions(+), 285 deletions(-) create mode 100644 lib/vcpkg-kraken.json create mode 100644 src/capture/kraken/HeimdallFrame.cpp create mode 100644 src/capture/kraken/HeimdallFrame.h create mode 100644 src/process/conditioning/ArrayReferenceSynthesizer.cpp create mode 100644 src/process/conditioning/ArrayReferenceSynthesizer.h create mode 100644 src/process/fusion/Noncoherent.cpp create mode 100644 src/process/fusion/Noncoherent.h create mode 100644 test/unit/capture/kraken/TestHeimdallFrame.cpp create mode 100644 test/unit/process/conditioning/TestArrayReferenceSynthesizer.cpp create mode 100644 test/unit/process/fusion/TestNoncoherent.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bd44c66a..c0b171d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,8 @@ project(blah2) include(CMakePrintHelpers) include(CTest) +option(BLAH2_KRAKEN_ONLY "Build only the Kraken capture backend" OFF) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Werror") find_package(Threads REQUIRED) @@ -14,11 +16,13 @@ find_package(asio REQUIRED) find_path(RAPIDJSON_INCLUDE_DIRS "rapidjson/allocators.h") find_package(ryml CONFIG REQUIRED) find_package(httplib CONFIG REQUIRED) -find_package(Armadillo CONFIG REQUIRED) +find_package(Armadillo REQUIRED) find_package(Catch2 CONFIG REQUIRED) -set(CMAKE_PREFIX_PATH "/opt/uhd" ${CMAKE_PREFIX_PATH}) -find_package(UHD "4.8.0.0" CONFIG REQUIRED) +if(NOT BLAH2_KRAKEN_ONLY) + set(CMAKE_PREFIX_PATH "/opt/uhd" ${CMAKE_PREFIX_PATH}) + find_package(UHD "4.8.0.0" CONFIG REQUIRED) +endif() set(PROJECT_ROOT "${PROJECT_SOURCE_DIR}") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_ROOT}/bin") @@ -31,24 +35,46 @@ message("Binary path: ${PROJECT_BINARY_DIR}") message("Binary test path: ${PROJECT_BINARY_TEST_DIR}") # include from top-level src dir -include_directories(src ${UHD_INCLUDE_DIRS}) +include_directories(src) +if(NOT BLAH2_KRAKEN_ONLY) + include_directories(${UHD_INCLUDE_DIRS}) +endif() # TODO: create FindSdrplay.cmake for this -add_library(sdrplay /usr/local/include/sdrplay_api.h) -set_target_properties(sdrplay PROPERTIES LINKER_LANGUAGE C) -target_link_libraries(sdrplay PUBLIC /usr/local/lib/libsdrplay_api.so.3.15) +if(NOT BLAH2_KRAKEN_ONLY) + add_library(sdrplay /usr/local/include/sdrplay_api.h) + set_target_properties(sdrplay PROPERTIES LINKER_LANGUAGE C) + target_link_libraries(sdrplay PUBLIC /usr/local/lib/libsdrplay_api.so.3.15) +endif() + +set(BLAH2_CAPTURE_SOURCES + src/capture/kraken/Kraken.cpp + src/capture/kraken/HeimdallFrame.cpp +) +set(BLAH2_CAPTURE_LIBRARIES) +if(NOT BLAH2_KRAKEN_ONLY) + list(APPEND BLAH2_CAPTURE_SOURCES + src/capture/rspduo/RspDuo.cpp + src/capture/usrp/Usrp.cpp + src/capture/hackrf/HackRf.cpp + ) + list(APPEND BLAH2_CAPTURE_LIBRARIES + ${UHD_LIBRARIES} + sdrplay + hackrf + ) +endif() # TODO: Move to separate src/CMakeLists.txt add_executable(blah2 src/blah2.cpp src/capture/Capture.cpp src/capture/Source.cpp - src/capture/rspduo/RspDuo.cpp - src/capture/usrp/Usrp.cpp - src/capture/hackrf/HackRf.cpp - src/capture/kraken/Kraken.cpp + ${BLAH2_CAPTURE_SOURCES} src/process/ambiguity/Ambiguity.cpp src/process/clutter/WienerHopf.cpp + src/process/conditioning/ArrayReferenceSynthesizer.cpp + src/process/fusion/Noncoherent.cpp src/process/detection/CfarDetector1D.cpp src/process/detection/Centroid.cpp src/process/detection/Interpolate.cpp @@ -69,14 +95,14 @@ target_link_libraries(blah2 PRIVATE ryml::ryml httplib::httplib armadillo - ${UHD_LIBRARIES} fftw3 fftw3_threads - sdrplay - hackrf - rtlsdr + ${BLAH2_CAPTURE_LIBRARIES} ) target_include_directories(blah2 PRIVATE RAPIDJSON_INCLUDE_DIRS "rapidjson/allocators.h") +if(BLAH2_KRAKEN_ONLY) + target_compile_definitions(blah2 PRIVATE BLAH2_KRAKEN_ONLY=1) +endif() # unit tests add_executable(testAmbiguity @@ -116,6 +142,24 @@ target_link_libraries(testHammingNumber PRIVATE set_target_properties(testHammingNumber PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_TEST_UNIT_DIR}") +add_executable(testKrakenMultiChannel + test/unit/capture/kraken/TestHeimdallFrame.cpp + test/unit/process/conditioning/TestArrayReferenceSynthesizer.cpp + test/unit/process/fusion/TestNoncoherent.cpp + src/capture/kraken/HeimdallFrame.cpp + src/process/conditioning/ArrayReferenceSynthesizer.cpp + src/process/fusion/Noncoherent.cpp + src/data/IqData.cpp + src/data/Map.cpp +) +target_link_libraries(testKrakenMultiChannel PRIVATE + Catch2::Catch2WithMain +) +set_target_properties(testKrakenMultiChannel PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_TEST_UNIT_DIR}") + # TODO: Unsure if will be using CTest. add_test(NAME testAmbiguity COMMAND testAmbiguity) add_test(NAME testTracker COMMAND testTracker) +add_test(NAME testHammingNumber COMMAND testHammingNumber) +add_test(NAME testKrakenMultiChannel COMMAND testKrakenMultiChannel) diff --git a/Dockerfile b/Dockerfile index f63b182d..de09f9f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,9 @@ RUN apt-get update && apt-get install -y software-properties-common \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y \ g++ make cmake git curl zip unzip doxygen graphviz \ - libfftw3-dev pkg-config gfortran libhackrf-dev \ - libuhd-dev=4.9.0.0-0ubuntu1~jammy3 \ - uhd-host=4.9.0.0-0ubuntu1~jammy3 \ + libfftw3-dev libarmadillo-dev pkg-config gfortran libhackrf-dev \ + libuhd-dev \ + uhd-host \ libusb-dev libusb-1.0.0-dev \ && apt-get autoremove -y \ && apt-get clean -y \ @@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y software-properties-common \ ENV VCPKG_ROOT=/opt/vcpkg RUN export PATH="/opt/vcpkg:${PATH}" \ && git clone https://github.com/microsoft/vcpkg /opt/vcpkg \ + && git -C /opt/vcpkg checkout c8696863d371ab7f46e213d8f5ca923c4aef2a00 \ && if [ "$(uname -m)" = "aarch64" ]; then export VCPKG_FORCE_SYSTEM_BINARIES=1; fi \ && /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics \ && cd /blah2/lib && vcpkg integrate install \ diff --git a/README.md b/README.md index 6fb8e684..f1518566 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ A real-time radar which can support various SDR platforms. See a live instance a ## Features -- 2 channel processing for a reference and surveillance signal. +- 2 channel processing for a dedicated reference and surveillance signal. +- Multi-channel KrakenSDR processing through KrakenSDR Suite V2 Heimdall output. +- Optional coherent array-reference synthesis and noncoherent surveillance-map fusion. - Designed to be used with external RF source (for passive radar or active radar). - Outputs delay-Doppler maps to a web front-end. - Record raw IQ data by pressing spacebar on the web front-end. @@ -18,7 +20,77 @@ A real-time radar which can support various SDR platforms. See a live instance a - [USRP](https://www.ettus.com/products/) (only tested on the B210). - 2x [HackRF](https://greatscottgadgets.com/hackrf/) with clock synchronisation and hardware trigger. - 2x [RTL-SDR](https://www.rtl-sdr.com/) with clock synchronisation. -- [KrakenSDR](https://www.krakenrf.com/) with 2x channels only. +- [KrakenSDR](https://www.krakenrf.com/) with two to eight coherent channels. + +### KrakenSDR Suite V2 + +The Kraken path consumes the standard TCP data stream from +[KrakenSDR Suite V2](https://github.com/krakenrf/krakensdr_suite). It does not +require a patched DAQ. Run `heimdall_v2`, configure frequency and gain in the +Suite, and use `config/config-kraken.yml` as a starting point. BLAH2 accepts +only calibrated antenna frames; calibration/noise and retuning frames are +discarded. + +The Kraken-specific fields are: + +```yaml +capture: + fs: 2400000 + fc: 204640000 + device: + type: "Kraken" + heimdall: + host: "127.0.0.1" + port: 8091 + channel_count: 5 + reference_channel: 0 + surveillance_channels: [0, 1, 2, 3, 4] +process: + performance: + surveillance_workers: 0 + fft_threads: 1 + reference_synthesis: + mode: "array_eigenbeam" + channels: [0, 1, 2, 3, 4] +``` + +Suite V2 currently streams at 2.4 MS/s, which must match `capture.fs`. The +Suite's packet metadata is used to verify the channel count and center +frequency; `channel_count` determines how many channels BLAH2 consumes, while +frequency and gain remain configured in the Suite. With `mode: dedicated`, +`reference_channel` is excluded from +`surveillance_channels`, preserving conventional reference/surveillance +operation. With `mode: array_eigenbeam`, the channels listed under +`reference_synthesis.channels` estimate +the dominant common arrival and may also produce independent surveillance +maps. Their ambiguity-map magnitudes are RMS-fused into the ordinary BLAH2 map, +so the API and web UI remain compatible. + +`surveillance_channels` defaults to every non-reference channel in dedicated +mode and every capture channel in array-reference mode. The optional +`reference_synthesis.channels` list limits which channels contribute to the +synthesized reference; it defaults to every capture channel. + +Two through eight Suite V2 channels are accepted at runtime. Independent +surveillance paths can run concurrently; `surveillance_workers: 0` selects a +CPU-aware automatic limit, while `1` uses serial processing on a constrained +host. `fft_threads` controls the FFTW threads used inside each path. This changes +processing latency only, not map fusion or detector math. + +The processor logs each reference update in a machine-readable line containing +the channel count, coherent fraction, coherent gain, and per-channel complex +weight magnitude/phase. This is intended for installation verification; a high +reported gain alone is not proof of improved target detection. + +The fused detector output keeps BLAH2's existing timestamp, delay, Doppler and +SNR schema. A Kraken installation therefore remains usable as one ordinary +3lips node. Set the receiver and transmitter coordinates under `location` for +the selected illuminator; the array channels are not separate 3lips +nodes. + +The existing `docker/Dockerfile-kraken` builds this same BLAH2 processor with +only the Kraken capture backend dependencies. The default `Dockerfile` remains +the multi-backend build for RSPduo, USRP, HackRF and Kraken installations. ## Services @@ -70,7 +142,6 @@ The radar processing output is available on [http://localhost:49152](http://loca - Add a tracker in delay-Doppler space. - Support for the HackRF/RTL-SDR using a front-end mixer, to sample 2 RF channels in 1 stream. -- Support for the Kraken SDR with all 5 channels. - Add [SoapySDR](https://github.com/pothosware/SoapySDR) support for the [C++ API](https://github.com/pothosware/SoapySDR/wiki/Cpp_API_Example) to include a wide range of SDR platforms. ## FAQ diff --git a/config/config-kraken.yml b/config/config-kraken.yml index 5846c399..10830a81 100644 --- a/config/config-kraken.yml +++ b/config/config-kraken.yml @@ -1,23 +1,35 @@ capture: - fs: 2000000 + # Example values. Keep these in sync with KrakenSDR Suite V2. + # KrakenSDR Suite V2 currently streams at 2.4 MS/s. + fs: 2400000 fc: 204640000 device: type: "Kraken" - gain: [15.0, 15.0] - array: - x: [0, 0] - y: [0, 0] - z: [0, 0] - boresight: 0.0 + # Standard KrakenSDR Suite V2 Heimdall data interface. + heimdall: + host: "127.0.0.1" + port: 8091 + # Must match the number of channels streamed by the Suite. + channel_count: 5 + reference_channel: 0 + # Channels that produce independent surveillance maps. In array-eigenbeam + # mode these may overlap with the reference-synthesis channels below. + surveillance_channels: [0, 1, 2, 3, 4] replay: state: false loop: true file: '/opt/blah2/replay/file.kraken' process: + performance: + # 0 chooses a CPU-aware automatic value. Set to 1 to minimize peak CPU, + # or up to the surveillance-channel count on a workstation. + surveillance_workers: 0 + # Keep this at 1 when surveillance paths run concurrently. + fft_threads: 1 data: cpi: 0.5 - buffer: 1.5 + buffer: 2.0 overlap: 0 ambiguity: delayMin: -10 @@ -28,6 +40,15 @@ process: enable: true delayMin: -10 delayMax: 400 + reference_synthesis: + mode: "array_eigenbeam" + # Optional; all capture channels are used when omitted. + channels: [0, 1, 2, 3, 4] + analysis_samples: 32768 + analysis_interval: 10 + power_iterations: 12 + covariance_smoothing: 0.8 + diagonal_loading: 0.001 detection: enable: true pfa: 0.00001 @@ -80,7 +101,7 @@ location: name: "Mount Lofty" save: - iq: true + iq: false map: false detection: false timing: false diff --git a/docker/Dockerfile-kraken b/docker/Dockerfile-kraken index 6cbf623d..b33b25f2 100644 --- a/docker/Dockerfile-kraken +++ b/docker/Dockerfile-kraken @@ -8,16 +8,20 @@ RUN apt-get update && apt-get install -y software-properties-common \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y \ g++ make cmake git curl zip unzip doxygen graphviz \ - libfftw3-dev pkg-config gfortran \ - libusb-dev libusb-1.0.0-dev \ + libfftw3-dev libarmadillo-dev pkg-config gfortran \ && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* -# install RTL-SDR API -RUN git clone https://github.com/krakenrf/librtlsdr /opt/librtlsdr \ - && cd /opt/librtlsdr && mkdir build && cd build \ - && cmake ../ -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON && make && make install && ldconfig +# Install the portable dependencies used by the existing Kraken image without +# pulling in the unrelated SDRplay, UHD, or HackRF development packages. +ENV VCPKG_ROOT=/opt/vcpkg +RUN git clone https://github.com/microsoft/vcpkg /opt/vcpkg \ + && git -C /opt/vcpkg checkout c8696863d371ab7f46e213d8f5ca923c4aef2a00 \ + && /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics \ + && cd /blah2/lib \ + && cp vcpkg-kraken.json vcpkg.json \ + && /opt/vcpkg/vcpkg install --clean-after-build FROM blah2_env as blah2 LABEL maintainer="30hours " @@ -25,7 +29,10 @@ LABEL maintainer="30hours " ADD src src ADD test test ADD CMakeLists.txt CMakePresets.json Doxyfile /blah2/ -RUN mkdir -p build && cd build && cmake -S . --preset prod-release \ - -DCMAKE_PREFIX_PATH=$(echo /blah2/lib/vcpkg_installed/*/share) .. \ - && cd prod-release && make +RUN cmake -S /blah2 -B /blah2/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBLAH2_KRAKEN_ONLY=ON \ + -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DVCPKG_INSTALLED_DIR=/blah2/lib/vcpkg_installed \ + && cmake --build /blah2/build --parallel RUN chmod +x bin/blah2 diff --git a/lib/vcpkg-kraken.json b/lib/vcpkg-kraken.json new file mode 100644 index 00000000..41b45344 --- /dev/null +++ b/lib/vcpkg-kraken.json @@ -0,0 +1,19 @@ +{ + "name": "blah2-kraken", + "version": "1.0.0", + "dependencies": [ + { "name": "catch2", "version>=": "3.4.0" }, + { "name": "rapidjson", "version>=": "1.1.0" }, + { "name": "asio", "version>=": "1.28.0" }, + { "name": "cpp-httplib", "version>=": "0.12.2" }, + { "name": "ryml", "version>=": "0.5.0" } + ], + "builtin-baseline": "c8696863d371ab7f46e213d8f5ca923c4aef2a00", + "overrides": [ + { "name": "catch2", "version": "3.4.0" }, + { "name": "rapidjson", "version": "1.1.0" }, + { "name": "asio", "version": "1.28.0" }, + { "name": "cpp-httplib", "version": "0.12.2" }, + { "name": "ryml", "version": "0.5.0" } + ] +} diff --git a/lib/vcpkg.json b/lib/vcpkg.json index 3b1b31cd..d32d8edd 100644 --- a/lib/vcpkg.json +++ b/lib/vcpkg.json @@ -6,7 +6,6 @@ { "name": "rapidjson", "version>=": "1.1.0" }, { "name": "asio", "version>=": "1.28.0" }, { "name": "cpp-httplib", "version>=": "0.12.2" }, - { "name": "armadillo", "version>=": "12.0.1" }, { "name": "ryml", "version>=": "0.5.0" } ], "builtin-baseline": "c8696863d371ab7f46e213d8f5ca923c4aef2a00", @@ -15,7 +14,6 @@ { "name": "rapidjson", "version": "1.1.0" }, { "name": "asio", "version": "1.28.0" }, { "name": "cpp-httplib", "version": "0.12.2" }, - { "name": "armadillo", "version": "12.0.1" }, { "name": "ryml", "version": "0.5.0" } ] -} \ No newline at end of file +} diff --git a/src/blah2.cpp b/src/blah2.cpp index 00ba039a..57d528b1 100644 --- a/src/blah2.cpp +++ b/src/blah2.cpp @@ -10,6 +10,8 @@ #include "data/Track.h" #include "process/ambiguity/Ambiguity.h" #include "process/clutter/WienerHopf.h" +#include "process/conditioning/ArrayReferenceSynthesizer.h" +#include "process/fusion/Noncoherent.h" #include "process/detection/CfarDetector1D.h" #include "process/detection/Centroid.h" #include "process/detection/Interpolate.h" @@ -34,6 +36,9 @@ #include #include #include +#include +#include +#include Capture *CAPTURE_POINTER = NULL; std::unique_ptr socket_map; @@ -53,6 +58,29 @@ void timing_helper(std::vector& timing_name, std::vector& timing_time, std::vector& time_us, std::string name); +template +bool process_paths(std::size_t pathCount, std::size_t workerCount, Work work) +{ + bool success = true; + workerCount = std::max(1, + std::min(workerCount, pathCount)); + if (workerCount == 1) + { + for (std::size_t path = 0; path < pathCount; path++) + success = work(path) && success; + return success; + } + for (std::size_t first = 0; first < pathCount; first += workerCount) + { + std::vector> tasks; + const std::size_t last = std::min(first + workerCount, pathCount); + for (std::size_t path = first; path < last; path++) + tasks.push_back(std::async(std::launch::async, work, path)); + for (auto& task : tasks) success = task.get() && success; + } + return success; +} + int main(int argc, char **argv) { // input handling @@ -111,38 +139,135 @@ int main(int argc, char **argv) return 1; } - // set up fftw multithread + // set up FFTW multithreading if (fftw_init_threads() == 0) { std::cout << "Error in FFTW multithreading." << "\n"; return -1; } - fftw_plan_with_nthreads(4); + // Configure channel roles. Other devices keep their usual two-channel path. + uint32_t referenceChannel = 0; + std::vector surveillanceChannels{1}; + std::vector referenceChannels; + std::size_t captureChannelCount = 2; + std::string referenceMode = "dedicated"; + if (type == "Kraken") + { + auto device = tree["capture"]["device"]; + if (!device.has_child("channel_count")) + throw std::invalid_argument("Kraken channel_count is required"); + device["channel_count"] >> captureChannelCount; + if (device.has_child("reference_channel")) + device["reference_channel"] >> referenceChannel; + if (tree["process"].has_child("reference_synthesis")) + { + auto reference = tree["process"]["reference_synthesis"]; + if (reference.has_child("mode")) reference["mode"] >> referenceMode; + if (reference.has_child("channels")) + for (auto child : reference["channels"].children()) + { + uint32_t channel; + child >> channel; + referenceChannels.push_back(channel); + } + } + surveillanceChannels.clear(); + if (device.has_child("surveillance_channels")) + for (auto child : device["surveillance_channels"].children()) + { + uint32_t channel; + child >> channel; + surveillanceChannels.push_back(channel); + } + } + if (referenceMode != "dedicated" && referenceMode != "array_eigenbeam") + throw std::invalid_argument("Unknown reference-synthesis mode"); + const bool arrayReference = referenceMode == "array_eigenbeam"; + if (captureChannelCount < 2 || captureChannelCount > 8 || + referenceChannel >= captureChannelCount) + throw std::invalid_argument("Invalid capture channel configuration"); + if (referenceChannels.empty() && arrayReference) + for (std::size_t channel = 0; channel < captureChannelCount; channel++) + referenceChannels.push_back(static_cast(channel)); + if (surveillanceChannels.empty()) + for (std::size_t channel = 0; channel < captureChannelCount; channel++) + if (arrayReference || channel != referenceChannel) + surveillanceChannels.push_back(static_cast(channel)); + std::sort(surveillanceChannels.begin(), surveillanceChannels.end()); + if (std::adjacent_find(surveillanceChannels.begin(), + surveillanceChannels.end()) != surveillanceChannels.end()) + throw std::invalid_argument("Surveillance channels must be unique"); + for (uint32_t channel : surveillanceChannels) + if (channel >= captureChannelCount || + (!arrayReference && channel == referenceChannel)) + throw std::invalid_argument("Invalid surveillance channel"); + std::sort(referenceChannels.begin(), referenceChannels.end()); + if (arrayReference && (referenceChannels.size() < 2 || + std::adjacent_find(referenceChannels.begin(), referenceChannels.end()) != + referenceChannels.end())) + throw std::invalid_argument("Array-reference channels must be unique"); + for (uint32_t channel : referenceChannels) + if (channel >= captureChannelCount) + throw std::invalid_argument("Invalid array-reference channel"); + + // Each surveillance path owns its processing state and working buffers. + // Zero workers chooses a CPU-aware automatic value. + uint32_t configuredWorkers = 1; + uint32_t fftThreads = type == "Kraken" ? 1 : 4; + if (tree["process"].has_child("performance") && + tree["process"]["performance"].has_child("surveillance_workers")) + tree["process"]["performance"]["surveillance_workers"] >> + configuredWorkers; + if (tree["process"].has_child("performance") && + tree["process"]["performance"].has_child("fft_threads")) + tree["process"]["performance"]["fft_threads"] >> fftThreads; + if (fftThreads == 0) + throw std::invalid_argument("FFT thread count must be positive"); + const std::size_t hardwareThreads = std::max(1U, + std::thread::hardware_concurrency()); + const std::size_t surveillanceWorkers = std::min( + configuredWorkers == 0 ? hardwareThreads : configuredWorkers, + surveillanceChannels.size()); + fftw_plan_with_nthreads(fftThreads); + std::cout << "Surveillance paths=" << surveillanceChannels.size() + << " workers=" << surveillanceWorkers << " fft_threads=" << fftThreads + << "\n"; Capture *capture = new Capture(type, fs, fc, path); CAPTURE_POINTER = capture; - if (state) - { - capture->set_replay(loop, replayFile); - } + if (state) capture->set_replay(loop, replayFile); - // create shared queue + // Keep one bounded capture queue per coherent input. double tCpi, tBuffer; tree["process"]["data"]["cpi"] >> tCpi; tree["process"]["data"]["buffer"] >> tBuffer; - IqData *buffer1 = new IqData((int) (tCpi*tBuffer*fs)); - IqData *buffer2 = new IqData((int) (tCpi*tBuffer*fs)); + const uint32_t captureBufferSamples = static_cast(tCpi*tBuffer*fs); + std::vector> captureStorage; + std::vector captureBuffers; + for (std::size_t channel = 0; channel < captureChannelCount; channel++) + { + captureStorage.push_back(std::make_unique(captureBufferSamples)); + captureBuffers.push_back(captureStorage.back().get()); + } // run capture - std::thread t1([&]{capture->process(buffer1, buffer2, + std::thread t1([&]{capture->process(captureBuffers, tree["capture"]["device"], ip_capture, port_capture); }); // set up process CPI uint32_t nSamples = fs * tCpi; - IqData *x = new IqData(nSamples); - IqData *y = new IqData(nSamples); - Map> *map; + auto referenceData = std::make_unique(nSamples); + std::vector> captureData; + for (std::size_t channel = 0; channel < captureChannelCount; channel++) + captureData.push_back(std::make_unique(nSamples)); + std::vector> surveillanceData; + for (std::size_t pathIndex = 0; + pathIndex < surveillanceChannels.size(); pathIndex++) + { + surveillanceData.push_back(std::make_unique(nSamples)); + } + std::unique_ptr>> map; std::unique_ptr detection; std::unique_ptr detection1; std::unique_ptr detection2; @@ -156,14 +281,40 @@ int main(int argc, char **argv) tree["process"]["ambiguity"]["delayMax"] >> delayMax; tree["process"]["ambiguity"]["dopplerMin"] >> dopplerMin; tree["process"]["ambiguity"]["dopplerMax"] >> dopplerMax; - Ambiguity *ambiguity = new Ambiguity(delayMin, delayMax, - dopplerMin, dopplerMax, fs, nSamples, roundHamming); + std::vector> ambiguity; + for (std::size_t pathIndex = 0; + pathIndex < surveillanceChannels.size(); pathIndex++) + ambiguity.push_back(std::make_unique(delayMin, delayMax, + dopplerMin, dopplerMax, fs, nSamples, roundHamming)); // set up process clutter int32_t delayMinClutter, delayMaxClutter; tree["process"]["clutter"]["delayMin"] >> delayMinClutter; tree["process"]["clutter"]["delayMax"] >> delayMaxClutter; - WienerHopf *filter = new WienerHopf(delayMinClutter, delayMaxClutter, nSamples); + std::vector> filter; + for (std::size_t pathIndex = 0; + pathIndex < surveillanceChannels.size(); pathIndex++) + filter.push_back(std::make_unique(delayMinClutter, + delayMaxClutter, nSamples)); + + ArrayReferenceSynthesizer::Config referenceConfig; + if (tree["process"].has_child("reference_synthesis")) + { + auto node = tree["process"]["reference_synthesis"]; + if (node.has_child("analysis_samples")) + node["analysis_samples"] >> referenceConfig.analysisSamples; + if (node.has_child("analysis_interval")) + node["analysis_interval"] >> referenceConfig.analysisInterval; + if (node.has_child("power_iterations")) + node["power_iterations"] >> referenceConfig.powerIterations; + if (node.has_child("covariance_smoothing")) + node["covariance_smoothing"] >> referenceConfig.covarianceSmoothing; + if (node.has_child("diagonal_loading")) + node["diagonal_loading"] >> referenceConfig.diagonalLoading; + } + ArrayReferenceSynthesizer referenceSynthesizer(referenceConfig); + uint64_t reportedReferenceUpdate = 0; + Noncoherent mapFusion; // set up process detection double pfa, minDoppler; @@ -192,11 +343,13 @@ int main(int argc, char **argv) tree["process"]["tracker"]["initiate"]["maxAcc"] >> maxAcc; rangeRes = (double)Constants::c/fs; lambda = (double)Constants::c/fc; - Tracker *tracker = new Tracker(m, n, nDelete, ambiguity->get_cpi(), maxAcc, rangeRes, lambda); + Tracker *tracker = new Tracker(m, n, nDelete, + ambiguity.front()->get_cpi(), maxAcc, rangeRes, lambda); // set up process spectrum analyser double spectrumBandwidth = 2000; - SpectrumAnalyser *spectrumAnalyser = new SpectrumAnalyser(nSamples, spectrumBandwidth); + SpectrumAnalyser *spectrumAnalyser = new SpectrumAnalyser(nSamples, + spectrumBandwidth, fc); // process options bool isClutter, isDetection, isTracker; @@ -245,46 +398,101 @@ int main(int argc, char **argv) std::thread t2([&]{ while (true) { - buffer1->lock(); - buffer2->lock(); - if ((buffer1->get_length() > nSamples) && (buffer2->get_length() > nSamples)) + for (auto *buffer : captureBuffers) buffer->lock(); + bool ready = true; + for (auto *buffer : captureBuffers) + ready = ready && buffer->get_length() >= nSamples; + if (ready) { time.push_back(current_time_us()); - // extract data from buffer - for (uint32_t i = 0; i < nSamples; i++) + for (std::size_t channel = 0; channel < captureChannelCount; channel++) + captureData[channel]->replace( + captureBuffers[channel]->drain_front(nSamples)); + for (auto it = captureBuffers.rbegin(); + it != captureBuffers.rend(); ++it) (*it)->unlock(); + if (!arrayReference) + referenceData->replace(captureData[referenceChannel]->get_data()); + for (std::size_t pathIndex = 0; + pathIndex < surveillanceChannels.size(); pathIndex++) + surveillanceData[pathIndex]->replace( + captureData[surveillanceChannels[pathIndex]]->get_data()); + timing_helper(timing_name, timing_time, time, "extract_buffer"); + + std::vector surveillancePointers; + for (auto& channel : surveillanceData) + surveillancePointers.push_back(channel.get()); + if (arrayReference) { - x->push_back(buffer1->pop_front()); - y->push_back(buffer2->pop_front()); + std::vector referencePointers; + for (uint32_t channel : referenceChannels) + referencePointers.push_back(captureData[channel].get()); + referenceData = referenceSynthesizer.process(referencePointers); + const auto& metrics = referenceSynthesizer.get_metrics(); + if (metrics.updates != reportedReferenceUpdate) + { + std::cout << "[ArrayReference] channels=" + << referencePointers.size() << " update=" + << metrics.updates << " coherent_fraction=" + << metrics.coherentFraction << " coherent_gain_db=" + << metrics.coherentGainDb << " weights="; + for (std::size_t channel = 0; + channel < metrics.weights.size(); channel++) + { + if (channel) std::cout << ","; + std::cout << referenceChannels[channel] << ":" + << std::abs(metrics.weights[channel]) + << "@" << std::arg(metrics.weights[channel]); + } + std::cout << "\n"; + reportedReferenceUpdate = metrics.updates; + } } - buffer1->unlock(); - buffer2->unlock(); - timing_helper(timing_name, timing_time, time, "extract_buffer"); + timing_helper(timing_name, timing_time, time, + "reference_synthesis"); // spectrum - spectrumAnalyser->process(x); + spectrumAnalyser->process(referenceData.get()); timing_helper(timing_name, timing_time, time, "spectrum"); - // clutter filter + // Filter each surveillance channel independently. if (isClutter) { - if (!filter->process(x, y)) + const bool success = process_paths(surveillanceData.size(), + surveillanceWorkers, [&](std::size_t pathIndex) { + return filter[pathIndex]->process(referenceData.get(), + surveillanceData[pathIndex].get()); + }); + if (!success) { + time.clear(); + timing_name.clear(); + timing_time.clear(); continue; } timing_helper(timing_name, timing_time, time, "clutter_filter"); } - // ambiguity process - map = ambiguity->process(x, y); + // Produce one map per surveillance channel, then combine their power. + std::vector> *> channelMaps( + surveillanceData.size()); + process_paths(surveillanceData.size(), surveillanceWorkers, + [&](std::size_t pathIndex) { + channelMaps[pathIndex] = ambiguity[pathIndex]->process( + referenceData->view_data(), + surveillanceData[pathIndex].get()); + channelMaps[pathIndex]->set_metrics(); + return true; + }); + map = mapFusion.process(channelMaps); map->set_metrics(); timing_helper(timing_name, timing_time, time, "ambiguity_processing"); // detection process if (isDetection) { - detection1 = cfarDetector1D->process(map); + detection1 = cfarDetector1D->process(map.get()); detection2 = centroid->process(detection1.get()); - detection = interpolate->process(detection2.get(), map); + detection = interpolate->process(detection2.get(), map.get()); timing_helper(timing_name, timing_time, time, "detector"); } @@ -296,7 +504,7 @@ int main(int argc, char **argv) } // output IqData meta data - jsonIqData = x->to_json(time[0]/1000); + jsonIqData = referenceData->to_json(time[0]/1000); socket_iqdata->sendData(jsonIqData); // output map data @@ -352,8 +560,8 @@ int main(int argc, char **argv) } else { - buffer1->unlock(); - buffer2->unlock(); + for (auto it = captureBuffers.rbegin(); + it != captureBuffers.rend(); ++it) (*it)->unlock(); // short delay to prevent tight looping std::this_thread::sleep_for(std::chrono::milliseconds(1)); } diff --git a/src/capture/Capture.cpp b/src/capture/Capture.cpp index 2a83e912..ec21597c 100644 --- a/src/capture/Capture.cpp +++ b/src/capture/Capture.cpp @@ -1,7 +1,9 @@ #include "Capture.h" +#ifndef BLAH2_KRAKEN_ONLY #include "rspduo/RspDuo.h" #include "usrp/Usrp.h" #include "hackrf/HackRf.h" +#endif #include "kraken/Kraken.h" #include #include @@ -23,10 +25,17 @@ Capture::Capture(std::string _type, uint32_t _fs, uint32_t _fc, std::string _pat void Capture::process(IqData *buffer1, IqData *buffer2, c4::yml::NodeRef config, std::string ip_capture, uint16_t port_capture) +{ + process(std::vector{buffer1, buffer2}, config, ip_capture, + port_capture); +} + +void Capture::process(const std::vector& buffers, + c4::yml::NodeRef config, std::string ip_capture, uint16_t port_capture) { std::cout << "Setting up device " + type << std::endl; - device = factory_source(type, config); + device = factory_source(type, config, buffers.size()); // capture status thread std::thread t1([&]{ @@ -56,18 +65,33 @@ void Capture::process(IqData *buffer1, IqData *buffer2, c4::yml::NodeRef config, if (!replay) { device->start(); - device->process(buffer1, buffer2); + device->process(buffers); } else { - device->replay(buffer1, buffer2, file, loop); + device->replay(buffers, file, loop); } t1.join(); } -std::unique_ptr Capture::factory_source(const std::string& type, c4::yml::NodeRef config) +std::unique_ptr Capture::factory_source(const std::string& type, + c4::yml::NodeRef config, std::size_t channelCount) { + if (type == VALID_TYPE[3]) + { + std::string heimdallHost = "127.0.0.1"; + uint16_t heimdallPort = 8091; + if (config.has_child("heimdall")) + { + config["heimdall"]["host"] >> heimdallHost; + config["heimdall"]["port"] >> heimdallPort; + } + return std::make_unique(type, fc, fs, path, &saveIq, + channelCount, heimdallHost, heimdallPort); + } + // SDRplay RSPduo +#ifndef BLAH2_KRAKEN_ONLY if (type == VALID_TYPE[0]) { int agcSetPoint, bandwidthNumber, gainReductionA, gainReductionB, lnaState; @@ -137,18 +161,7 @@ std::unique_ptr Capture::factory_source(const std::string& type, c4::yml return std::make_unique(type, fc, fs, path, &saveIq, serial, gainLna, gainVga, ampEnable); } - // Kraken - else if (type == VALID_TYPE[3]) - { - std::vector gain; - float _gain; - for (auto child : config["gain"].children()) - { - c4::atof(child.val(), &_gain); - gain.push_back(static_cast(_gain)); - } - return std::make_unique(type, fc, fs, path, &saveIq, gain); - } +#endif // handle unknown type std::cerr << "Error: Source type does not exist." << std::endl; return nullptr; diff --git a/src/capture/Capture.h b/src/capture/Capture.h index 9994d4b1..1f6dcb63 100644 --- a/src/capture/Capture.h +++ b/src/capture/Capture.h @@ -66,11 +66,15 @@ class Capture /// @param ip_capture IP address of capture API. /// @param port_capture Port of capture API. /// @return Void. - void process(IqData *buffer1, IqData *buffer2, c4::yml::NodeRef config, + void process(IqData *buffer1, IqData *buffer2, c4::yml::NodeRef config, std::string ip_capture, uint16_t port_capture); - std::unique_ptr factory_source(const std::string& type, - c4::yml::NodeRef config); + void process(const std::vector& buffers, + c4::yml::NodeRef config, std::string ip_capture, uint16_t port_capture); + + /// @brief Construct a capture source for the configured input channels. + std::unique_ptr factory_source(const std::string& type, + c4::yml::NodeRef config, std::size_t channelCount = 2); /// @brief Set parameters to enable file replay. /// @param loop True if replay file should loop when complete. @@ -80,4 +84,4 @@ class Capture }; -#endif \ No newline at end of file +#endif diff --git a/src/capture/Source.cpp b/src/capture/Source.cpp index 2f7e96ac..81c2b086 100644 --- a/src/capture/Source.cpp +++ b/src/capture/Source.cpp @@ -22,6 +22,25 @@ Source::Source(std::string _type, uint32_t _fc, uint32_t _fs, saveIq = _saveIq; } +void Source::process(const std::vector& buffers) +{ + if (buffers.size() != 2) + { + throw std::invalid_argument("Two-channel source requires two buffers"); + } + process(buffers[0], buffers[1]); +} + +void Source::replay(const std::vector& buffers, + std::string file, bool loop) +{ + if (buffers.size() != 2) + { + throw std::invalid_argument("Two-channel replay requires two buffers"); + } + replay(buffers[0], buffers[1], file, loop); +} + std::string Source::open_file() { // get string of timestamp in YYYYmmdd-HHMMSS @@ -67,7 +86,7 @@ void Source::kill() if (type == "RspDuo") { stop(); - } else if (type == "HackRF") + } else if (type == "HackRF" || type == "Kraken") { stop(); } diff --git a/src/capture/Source.h b/src/capture/Source.h index 9f969df2..d8a9cca1 100644 --- a/src/capture/Source.h +++ b/src/capture/Source.h @@ -10,6 +10,7 @@ #include #include #include +#include #include "data/IqData.h" class Source @@ -53,6 +54,9 @@ class Source /// @return Void. virtual void process(IqData *buffer1, IqData *buffer2) = 0; + /// @brief Multi-channel capture; two-channel sources use this adapter. + virtual void process(const std::vector& buffers); + /// @brief Call methods to start capture. /// @return Void. virtual void start() = 0; @@ -67,9 +71,13 @@ class Source /// @param file Path to file to replay data from. /// @param loop True if samples should loop at EOF. /// @return Void. - virtual void replay(IqData *buffer1, IqData *buffer2, + virtual void replay(IqData *buffer1, IqData *buffer2, std::string file, bool loop) = 0; + /// @brief Multi-channel replay; two-channel sources use this adapter. + virtual void replay(const std::vector& buffers, + std::string file, bool loop); + /// @brief Open a new file to record IQ. /// @details First creates a new file from current timestamp. /// Files are of format ..iq. @@ -86,4 +94,4 @@ class Source }; -#endif \ No newline at end of file +#endif diff --git a/src/capture/kraken/HeimdallFrame.cpp b/src/capture/kraken/HeimdallFrame.cpp new file mode 100644 index 00000000..4fa9927a --- /dev/null +++ b/src/capture/kraken/HeimdallFrame.cpp @@ -0,0 +1,122 @@ +#include "HeimdallFrame.h" + +#include +#include +#include + +namespace +{ +uint32_t read_be_u32(const uint8_t *bytes) +{ + return (static_cast(bytes[0]) << 24) | + (static_cast(bytes[1]) << 16) | + (static_cast(bytes[2]) << 8) | + static_cast(bytes[3]); +} + +uint32_t read_le_u32(const uint8_t *bytes) +{ + return static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +float read_le_float(const uint8_t *bytes) +{ + const uint32_t bits = read_le_u32(bytes); + float value; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} +} + +HeimdallFrame::Header HeimdallFrame::decode_header( + const std::array& bytes) +{ + if (read_be_u32(bytes.data()) != MAGIC) + throw std::runtime_error("Invalid Heimdall V2 frame magic"); + + Header header; + header.numChannels = read_be_u32(bytes.data() + 4); + header.samplesPerChannel = read_be_u32(bytes.data() + 8); + header.phaseState = read_be_u32(bytes.data() + 12); + header.noiseSource = read_be_u32(bytes.data() + 16); + header.frequencyChangeCounter = read_be_u32(bytes.data() + 20); + header.currentGroupIndex = read_be_u32(bytes.data() + 24); + header.retuningInProgress = read_be_u32(bytes.data() + 28); + + if (header.numChannels == 0 || header.numChannels > 8) + throw std::runtime_error("Invalid Heimdall V2 channel count"); + if (header.samplesPerChannel == 0 || header.samplesPerChannel > 65536) + throw std::runtime_error("Invalid Heimdall V2 sample count"); + return header; +} + +std::size_t HeimdallFrame::metadata_size(const Header& header) +{ + return static_cast(header.numChannels) * 2 * sizeof(float); +} + +void HeimdallFrame::decode_metadata( + Header& header, const std::vector& bytes) +{ + if (bytes.size() != metadata_size(header)) + throw std::runtime_error("Incomplete Heimdall V2 channel metadata"); + header.frequencies.resize(header.numChannels); + header.gains.resize(header.numChannels); + for (uint32_t channel = 0; channel < header.numChannels; channel++) + { + const std::size_t offset = static_cast(channel) * 8; + header.frequencies[channel] = read_le_float(bytes.data() + offset); + header.gains[channel] = read_le_float(bytes.data() + offset + 4); + } +} + +std::size_t HeimdallFrame::payload_size(const Header& header) +{ + constexpr std::size_t bytesPerComplex = 2; + if (header.samplesPerChannel > std::numeric_limits::max() / + header.numChannels / bytesPerComplex) + throw std::overflow_error("Heimdall V2 payload size overflow"); + return static_cast(header.samplesPerChannel) * + header.numChannels * bytesPerComplex; +} + +std::vector>> HeimdallFrame::decode_payload( + const Header& header, const std::vector& bytes) +{ + if (bytes.size() != payload_size(header)) + throw std::runtime_error("Incomplete Heimdall V2 payload"); + + std::vector>> channels( + header.numChannels, + std::vector>(header.samplesPerChannel)); + for (uint32_t channel = 0; channel < header.numChannels; channel++) + { + const std::size_t start = static_cast(channel) * + header.samplesPerChannel * 2; + double meanI = 0; + double meanQ = 0; + for (uint32_t sample = 0; sample < header.samplesPerChannel; sample++) + { + meanI += bytes[start + sample * 2]; + meanQ += bytes[start + sample * 2 + 1]; + } + meanI /= header.samplesPerChannel; + meanQ /= header.samplesPerChannel; + for (uint32_t sample = 0; sample < header.samplesPerChannel; sample++) + { + channels[channel][sample] = { + static_cast((bytes[start + sample * 2] - meanI) / 127.5), + static_cast((bytes[start + sample * 2 + 1] - meanQ) / 127.5)}; + } + } + return channels; +} + +bool HeimdallFrame::is_synchronized_data(const Header& header) +{ + return (header.phaseState & 0xffU) == CALIBRATED && + header.noiseSource == 0 && header.retuningInProgress == 0; +} diff --git a/src/capture/kraken/HeimdallFrame.h b/src/capture/kraken/HeimdallFrame.h new file mode 100644 index 00000000..723cef2b --- /dev/null +++ b/src/capture/kraken/HeimdallFrame.h @@ -0,0 +1,41 @@ +#ifndef HEIMDALL_FRAME_H +#define HEIMDALL_FRAME_H + +#include +#include +#include +#include +#include + +/// Decoder for the KrakenSDR Suite V2 Heimdall data stream. +class HeimdallFrame +{ +public: + static constexpr std::size_t FIXED_HEADER_SIZE = 32; + static constexpr uint32_t MAGIC = 0x4d434851; // "MCHQ" + static constexpr uint32_t CALIBRATED = 4; + + struct Header + { + uint32_t numChannels{}; + uint32_t samplesPerChannel{}; + uint32_t phaseState{}; + uint32_t noiseSource{}; + uint32_t frequencyChangeCounter{}; + uint32_t currentGroupIndex{}; + uint32_t retuningInProgress{}; + std::vector frequencies; + std::vector gains; + }; + + static Header decode_header( + const std::array& bytes); + static std::size_t metadata_size(const Header& header); + static void decode_metadata(Header& header, const std::vector& bytes); + static std::size_t payload_size(const Header& header); + static std::vector>> decode_payload( + const Header& header, const std::vector& bytes); + static bool is_synchronized_data(const Header& header); +}; + +#endif diff --git a/src/capture/kraken/Kraken.cpp b/src/capture/kraken/Kraken.cpp index ab8a4947..a5300fe3 100644 --- a/src/capture/kraken/Kraken.cpp +++ b/src/capture/kraken/Kraken.cpp @@ -1,125 +1,226 @@ #include "Kraken.h" +#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include #include -#include +#include -// constructor -Kraken::Kraken(std::string _type, uint32_t _fc, uint32_t _fs, - std::string _path, bool *_saveIq, std::vector _gain) - : Source(_type, _fc, _fs, _path, _saveIq) +namespace { - // convert gain to tenths of dB - for (size_t i = 0; i < _gain.size(); i++) - { - gain.push_back(static_cast(_gain[i]*10)); - channelIndex.push_back(i); - } - std::vector devs(channelIndex.size()); - - // store all valid gains - std::vector validGains; - int nGains, status; - status = rtlsdr_open(&devs[0], 0); - check_status(status, "Failed to open device for available gains."); - nGains = rtlsdr_get_tuner_gains(devs[0], nullptr); - check_status(nGains, "Failed to get number of gains."); - std::unique_ptr _validGains(new int[nGains]); - status = rtlsdr_get_tuner_gains(devs[0], _validGains.get()); - check_status(status, "Failed to get number of gains."); - validGains.assign(_validGains.get(), _validGains.get() + nGains); - status = rtlsdr_close(devs[0]); - check_status(status, "Failed to close device for available gains."); - - // update gains to next value if invalid - for (size_t i = 0; i < _gain.size(); i++) - { - int adjustedGain = static_cast(_gain[i] * 10); - auto it = std::lower_bound(validGains.begin(), - validGains.end(), adjustedGain); - if (it != validGains.end()) { - gain.push_back(*it); - } else { - gain.push_back(validGains.back()); - } - std::cout << "[Kraken] Gain update on channel " << i << " from " << - adjustedGain << " to " << gain[i] << "." << std::endl; - } +class ConnectionError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; +} + +Kraken::Kraken(std::string type, uint32_t fc, uint32_t fs, + std::string path, bool *saveIq, std::size_t channelCount, + std::string heimdallHost, uint16_t heimdallPort) + : Source(type, fc, fs, path, saveIq), channelCount(channelCount), + heimdallHost(heimdallHost), heimdallPort(heimdallPort) +{ + if (channelCount == 0 || channelCount > MAX_CHANNELS) + throw std::invalid_argument("Kraken requires between one and eight channels"); + if (fs != SUITE_SAMPLE_RATE) + throw std::invalid_argument( + "KrakenSDR Suite V2 requires capture.fs to be 2400000"); } void Kraken::start() { - int status; - for (size_t i = 0; i < channelIndex.size(); i++) - { - std::cout << "[Kraken] Setting up channel " << i << "." << std::endl; - - status = rtlsdr_open(&devs[i], i); - check_status(status, "Failed to open device."); - - status = rtlsdr_set_center_freq(devs[i], fc); - check_status(status, "Failed to set center frequency."); - status = rtlsdr_set_sample_rate(devs[i], fs); - check_status(status, "Failed to set sample rate."); - status = rtlsdr_set_dithering(devs[i], 0); // disable dither - check_status(status, "Failed to disable dithering."); - status = rtlsdr_set_tuner_gain_mode(devs[i], 1); // disable AGC - check_status(status, "Failed to disable AGC."); - status = rtlsdr_set_tuner_gain(devs[i], gain[i]); - check_status(status, "Failed to set gain."); - status = rtlsdr_reset_buffer(devs[i]); - check_status(status, "Failed to reset buffer."); - } + running = true; } void Kraken::stop() { - int status; - for (size_t i = 0; i < channelIndex.size(); i++) - { - status = rtlsdr_cancel_async(devs[i]); - check_status(status, "Failed to stop async read."); - } + running = false; + if (socketFd >= 0) + { + shutdown(socketFd, SHUT_RDWR); + close(socketFd); + socketFd = -1; + } } void Kraken::process(IqData *buffer1, IqData *buffer2) { - std::vector threads; - threads.emplace_back(rtlsdr_read_async, devs[0], callback, buffer1, 0, 16 * 16384); - threads.emplace_back(rtlsdr_read_async, devs[1], callback, buffer2, 0, 16 * 16384); - // join threads - for (auto& thread : threads) { - thread.join(); - } + process(std::vector{buffer1, buffer2}); } -void Kraken::callback(unsigned char *buf, uint32_t len, void *ctx) +void Kraken::process(const std::vector& buffers) { - IqData* buffer_blah2 = (IqData*)ctx; - int8_t* buffer_kraken = (int8_t*)buf; + if (buffers.empty() || buffers.size() > MAX_CHANNELS || + buffers.size() != channelCount) + throw std::invalid_argument("Kraken buffers must match configured channels"); + process_heimdall(buffers); +} - buffer_blah2->lock(); +void Kraken::process_heimdall(const std::vector& buffers) +{ + while (running) + { + try + { + stream_heimdall(buffers); + } + catch (const ConnectionError& error) + { + if (socketFd >= 0) + { + close(socketFd); + socketFd = -1; + } + if (!running) return; + clear_buffers(buffers); + std::cerr << "[Kraken] " << error.what() + << "; retrying in 1 second" << std::endl; + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + } +} + +void Kraken::stream_heimdall(const std::vector& buffers) +{ + connect_heimdall(); + std::optional frequencyChangeCounter; + bool resetPending = false; + while (running) + { + const auto headerBytes = receive_header(); + auto header = HeimdallFrame::decode_header(headerBytes); + std::vector metadata(HeimdallFrame::metadata_size(header)); + receive_exact(metadata.data(), metadata.size()); + HeimdallFrame::decode_metadata(header, metadata); + std::vector payload(HeimdallFrame::payload_size(header)); + receive_exact(payload.data(), payload.size()); + + if (header.numChannels != buffers.size()) + throw std::runtime_error( + "Heimdall V2 channel count does not match BLAH2 configuration"); + for (float frequency : header.frequencies) + if (std::abs(static_cast(frequency) - fc) > 256.0) + throw std::runtime_error( + "Heimdall V2 frequency does not match BLAH2 configuration"); + + if (frequencyChangeCounter && + *frequencyChangeCounter != header.frequencyChangeCounter) + resetPending = true; + frequencyChangeCounter = header.frequencyChangeCounter; + if (!HeimdallFrame::is_synchronized_data(header)) + { + resetPending = true; + continue; + } + if (resetPending) + { + clear_buffers(buffers); + resetPending = false; + } - for (size_t i = 0; i < len; i += 2) { - double iqi = static_cast(buffer_kraken[i]); - double iqq = static_cast(buffer_kraken[i + 1]); + const auto channels = HeimdallFrame::decode_payload(header, payload); + for (auto *buffer : buffers) buffer->lock(); + for (std::size_t channel = 0; channel < channels.size(); channel++) + buffers[channel]->append_unlocked(channels[channel]); + for (auto it = buffers.rbegin(); it != buffers.rend(); ++it) + (*it)->unlock(); - buffer_blah2->push_back({iqi, iqq}); + if (*saveIq && saveIqFile.is_open()) + { + saveIqFile.write(reinterpret_cast(headerBytes.data()), + static_cast(headerBytes.size())); + saveIqFile.write(reinterpret_cast(metadata.data()), + static_cast(metadata.size())); + saveIqFile.write(reinterpret_cast(payload.data()), + static_cast(payload.size())); } + } +} - buffer_blah2->unlock(); +void Kraken::connect_heimdall() +{ + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo *addresses = nullptr; + const std::string port = std::to_string(heimdallPort); + if (getaddrinfo(heimdallHost.c_str(), port.c_str(), &hints, &addresses) != 0) + throw ConnectionError("Unable to resolve Heimdall V2 host"); + std::unique_ptr guard(addresses, freeaddrinfo); + for (auto *address = addresses; address; address = address->ai_next) + { + socketFd = socket(address->ai_family, address->ai_socktype, + address->ai_protocol); + if (socketFd >= 0 && + connect(socketFd, address->ai_addr, address->ai_addrlen) == 0) + { + std::cout << "[Kraken] Connected to KrakenSDR Suite V2 Heimdall at " + << heimdallHost << ":" << heimdallPort << std::endl; + return; + } + if (socketFd >= 0) close(socketFd); + socketFd = -1; + } + throw ConnectionError("Unable to connect to Heimdall V2"); } -void Kraken::replay(IqData *buffer1, IqData *buffer2, std::string _file, bool _loop) +void Kraken::receive_exact(void *data, std::size_t length) { - // todo + auto *bytes = static_cast(data); + std::size_t received = 0; + while (received < length) + { + const ssize_t count = recv(socketFd, bytes + received, length - received, 0); + if (count == 0) throw ConnectionError("Heimdall V2 closed connection"); + if (count < 0) + { + if (errno == EINTR) continue; + throw ConnectionError(std::strerror(errno)); + } + received += static_cast(count); + } } -void Kraken::check_status(int status, std::string message) +std::array Kraken::receive_header() { - if (status < 0) + constexpr std::array magic{'M', 'C', 'H', 'Q'}; + std::array header{}; + std::size_t matched = 0; + while (matched < magic.size()) { - throw std::runtime_error("[Kraken] " + message); + uint8_t byte; + receive_exact(&byte, 1); + if (byte == magic[matched]) + header[matched++] = byte; + else + matched = byte == magic[0] ? 1 : 0; } + receive_exact(header.data() + magic.size(), header.size() - magic.size()); + return header; +} + +void Kraken::clear_buffers(const std::vector& buffers) +{ + for (auto *buffer : buffers) buffer->lock(); + for (auto *buffer : buffers) buffer->clear(); + for (auto it = buffers.rbegin(); it != buffers.rend(); ++it) + (*it)->unlock(); +} + +void Kraken::replay(IqData *, IqData *, std::string, bool) +{ + throw std::runtime_error("Kraken replay is not implemented"); +} + +void Kraken::replay(const std::vector&, std::string, bool) +{ + throw std::runtime_error("Kraken replay is not implemented"); } diff --git a/src/capture/kraken/Kraken.h b/src/capture/kraken/Kraken.h index d56570f4..e346fc14 100644 --- a/src/capture/kraken/Kraken.h +++ b/src/capture/kraken/Kraken.h @@ -1,85 +1,49 @@ /// @file Kraken.h -/// @class Kraken -/// @brief A class to capture data on the Kraken SDR. -/// @details Uses a custom librtlsdr API to extract samples. -/// Uses 2 channels of the Kraken to capture IQ data. -/// The noise source phase synchronisation is not required for 2 channel operation. -/// Future work is to replicate the Heimdall DAQ phase syncronisation. -/// This will enable a surveillance array of up to 4 antenna elements. -/// Requires a custom librtlsdr which includes method rtlsdr_set_dithering(). -/// The original steve-m/librtlsdr does not include this method. -/// This is included in librtlsdr/librtlsdr or krakenrf/librtlsdr. -/// Also works using 2 RTL-SDRs which have been clock synchronised. -/// @author 30hours, Michael Brock, sdn-ninja -/// @todo Add support for multiple surveillance channels. -/// @todo Replay support. +/// @brief Coherent KrakenSDR capture through KrakenSDR Suite V2 Heimdall. #ifndef KRAKEN_H #define KRAKEN_H #include "capture/Source.h" #include "data/IqData.h" +#include "HeimdallFrame.h" -#include +#include +#include +#include #include #include -#include class Kraken : public Source { private: - - /// @brief Individual RTL-SDR devices. - rtlsdr_dev_t* devs[5]; - - /// @brief Device indices for Kraken. - std::vector channelIndex; - - /// @brief Gain for each channel. - std::vector gain; - - /// @brief Check status of API returns. - /// @param status Return code of API call. - /// @param message Message if API call error. - /// @return Void. - void check_status(int status, std::string message); - - /// @brief Callback function when buffer is filled. - /// @param buf Pointer to buffer of IQ data. - /// @param len Length of buffer. - /// @param ctx Context data for callback. - /// @return Void. - static void callback(unsigned char *buf, uint32_t len, void *ctx); + static constexpr std::size_t MAX_CHANNELS = 8; + static constexpr uint32_t SUITE_SAMPLE_RATE = 2400000; + std::size_t channelCount; + std::string heimdallHost; + uint16_t heimdallPort; + std::atomic running{false}; + int socketFd{-1}; + + void process_heimdall(const std::vector& buffers); + void stream_heimdall(const std::vector& buffers); + void connect_heimdall(); + void receive_exact(void *data, std::size_t length); + std::array receive_header(); + static void clear_buffers(const std::vector& buffers); public: - - /// @brief Constructor. - /// @param fc Center frequency (Hz). - /// @param path Path to save IQ data. - /// @return The object. - Kraken(std::string type, uint32_t fc, uint32_t fs, std::string path, - bool *saveIq, std::vector gain); - - /// @brief Implement capture function on KrakenSDR. - /// @param buffer Pointers to buffers for each channel. - /// @return Void. - void process(IqData *buffer1, IqData *buffer2); - - /// @brief Call methods to start capture. - /// @return Void. - void start(); - - /// @brief Call methods to gracefully stop capture. - /// @return Void. - void stop(); - - /// @brief Implement replay function on the Kraken. - /// @param buffers Pointers to buffers for each channel. - /// @param file Path to file to replay data from. - /// @param loop True if samples should loop at EOF. - /// @return Void. - void replay(IqData *buffer1, IqData *buffer2, std::string file, bool loop); - + Kraken(std::string type, uint32_t fc, uint32_t fs, std::string path, + bool *saveIq, std::size_t channelCount, std::string heimdallHost, + uint16_t heimdallPort); + void process(IqData *buffer1, IqData *buffer2) override; + void process(const std::vector& buffers) override; + void start() override; + void stop() override; + void replay(IqData *buffer1, IqData *buffer2, + std::string file, bool loop) override; + void replay(const std::vector& buffers, + std::string file, bool loop) override; }; #endif diff --git a/src/data/IqData.cpp b/src/data/IqData.cpp index 2c9bb814..21fcc43d 100644 --- a/src/data/IqData.cpp +++ b/src/data/IqData.cpp @@ -14,6 +14,11 @@ IqData::IqData(uint32_t _n) data = new std::deque>; } +IqData::~IqData() +{ + delete data; +} + uint32_t IqData::get_n() { return n; @@ -39,6 +44,32 @@ std::deque> IqData::get_data() return *data; } +const std::deque>& IqData::view_data() const +{ + return *data; +} + +std::deque> IqData::drain_front(uint32_t count) +{ + if (count > data->size()) + { + throw std::runtime_error("Attempting to drain past the end of a deque"); + } + auto end = data->begin() + count; + std::deque> samples(data->begin(), end); + data->erase(data->begin(), end); + return samples; +} + +void IqData::replace(std::deque>&& samples) +{ + if (samples.size() > n) + { + samples.erase(samples.begin(), samples.end() - n); + } + *data = std::move(samples); +} + void IqData::push_back(std::complex sample) { if (data->size() < n) @@ -52,6 +83,23 @@ void IqData::push_back(std::complex sample) } } +void IqData::append_unlocked( + const std::vector>& samples) +{ + if (samples.size() >= n) + { + data->clear(); + data->insert(data->end(), samples.end() - n, samples.end()); + return; + } + const std::size_t required = data->size() + samples.size(); + if (required > n) + { + data->erase(data->begin(), data->begin() + (required - n)); + } + data->insert(data->end(), samples.begin(), samples.end()); +} + std::complex IqData::pop_front() { if (data->empty()) { @@ -123,4 +171,4 @@ std::string IqData::to_json(uint64_t timestamp) document.Accept(writer); return strbuf.GetString(); -} \ No newline at end of file +} diff --git a/src/data/IqData.h b/src/data/IqData.h index f45e3632..0922a31d 100644 --- a/src/data/IqData.h +++ b/src/data/IqData.h @@ -46,6 +46,12 @@ class IqData /// @return The object. IqData(uint32_t n); + /// @brief Destructor. + ~IqData(); + + IqData(const IqData&) = delete; + IqData& operator=(const IqData&) = delete; + /// @brief Getter for maximum number of samples. /// @return Maximum number of samples. uint32_t get_n(); @@ -66,11 +72,24 @@ class IqData /// @return IQ data. std::deque> get_data(); + /// @brief Read-only access without copying. Caller must prevent mutation. + const std::deque>& view_data() const; + + /// @brief Remove and return a block from the front of the queue. + std::deque> drain_front(uint32_t count); + + /// @brief Replace all samples with an existing block. + void replace(std::deque>&& samples); + /// @brief Push a sample to the queue. /// @param sample A single sample. /// @return Void. void push_back(std::complex sample); + /// @brief Append a coherent float32 block while retaining the newest n. + /// @warning Caller must hold this object's lock. + void append_unlocked(const std::vector>& samples); + /// @brief Pop the front of the queue. /// @return Sample from the front of the queue. std::complex pop_front(); @@ -99,4 +118,4 @@ class IqData std::string to_json(uint64_t timestamp); }; -#endif \ No newline at end of file +#endif diff --git a/src/process/ambiguity/Ambiguity.cpp b/src/process/ambiguity/Ambiguity.cpp index 74e6f75d..6958f68f 100644 --- a/src/process/ambiguity/Ambiguity.cpp +++ b/src/process/ambiguity/Ambiguity.cpp @@ -6,6 +6,7 @@ #include #include #include +#include // constructor Ambiguity::Ambiguity(int32_t _delayMin, int32_t _delayMax, @@ -91,23 +92,28 @@ Ambiguity::~Ambiguity() Map> *Ambiguity::process(IqData *x, IqData *y) { - // shift reference if not 0 centered - if (dopplerMiddle != 0) - { - std::complex j = {0, 1}; - for (uint32_t i = 0; i < x->get_length(); i++) - { - x->push_back(x->pop_front() * std::exp(1.0 * j * 2.0 * M_PI * dopplerMiddle * ((double)i / fs))); - } - } + return process(x->view_data(), y); +} + +Map> *Ambiguity::process( + const std::deque& x, IqData *y) +{ + if (x.size() < nDopplerBins * nCorr) + throw std::runtime_error("Reference CPI is shorter than ambiguity input"); // range processing nSamples = nDopplerBins * nCorr; + uint32_t referenceIndex = 0; + const std::complex imaginary = {0, 1}; for (uint16_t i = 0; i < nDopplerBins; i++) { for (uint16_t j = 0; j < nCorr; j++) { - dataXi[j] = x->pop_front(); + dataXi[j] = x[referenceIndex]; + if (dopplerMiddle != 0) + dataXi[j] *= std::exp(imaginary * 2.0 * M_PI * dopplerMiddle * + (static_cast(referenceIndex) / fs)); + referenceIndex++; dataYi[j] = y->pop_front(); } @@ -197,4 +203,4 @@ uint32_t Ambiguity::get_nfft() const { uint32_t Ambiguity::get_n_samples() const { return nSamples; -} \ No newline at end of file +} diff --git a/src/process/ambiguity/Ambiguity.h b/src/process/ambiguity/Ambiguity.h index 6498254d..84cbbeb2 100644 --- a/src/process/ambiguity/Ambiguity.h +++ b/src/process/ambiguity/Ambiguity.h @@ -13,6 +13,7 @@ #include "process/meta/HammingNumber.h" #include #include +#include #include class Ambiguity @@ -43,6 +44,9 @@ class Ambiguity /// @return Ambiguity map data of IQ samples. Map *process(IqData *x, IqData *y); + /// @brief Process against a shared, immutable reference CPI. + Map *process(const std::deque& x, IqData *y); + double get_doppler_middle() const; uint16_t get_n_delay_bins() const; diff --git a/src/process/clutter/WienerHopf.cpp b/src/process/clutter/WienerHopf.cpp index 9fa9ddc5..5aa0c9ac 100644 --- a/src/process/clutter/WienerHopf.cpp +++ b/src/process/clutter/WienerHopf.cpp @@ -58,8 +58,8 @@ WienerHopf::~WienerHopf() bool WienerHopf::process(IqData *x, IqData *y) { uint32_t i, j; - xData = x->get_data(); - yData = y->get_data(); + const auto& xData = x->view_data(); + const auto yData = y->get_data(); // change deque to std::complex for (i = 0; i < nSamples; i++) @@ -160,4 +160,4 @@ bool WienerHopf::process(IqData *x, IqData *y) } return true; -} \ No newline at end of file +} diff --git a/src/process/clutter/WienerHopf.h b/src/process/clutter/WienerHopf.h index 5f9ddc3b..55d35495 100644 --- a/src/process/clutter/WienerHopf.h +++ b/src/process/clutter/WienerHopf.h @@ -42,11 +42,6 @@ class WienerHopf std::complex *dataX, *dataY, *dataOutX, *dataOutY, *dataA, *dataB, *filtX, *filtW, *filt; /// @} - /// @brief Deque storage for clutter filter processing. - /// @{ - std::deque> xData, yData; - /// @} - /// @brief Autocorrelation toeplitz matrix. arma::cx_mat A; @@ -78,4 +73,4 @@ class WienerHopf bool process(IqData *x, IqData *y); }; -#endif \ No newline at end of file +#endif diff --git a/src/process/conditioning/ArrayReferenceSynthesizer.cpp b/src/process/conditioning/ArrayReferenceSynthesizer.cpp new file mode 100644 index 00000000..ad55df07 --- /dev/null +++ b/src/process/conditioning/ArrayReferenceSynthesizer.cpp @@ -0,0 +1,135 @@ +#include "ArrayReferenceSynthesizer.h" + +#include +#include +#include +#include + +namespace +{ +void normalize(std::vector>& values) +{ + double power = 0; + for (const auto& value : values) power += std::norm(value); + const double norm = std::sqrt(std::max(power, 1e-30)); + for (auto& value : values) value /= norm; +} +} + +ArrayReferenceSynthesizer::ArrayReferenceSynthesizer() + : ArrayReferenceSynthesizer(Config{}) +{ +} + +ArrayReferenceSynthesizer::ArrayReferenceSynthesizer(Config config) + : config(config) +{ + if (config.analysisSamples < 64 || config.analysisInterval == 0 || + config.powerIterations == 0 || config.covarianceSmoothing < 0 || + config.covarianceSmoothing >= 1 || config.diagonalLoading < 0) + throw std::invalid_argument("Invalid array-reference configuration"); +} + +std::unique_ptr ArrayReferenceSynthesizer::process( + const std::vector& channels) +{ + if (channels.size() < 2) + throw std::invalid_argument("Array reference requires multiple channels"); + if (!channels.front()) + throw std::invalid_argument("Array-reference channel is null"); + const std::size_t samples = channels.front()->view_data().size(); + for (const auto *channel : channels) + if (!channel || channel->view_data().size() != samples) + throw std::invalid_argument("Array-reference channels are not aligned"); + + const std::size_t count = channels.size(); + const bool analyze = metrics.weights.size() != count || + frames++ % config.analysisInterval == 0; + if (analyze) + { + std::vector>> covariance(count, + std::vector>(count)); + const std::size_t stride = std::max(1, + samples / config.analysisSamples); + std::size_t observations = 0; + for (std::size_t sample = 0; sample < samples; sample += stride) + { + for (std::size_t left = 0; left < count; left++) + for (std::size_t right = 0; right < count; right++) + covariance[left][right] += channels[left]->view_data()[sample] * + std::conj(channels[right]->view_data()[sample]); + observations++; + } + double trace = 0; + for (std::size_t left = 0; left < count; left++) + for (std::size_t right = 0; right < count; right++) + { + covariance[left][right] /= static_cast(observations); + if (left == right) trace += covariance[left][right].real(); + } + const double loading = config.diagonalLoading * trace / count; + for (std::size_t channel = 0; channel < count; channel++) + covariance[channel][channel] += loading; + + std::vector> weights(count, + {1.0 / std::sqrt(static_cast(count)), 0}); + if (metrics.weights.size() == count) weights = metrics.weights; + for (uint32_t iteration = 0; iteration < config.powerIterations; iteration++) + { + std::vector> next(count); + for (std::size_t left = 0; left < count; left++) + for (std::size_t right = 0; right < count; right++) + next[left] += covariance[left][right] * weights[right]; + normalize(next); + weights = std::move(next); + } + if (metrics.weights.size() == count) + { + std::complex alignment{}; + for (std::size_t channel = 0; channel < count; channel++) + alignment += std::conj(metrics.weights[channel]) * weights[channel]; + if (std::abs(alignment) > 1e-12) + { + const auto phase = std::conj(alignment) / std::abs(alignment); + for (auto& weight : weights) weight *= phase; + } + for (std::size_t channel = 0; channel < count; channel++) + weights[channel] = config.covarianceSmoothing * + metrics.weights[channel] + (1 - config.covarianceSmoothing) * + weights[channel]; + normalize(weights); + } + std::vector> projected(count); + for (std::size_t left = 0; left < count; left++) + for (std::size_t right = 0; right < count; right++) + projected[left] += covariance[left][right] * weights[right]; + std::complex eigenvalue{}; + for (std::size_t channel = 0; channel < count; channel++) + eigenvalue += std::conj(weights[channel]) * projected[channel]; + const double principal = std::max(0.0, eigenvalue.real() - loading); + metrics.coherentFraction = principal / std::max(trace, 1e-30); + metrics.coherentGainDb = 10 * std::log10(std::max(1e-12, + principal / std::max(trace / count, 1e-30))); + metrics.weights = std::move(weights); + metrics.updates++; + } + + auto output = std::make_unique(samples); + std::deque> combined(samples); + double inputPower = 0; + double outputPower = 0; + for (std::size_t sample = 0; sample < samples; sample++) + { + for (std::size_t channel = 0; channel < count; channel++) + { + combined[sample] += std::conj(metrics.weights[channel]) * + channels[channel]->view_data()[sample]; + inputPower += std::norm(channels[channel]->view_data()[sample]) / count; + } + outputPower += std::norm(combined[sample]); + } + const double scale = std::sqrt(inputPower / std::max(outputPower, 1e-30)); + for (auto& value : combined) value *= scale; + output->replace(std::move(combined)); + return output; +} diff --git a/src/process/conditioning/ArrayReferenceSynthesizer.h b/src/process/conditioning/ArrayReferenceSynthesizer.h new file mode 100644 index 00000000..d0338577 --- /dev/null +++ b/src/process/conditioning/ArrayReferenceSynthesizer.h @@ -0,0 +1,42 @@ +#ifndef ARRAY_REFERENCE_SYNTHESIZER_H +#define ARRAY_REFERENCE_SYNTHESIZER_H + +#include "data/IqData.h" + +#include +#include +#include +#include + +class ArrayReferenceSynthesizer +{ +public: + struct Config + { + uint32_t analysisSamples = 32768; + uint32_t analysisInterval = 10; + uint32_t powerIterations = 12; + double covarianceSmoothing = 0.8; + double diagonalLoading = 0.001; + }; + + struct Metrics + { + double coherentFraction = 0; + double coherentGainDb = 0; + uint64_t updates = 0; + std::vector> weights; + }; + + ArrayReferenceSynthesizer(); + explicit ArrayReferenceSynthesizer(Config config); + std::unique_ptr process(const std::vector& channels); + const Metrics& get_metrics() const { return metrics; } + +private: + Config config; + Metrics metrics; + uint64_t frames = 0; +}; + +#endif diff --git a/src/process/fusion/Noncoherent.cpp b/src/process/fusion/Noncoherent.cpp new file mode 100644 index 00000000..2ff68cae --- /dev/null +++ b/src/process/fusion/Noncoherent.cpp @@ -0,0 +1,28 @@ +#include "Noncoherent.h" + +#include +#include + +std::unique_ptr>> Noncoherent::process( + const std::vector> *>& maps) const +{ + if (maps.empty() || maps.front() == nullptr) + throw std::invalid_argument("At least one ambiguity map is required"); + const uint32_t rows = maps.front()->get_nRows(); + const uint32_t columns = maps.front()->get_nCols(); + auto fused = std::make_unique>>(rows, columns); + fused->delay = maps.front()->delay; + fused->doppler = maps.front()->doppler; + for (auto *map : maps) + if (!map || map->get_nRows() != rows || map->get_nCols() != columns) + throw std::invalid_argument("Ambiguity-map dimensions do not match"); + for (uint32_t row = 0; row < rows; row++) + for (uint32_t column = 0; column < columns; column++) + { + double power = 0; + for (auto *map : maps) power += std::norm(map->data[row][column]); + fused->data[row][column] = { + std::sqrt(power / static_cast(maps.size())), 0}; + } + return fused; +} diff --git a/src/process/fusion/Noncoherent.h b/src/process/fusion/Noncoherent.h new file mode 100644 index 00000000..864966cb --- /dev/null +++ b/src/process/fusion/Noncoherent.h @@ -0,0 +1,17 @@ +#ifndef NONCOHERENT_H +#define NONCOHERENT_H + +#include "data/Map.h" + +#include +#include +#include + +class Noncoherent +{ +public: + std::unique_ptr>> process( + const std::vector> *>& maps) const; +}; + +#endif diff --git a/src/process/spectrum/SpectrumAnalyser.cpp b/src/process/spectrum/SpectrumAnalyser.cpp index 1a17bd32..1f0aa320 100644 --- a/src/process/spectrum/SpectrumAnalyser.cpp +++ b/src/process/spectrum/SpectrumAnalyser.cpp @@ -6,11 +6,13 @@ #include // constructor -SpectrumAnalyser::SpectrumAnalyser(uint32_t _n, double _bandwidth) +SpectrumAnalyser::SpectrumAnalyser(uint32_t _n, double _bandwidth, + double _centerFrequency) { // input n = _n; bandwidth = _bandwidth; + centerFrequency = _centerFrequency; // compute nfft decimation = n/bandwidth; @@ -63,7 +65,7 @@ void SpectrumAnalyser::process(IqData *x) } for (i = -nSpectrum/2; i < nSpectrum/2; i++) { - frequency.push_back(((i*bandwidth)+offset+204640000)/1000); + frequency.push_back(((i*bandwidth)+offset+centerFrequency)/1000); } x->update_frequency(frequency); diff --git a/src/process/spectrum/SpectrumAnalyser.h b/src/process/spectrum/SpectrumAnalyser.h index da0bf302..3f1c81fb 100644 --- a/src/process/spectrum/SpectrumAnalyser.h +++ b/src/process/spectrum/SpectrumAnalyser.h @@ -22,6 +22,9 @@ class SpectrumAnalyser /// @brief Minimum bandwidth of frequency bin (Hz). double bandwidth; + /// @brief Capture center frequency (Hz). + double centerFrequency; + /// @brief Decimation factor. uint32_t decimation; @@ -45,7 +48,7 @@ class SpectrumAnalyser /// @param n Number of samples on input. /// @param bandwidth Minimum bandwidth of frequency bin (Hz). /// @return The object. - SpectrumAnalyser(uint32_t n, double bandwidth); + SpectrumAnalyser(uint32_t n, double bandwidth, double centerFrequency); /// @brief Destructor. /// @return Void. @@ -57,4 +60,4 @@ class SpectrumAnalyser void process(IqData *x); }; -#endif \ No newline at end of file +#endif diff --git a/test/unit/capture/kraken/TestHeimdallFrame.cpp b/test/unit/capture/kraken/TestHeimdallFrame.cpp new file mode 100644 index 00000000..288252d0 --- /dev/null +++ b/test/unit/capture/kraken/TestHeimdallFrame.cpp @@ -0,0 +1,121 @@ +#include "capture/kraken/HeimdallFrame.h" + +#include +#include +#include + +namespace +{ +void write_be_u32( + std::array& data, + std::size_t offset, uint32_t value) +{ + for (std::size_t byte = 0; byte < 4; byte++) + data[offset + byte] = static_cast(value >> (8 * (3 - byte))); +} + +void append_le_float(std::vector& data, float value) +{ + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + for (std::size_t byte = 0; byte < 4; byte++) + data.push_back(static_cast(bits >> (8 * byte))); +} + +std::array valid_header() +{ + std::array bytes{}; + write_be_u32(bytes, 0, HeimdallFrame::MAGIC); + write_be_u32(bytes, 4, 5); + write_be_u32(bytes, 8, 2); + write_be_u32(bytes, 12, HeimdallFrame::CALIBRATED); + return bytes; +} +} + +TEST_CASE("Heimdall V2 five-channel frames decode") +{ + const auto bytes = valid_header(); + auto header = HeimdallFrame::decode_header(bytes); + REQUIRE(header.numChannels == 5); + REQUIRE(header.samplesPerChannel == 2); + REQUIRE(HeimdallFrame::is_synchronized_data(header)); + REQUIRE(HeimdallFrame::metadata_size(header) == 40); + REQUIRE(HeimdallFrame::payload_size(header) == 20); + + std::vector metadata; + for (uint32_t channel = 0; channel < 5; channel++) + { + append_le_float(metadata, 527000000.0F); + append_le_float(metadata, 15.0F + channel); + } + HeimdallFrame::decode_metadata(header, metadata); + REQUIRE(header.frequencies[4] == 527000000.0F); + REQUIRE(header.gains[4] == 19.0F); + + std::vector payload; + for (uint32_t channel = 0; channel < 5; channel++) + { + payload.push_back(0); + payload.push_back(64); + payload.push_back(255); + payload.push_back(192); + } + const auto channels = HeimdallFrame::decode_payload(header, payload); + REQUIRE(channels.size() == 5); + REQUIRE(channels[4][0].real() == -1.0F); + REQUIRE(channels[4][1].real() == 1.0F); + REQUIRE(channels[4][0].imag() == Catch::Approx(-64.0 / 127.5)); + REQUIRE(channels[4][1].imag() == Catch::Approx(64.0 / 127.5)); +} + +TEST_CASE("Heimdall V2 accepts supported channel counts") +{ + for (const uint32_t channelCount : {2U, 3U, 4U, 5U, 6U, 7U, 8U}) + { + auto bytes = valid_header(); + write_be_u32(bytes, 4, channelCount); + auto header = HeimdallFrame::decode_header(bytes); + REQUIRE(header.numChannels == channelCount); + REQUIRE(HeimdallFrame::metadata_size(header) == channelCount * 8); + REQUIRE(HeimdallFrame::payload_size(header) == channelCount * 4); + } +} + +TEST_CASE("Heimdall V2 rejects calibration and retune frames") +{ + auto bytes = valid_header(); + write_be_u32(bytes, 12, 5); + REQUIRE_FALSE(HeimdallFrame::is_synchronized_data( + HeimdallFrame::decode_header(bytes))); + + bytes = valid_header(); + write_be_u32(bytes, 16, 1); + REQUIRE_FALSE(HeimdallFrame::is_synchronized_data( + HeimdallFrame::decode_header(bytes))); + + bytes = valid_header(); + write_be_u32(bytes, 28, 1); + REQUIRE_FALSE(HeimdallFrame::is_synchronized_data( + HeimdallFrame::decode_header(bytes))); + + bytes = valid_header(); + write_be_u32(bytes, 12, 0x100U | HeimdallFrame::CALIBRATED); + REQUIRE(HeimdallFrame::is_synchronized_data( + HeimdallFrame::decode_header(bytes))); +} + +TEST_CASE("Heimdall V2 validates frame dimensions") +{ + auto bytes = valid_header(); + write_be_u32(bytes, 4, 9); + REQUIRE_THROWS(HeimdallFrame::decode_header(bytes)); + + bytes = valid_header(); + write_be_u32(bytes, 8, 0); + REQUIRE_THROWS(HeimdallFrame::decode_header(bytes)); + + auto header = HeimdallFrame::decode_header(valid_header()); + REQUIRE_THROWS(HeimdallFrame::decode_metadata(header, {})); + REQUIRE_THROWS(HeimdallFrame::decode_payload(header, {})); +} diff --git a/test/unit/process/conditioning/TestArrayReferenceSynthesizer.cpp b/test/unit/process/conditioning/TestArrayReferenceSynthesizer.cpp new file mode 100644 index 00000000..87f7f922 --- /dev/null +++ b/test/unit/process/conditioning/TestArrayReferenceSynthesizer.cpp @@ -0,0 +1,39 @@ +#include "process/conditioning/ArrayReferenceSynthesizer.h" + +#include +#include +#include + +TEST_CASE("array reference coherently combines two to eight channels") +{ + constexpr std::size_t samples = 1024; + for (const std::size_t channelCount : {2U, 3U, 5U, 8U}) + { + std::vector> storage; + std::vector channels; + for (std::size_t channel = 0; channel < channelCount; channel++) + { + auto data = std::make_unique(samples); + std::deque> iq; + for (std::size_t sample = 0; sample < samples; sample++) + iq.push_back(std::polar(1.0, 0.02 * sample + 0.3 * channel)); + data->replace(std::move(iq)); + channels.push_back(data.get()); + storage.push_back(std::move(data)); + } + + ArrayReferenceSynthesizer::Config config; + config.analysisSamples = samples; + config.analysisInterval = 1; + config.covarianceSmoothing = 0; + ArrayReferenceSynthesizer synthesizer(config); + const auto result = synthesizer.process(channels); + const auto& metrics = synthesizer.get_metrics(); + + REQUIRE(result->view_data().size() == samples); + REQUIRE(metrics.weights.size() == channelCount); + REQUIRE(metrics.coherentFraction > 0.99); + REQUIRE(metrics.coherentGainDb > + 10.0 * std::log10(static_cast(channelCount)) - 0.15); + } +} diff --git a/test/unit/process/fusion/TestNoncoherent.cpp b/test/unit/process/fusion/TestNoncoherent.cpp new file mode 100644 index 00000000..52dbeebf --- /dev/null +++ b/test/unit/process/fusion/TestNoncoherent.cpp @@ -0,0 +1,22 @@ +#include "process/fusion/Noncoherent.h" + +#include +#include +#include + +TEST_CASE("noncoherent fusion preserves RMS magnitude") +{ + Map> first(1, 1); + Map> second(1, 1); + first.data[0][0] = {3, 0}; + second.data[0][0] = {0, 4}; + first.delay.push_back(2); + first.doppler.push_back(5); + + Noncoherent fusion; + const auto result = fusion.process({&first, &second}); + REQUIRE(result->data[0][0].real() == Catch::Approx(std::sqrt(12.5))); + REQUIRE(result->data[0][0].imag() == 0); + REQUIRE(result->delay.front() == 2); + REQUIRE(result->doppler.front() == 5); +}