diff --git a/.gitignore b/.gitignore index d1b4e06..7382898 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ _deps/ .Trashes ehthumbs.db Thumbs.db + +# config files +*.json diff --git a/README.md b/README.md index 88c9546..d78780b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha | Language | C++20 | | Build system | CMake (≥ 3.25), Ninja-friendly | | Experiment file format | Protocol Buffers (proto3) — see `protoFiles/neuronide.proto` | +| Device config format | JSON (`config.json`) parsed with [nlohmann/json](https://github.com/nlohmann/json) | | EEG acquisition | [LSL — Lab Streaming Layer](https://github.com/sccn/liblsl) (`liblsl`) | | Rendering / windowing | SDL2 (+ SDL2_image), with vsync | | Inter-thread queues | [moodycamel ConcurrentQueue](https://github.com/cameron314/concurrentqueue) (lock-free) | @@ -44,8 +45,9 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha | Testing | GoogleTest + CTest | | Tooling | clang-format, clang-tidy, gcovr (coverage) | -`liblsl`, `concurrentqueue`, and `googletest` are fetched automatically by CMake -(`FetchContent`). SDL2 and Protobuf are expected to be installed on the system. +`liblsl`, `concurrentqueue`, `nlohmann/json`, and `googletest` are fetched +automatically by CMake (`FetchContent`). SDL2 and Protobuf are expected to be +installed on the system. ## 3. Architecture @@ -198,9 +200,10 @@ stream rather than letting an exception terminate the process. | `ComponentRegistry` | Implemented | proto-type → factory, macro-based self-registration | | `specifiic components` | **Planned** | defined in `neuronide.proto`, not yet implemented in C++ | | `Renderer` | Implemented | SDL + vsync, marker timestamping | -| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4) | +| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `LSLConfig` | +| `ConfigParser` | Implemented | `config.json` → `ExperimentConfig` (incl. `LSLConfig`), nlohmann/json | | `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` | -| `Runtime` orchestration | **Stub** | `Runtime::start()` currently only prints; wiring of Parser + the three threads is the next integration step | +| `Runtime` orchestration | **Stub** | currently does nothing | The class diagram in older docs is partly aspirational; the table above reflects the actual code. @@ -217,6 +220,7 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo) │ └── tests/ # .pbtxt fixtures + compiled .pb ├── include/ # public headers, mirrored by src/ │ ├── data_structures/ # EEGData, Marker, Context + │ ├── config/ # ConfigParser + ExperimentConfig / LSLConfig / ChannelConfig │ ├── parser/ # Parser │ ├── scene/ # Scene, SceneObject, components/ │ ├── renderer/ # Renderer @@ -249,9 +253,12 @@ sudo apt install cmake clang-format clang-tidy libsdl2-dev protobuf-compiler gco ```bash cmake -B build cmake --build build -./build/src/NeuronIDE # run the (currently stub) executable +./build/src/NeuronIDE config.json experiment.neuroz # parses the device config, parses scene, starts LSLReader, DataWriter, Renderer. ``` +`NeuronIDE` takes the path to a device `config.json` (defaults to `config.json` in +the working directory). + ### Tests ```bash @@ -300,10 +307,3 @@ protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto \ `feat(parser): create Parser class`. - **Types:** `feat`, `fix`, `style` (clang config), `test`, `ci` (`.github`). -## 10. Roadmap (next steps) - -1. Implement `Runtime` orchestration: `Parser → Scene`, then run `Renderer`, - `LSLReader`, and `DataWriter` concurrently and shut them down cleanly. -2. Implement the remaining components: `SpriteRenderer`, `TextRenderer`, - `ScriptComponent` (pybind11), each self-registering with `ComponentRegistry`. -3. Validate `LSLReader` end-to-end against a real EEG headset. diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake index 4219d95..a510cd1 100644 --- a/cmake/Coverage.cmake +++ b/cmake/Coverage.cmake @@ -30,7 +30,9 @@ if(NEURON_IDE_ENABLE_COVERAGE) --exclude ".*protoFiles.*" --exclude ".*pb.*" --exclude ".*\\.hpp" - --fail-under-line 60 + --exclude-throw-branches + --exclude-unreachable-branches + --fail-under-line 90 --print-summary --html-details ${COVERAGE_DIR}/index.html --xml ${COVERAGE_DIR}/coverage.xml diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 8b0b7c8..d9f2c02 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -27,6 +27,14 @@ FetchContent_Declare( SYSTEM ) +# 4. nlohmann/json (header-only) - device config parsing +FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + SYSTEM +) +set(JSON_BuildTests OFF CACHE INTERNAL "") + # Suppress compiler warnings from third-party targets when compiling their source files set(BACKUP_C_FLAGS "${CMAKE_C_FLAGS}") set(BACKUP_CXX_FLAGS "${CMAKE_CXX_FLAGS}") @@ -38,14 +46,14 @@ elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /w") endif() -FetchContent_MakeAvailable(googletest liblsl concurrentqueue) +FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json) # Restore compiler flags for our own project code set(CMAKE_C_FLAGS "${BACKUP_C_FLAGS}") set(CMAKE_CXX_FLAGS "${BACKUP_CXX_FLAGS}") -# 4. SDL2 (System installed) +# 5. SDL2 (System installed) find_package(SDL2 REQUIRED) -# 5. Protobuf (System installed) +# 6. Protobuf (System installed) find_package(Protobuf REQUIRED) diff --git a/include/Runtime.hpp b/include/Runtime.hpp index 838b4a6..14c10ba 100644 --- a/include/Runtime.hpp +++ b/include/Runtime.hpp @@ -1,18 +1,84 @@ #ifndef RUNTIME_HPP #define RUNTIME_HPP -#include +#include + +#include +#include +#include +#include +#include + +class Scene; +class LSLReader; +class DataWriter; +class Renderer; +struct EEGData; +struct Marker; + +class SDL_Renderer; + +struct RuntimePaths { + std::string config; // device config.json + std::string experiment; // serialized experiment scene (protobuf) + std::string outputDir = "."; // directory the recorded CSV is written to +}; class Runtime { public: - Runtime() = default; - ~Runtime() = default; + using RenderTargetFactory = + std::function(const std::string& windowTitle)>; + + explicit Runtime(const RuntimePaths& paths); + Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory); + ~Runtime(); Runtime(const Runtime&) = delete; Runtime& operator=(const Runtime&) = delete; Runtime(Runtime&&) = delete; - Runtime& operator=(Runtime&&) = delete; - static void start(); + Runtime& operator=(Runtime&&) = delete; + + void run(); + + void requestStop(); + + const std::string& outputPath() const noexcept { return outputFilePath; } + + static RenderTargetFactory defaultRenderTargetFactory(); + + private: + struct SdlSession { + SdlSession(); + ~SdlSession(); + + SdlSession(const SdlSession&) = delete; + SdlSession& operator=(const SdlSession&) = delete; + SdlSession(SdlSession&&) = delete; + SdlSession& operator=(SdlSession&&) = delete; + }; + + void startWorkers(const std::string& outputPath); + void shutdown(); + std::string makeOutputPath() const; + + RuntimePaths paths; + ExperimentConfig config; + + SdlSession sdlSession; + + std::shared_ptr scene; + std::shared_ptr> eegQueue; + std::shared_ptr> markerQueue; + + std::shared_ptr sdlRenderer; + + std::unique_ptr lslReader; + std::unique_ptr dataWriter; + std::unique_ptr renderer; + + std::string outputExtension; + std::string outputFilePath; + std::stop_source stopSource; }; -#endif // RUNTIME_HPP \ No newline at end of file +#endif // RUNTIME_HPP diff --git a/include/config/ChannelConfig.hpp b/include/config/ChannelConfig.hpp new file mode 100644 index 0000000..a888a55 --- /dev/null +++ b/include/config/ChannelConfig.hpp @@ -0,0 +1,14 @@ +#ifndef CHANNELCONFIG_HPP +#define CHANNELCONFIG_HPP + +#include + +// Description of a single EEG channel as declared in the device config file. +struct ChannelConfig { + int index = 0; + std::string label; + bool enabled = true; + std::string unit; +}; + +#endif // CHANNELCONFIG_HPP diff --git a/include/config/ConfigParser.hpp b/include/config/ConfigParser.hpp new file mode 100644 index 0000000..94173ef --- /dev/null +++ b/include/config/ConfigParser.hpp @@ -0,0 +1,16 @@ +#ifndef CONFIGPARSER_HPP +#define CONFIGPARSER_HPP + +#include +#include +#include + +class ConfigParser { + public: + ConfigParser() = default; + + static ExperimentConfig parse(const std::string& filePath); + static ExperimentConfig parseStream(std::istream& stream); +}; + +#endif // CONFIGPARSER_HPP diff --git a/include/config/ExperimentConfig.hpp b/include/config/ExperimentConfig.hpp new file mode 100644 index 0000000..9cd6ae0 --- /dev/null +++ b/include/config/ExperimentConfig.hpp @@ -0,0 +1,33 @@ +#ifndef EXPERIMENTCONFIG_HPP +#define EXPERIMENTCONFIG_HPP + +#include +#include +#include + +struct ReferenceConfig { + std::string label; + std::string scheme; +}; + +struct GroundConfig { + std::string label; +}; + +struct ImpedanceConfig { + bool supported = false; + double thresholdKohm = 0.0; +}; + +struct ExperimentConfig { + std::string configVersion; + std::string deviceName; + std::string montageStandard; + LSLConfig lsl; + ReferenceConfig reference; + GroundConfig ground; + ImpedanceConfig impedance; + OutputConfig output; +}; + +#endif // EXPERIMENTCONFIG_HPP diff --git a/include/config/LSLConfig.hpp b/include/config/LSLConfig.hpp new file mode 100644 index 0000000..8d05725 --- /dev/null +++ b/include/config/LSLConfig.hpp @@ -0,0 +1,17 @@ +#ifndef LSLCONFIG_HPP +#define LSLCONFIG_HPP + +#include +#include +#include + +struct LSLConfig { + std::string name; // lsl_stream.name + std::string type; // lsl_stream.type + std::string sourceId; // lsl_stream.source_id + int expectedChannelCount = 0; + double expectedSampleRateHz = 0.0; + std::vector channels; +}; + +#endif // LSLCONFIG_HPP diff --git a/include/config/OutputConfig.hpp b/include/config/OutputConfig.hpp new file mode 100644 index 0000000..083c62c --- /dev/null +++ b/include/config/OutputConfig.hpp @@ -0,0 +1,13 @@ +#ifndef OUTPUTCONFIG_HPP +#define OUTPUTCONFIG_HPP + +#include + +// How recorded data should be persisted. The `format` selects the DataWriter +// format strategy (see DataFormatStrategyFactory) and, through it, the output +// file extension. +struct OutputConfig { + std::string format = "csv"; +}; + +#endif // OUTPUTCONFIG_HPP diff --git a/include/datawriter/CSVFormatStrategy.hpp b/include/datawriter/CSVFormatStrategy.hpp index fb76dbe..4a9efca 100644 --- a/include/datawriter/CSVFormatStrategy.hpp +++ b/include/datawriter/CSVFormatStrategy.hpp @@ -15,6 +15,8 @@ class CSVFormatStrategy : public IDataFormatStrategy { CSVFormatStrategy(CSVFormatStrategy&&) = delete; CSVFormatStrategy& operator=(CSVFormatStrategy&&) = delete; + std::string fileExtension() const override { return "csv"; } + void open(const std::string& filepath) override; void close() override; diff --git a/include/datawriter/DataFormatStrategyFactory.hpp b/include/datawriter/DataFormatStrategyFactory.hpp new file mode 100644 index 0000000..9699e62 --- /dev/null +++ b/include/datawriter/DataFormatStrategyFactory.hpp @@ -0,0 +1,18 @@ +#ifndef DATAFORMATSTRATEGYFACTORY_HPP +#define DATAFORMATSTRATEGYFACTORY_HPP + +#include +#include +#include + +// Builds the DataWriter format strategy selected by the config's output format. +// Adding a new output format means registering it here; nothing else in the +// runtime needs to change. +class DataFormatStrategyFactory { + public: + // Returns the strategy for the given format (case-insensitive), e.g. "csv". + // Throws std::invalid_argument if the format is unknown. + static std::unique_ptr create(const std::string& format); +}; + +#endif // DATAFORMATSTRATEGYFACTORY_HPP diff --git a/include/datawriter/IDataFormatStrategy.hpp b/include/datawriter/IDataFormatStrategy.hpp index c178e43..28992a7 100644 --- a/include/datawriter/IDataFormatStrategy.hpp +++ b/include/datawriter/IDataFormatStrategy.hpp @@ -16,6 +16,8 @@ class IDataFormatStrategy { IDataFormatStrategy(IDataFormatStrategy&&) = delete; IDataFormatStrategy& operator=(IDataFormatStrategy&&) = delete; + virtual std::string fileExtension() const = 0; + virtual void open(const std::string& filepath) = 0; virtual void close() = 0; diff --git a/include/lslreader/LSLReader.hpp b/include/lslreader/LSLReader.hpp new file mode 100644 index 0000000..fc65925 --- /dev/null +++ b/include/lslreader/LSLReader.hpp @@ -0,0 +1,33 @@ +#ifndef LSLREADER_HPP +#define LSLREADER_HPP + +#include + +#include +#include +#include + +struct EEGData; + +class LSLReader { + public: + explicit LSLReader(LSLConfig config); + ~LSLReader(); + + LSLReader(const LSLReader&) = delete; + LSLReader& operator=(const LSLReader&) = delete; + LSLReader(LSLReader&&) = delete; + LSLReader& operator=(LSLReader&&) = delete; + + void start(std::shared_ptr> eegQueue); + void stop(); + + private: + void readLoop(const std::stop_token& stopToken); + + LSLConfig config; + std::shared_ptr> eegQueue; + std::jthread readerThread; +}; + +#endif // LSLREADER_HPP diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9d0e94c..c134744 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,17 +7,21 @@ target_link_libraries(neuronide_proto PUBLIC protobuf::libprotobuf) add_subdirectory(scene) add_subdirectory(parser) +add_subdirectory(config) add_subdirectory(renderer) +add_subdirectory(lslreader) add_subdirectory(datawriter) add_library(runtime_core STATIC Runtime.cpp ) target_include_directories(runtime_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) -target_link_libraries(runtime_core PUBLIC - scene - parser - renderer +target_link_libraries(runtime_core PUBLIC + scene + parser + config + renderer + lslreader datawriter ) diff --git a/src/Runtime.cpp b/src/Runtime.cpp index 200a3d5..1651f95 100644 --- a/src/Runtime.cpp +++ b/src/Runtime.cpp @@ -1,3 +1,133 @@ +#include + #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kWindowWidth = 1280; +constexpr int kWindowHeight = 720; +constexpr const char* kDefaultTitle = "NeuronIDE"; +constexpr const char* kFallbackName = "experiment"; + +std::string sdlError(const char* what) { return std::string(what) + ": " + SDL_GetError(); } +} // namespace + +Runtime::SdlSession::SdlSession() { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) { + throw std::runtime_error(sdlError("Runtime: SDL_Init failed")); + } +} + +Runtime::SdlSession::~SdlSession() { SDL_Quit(); } + +Runtime::RenderTargetFactory Runtime::defaultRenderTargetFactory() { + return [](const std::string& windowTitle) -> std::shared_ptr { + const std::string title = windowTitle.empty() ? kDefaultTitle : windowTitle; + + SDL_Window* window = + SDL_CreateWindow(title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, + kWindowWidth, kWindowHeight, SDL_WINDOW_SHOWN); + if (window == nullptr) { + throw std::runtime_error(sdlError("Runtime: SDL_CreateWindow failed")); + } + + SDL_Renderer* renderer = + SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + if (renderer == nullptr) { + SDL_DestroyWindow(window); + throw std::runtime_error(sdlError("Runtime: SDL_CreateRenderer failed")); + } + + return {renderer, [window](SDL_Renderer* target) { + if (target != nullptr) { + SDL_DestroyRenderer(target); + } + SDL_DestroyWindow(window); + }}; + }; +} + +Runtime::Runtime(const RuntimePaths& paths) : Runtime(paths, defaultRenderTargetFactory()) {} + +Runtime::Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory) + : paths(paths), + config(ConfigParser::parse(paths.config)), + scene(Parser::parse(paths.experiment)), + eegQueue(std::make_shared>()), + markerQueue(std::make_shared>()) { + if (!renderTargetFactory) { + throw std::invalid_argument("Runtime: render target factory must not be null"); + } + + sdlRenderer = renderTargetFactory(scene->getExperimentName()); + if (!sdlRenderer) { + throw std::runtime_error("Runtime: render target factory returned no renderer"); + } + + auto formatStrategy = DataFormatStrategyFactory::create(config.output.format); + outputExtension = formatStrategy->fileExtension(); + + lslReader = std::make_unique(config.lsl); + dataWriter = std::make_unique(std::move(formatStrategy)); + renderer = std::make_unique(scene, sdlRenderer, markerQueue); +} + +Runtime::~Runtime() { shutdown(); } + +std::string Runtime::makeOutputPath() const { + std::string name = scene->getExperimentName(); + if (name.empty()) { + name = kFallbackName; + } + std::replace(name.begin(), name.end(), ' ', '_'); + + const auto now = std::chrono::system_clock::now().time_since_epoch(); + const auto epoch = std::chrono::duration_cast(now).count(); + + const std::filesystem::path fileName = + name + "_" + std::to_string(epoch) + "." + outputExtension; + return (std::filesystem::path(paths.outputDir) / fileName).string(); +} + +void Runtime::startWorkers(const std::string& outputPath) { + dataWriter->start(outputPath, eegQueue, markerQueue); + lslReader->start(eegQueue); +} + +void Runtime::run() { + outputFilePath = makeOutputPath(); + startWorkers(outputFilePath); + + renderer->render(stopSource.get_token()); + + shutdown(); +} + +void Runtime::requestStop() { stopSource.request_stop(); } + +void Runtime::shutdown() { + stopSource.request_stop(); -void Runtime::start() { std::cout << "Runtime started." << "\n"; } \ No newline at end of file + if (lslReader) { + lslReader->stop(); + } + if (dataWriter) { + dataWriter->stop(); + } +} diff --git a/src/config/CMakeLists.txt b/src/config/CMakeLists.txt new file mode 100644 index 0000000..e6fb0b6 --- /dev/null +++ b/src/config/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(config OBJECT + ConfigParser.cpp +) + +target_include_directories(config PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/config +) + +target_link_libraries(config PRIVATE nlohmann_json::nlohmann_json) diff --git a/src/config/ConfigParser.cpp b/src/config/ConfigParser.cpp new file mode 100644 index 0000000..57796c8 --- /dev/null +++ b/src/config/ConfigParser.cpp @@ -0,0 +1,182 @@ +#include "config/ConfigParser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using nlohmann::json; + +const json* requireMember(const json& obj, const char* key, std::string_view ctx) { + const auto member = obj.find(key); + if (member == obj.end()) { + throw std::invalid_argument("ConfigParser: missing '" + std::string(key) + "' in " + + std::string(ctx)); + } + return &(*member); +} + +template +T requireField(const json& obj, const char* key, std::string_view ctx) { + return requireMember(obj, key, ctx)->get(); +} + +void requireNonEmpty(const std::string& value, const char* field, std::string_view ctx) { + if (value.empty()) { + throw std::invalid_argument("ConfigParser: '" + std::string(field) + + "' must not be empty in " + std::string(ctx)); + } +} + +std::vector buildChannels(const json& root, int expectedCount) { + const json& channelsJson = *requireMember(root, "channels", "config root"); + if (!channelsJson.is_array()) { + throw std::invalid_argument("ConfigParser: 'channels' must be an array"); + } + if (static_cast(channelsJson.size()) != expectedCount) { + throw std::invalid_argument("ConfigParser: channel count mismatch: 'channels' has " + + std::to_string(channelsJson.size()) + + " entries but expected_channel_count is " + + std::to_string(expectedCount)); + } + + std::vector channels; + channels.reserve(channelsJson.size()); + std::unordered_set seenIndices; + + for (const auto& entry : channelsJson) { + ChannelConfig channel; + channel.index = requireField(entry, "index", "channel"); + channel.label = requireField(entry, "label", "channel"); + channel.enabled = requireField(entry, "enabled", "channel"); + channel.unit = requireField(entry, "unit", "channel"); + + if (channel.index < 0 || channel.index >= expectedCount) { + throw std::invalid_argument("ConfigParser: channel index out of range: " + + std::to_string(channel.index)); + } + if (!seenIndices.insert(channel.index).second) { + throw std::invalid_argument("ConfigParser: duplicate channel index: " + + std::to_string(channel.index)); + } + + channels.push_back(std::move(channel)); + } + + return channels; +} + +LSLConfig buildLSLConfig(const json& root) { + const json& streamJson = *requireMember(root, "lsl_stream", "config root"); + + LSLConfig lsl; + lsl.name = requireField(streamJson, "name", "lsl_stream"); + lsl.type = requireField(streamJson, "type", "lsl_stream"); + lsl.sourceId = requireField(streamJson, "source_id", "lsl_stream"); + lsl.expectedChannelCount = + requireField(streamJson, "expected_channel_count", "lsl_stream"); + lsl.expectedSampleRateHz = + requireField(streamJson, "expected_sample_rate_hz", "lsl_stream"); + + requireNonEmpty(lsl.name, "name", "lsl_stream"); + requireNonEmpty(lsl.type, "type", "lsl_stream"); + requireNonEmpty(lsl.sourceId, "source_id", "lsl_stream"); + if (lsl.expectedChannelCount <= 0) { + throw std::invalid_argument("ConfigParser: 'expected_channel_count' must be positive"); + } + if (lsl.expectedSampleRateHz <= 0.0) { + throw std::invalid_argument("ConfigParser: 'expected_sample_rate_hz' must be positive"); + } + + lsl.channels = buildChannels(root, lsl.expectedChannelCount); + return lsl; +} + +ReferenceConfig buildReference(const json& root) { + ReferenceConfig reference; + if (root.contains("reference")) { + const json& ref = root.at("reference"); + reference.label = requireField(ref, "label", "reference"); + reference.scheme = requireField(ref, "scheme", "reference"); + } + return reference; +} + +GroundConfig buildGround(const json& root) { + GroundConfig ground; + if (root.contains("ground")) { + ground.label = requireField(root.at("ground"), "label", "ground"); + } + return ground; +} + +ImpedanceConfig buildImpedance(const json& root) { + ImpedanceConfig impedance; + if (root.contains("impedance_check")) { + const json& imp = root.at("impedance_check"); + impedance.supported = requireField(imp, "supported", "impedance_check"); + impedance.thresholdKohm = requireField(imp, "threshold_kohm", "impedance_check"); + } + return impedance; +} + +OutputConfig buildOutput(const json& root) { + OutputConfig output; + if (root.contains("output")) { + output.format = requireField(root.at("output"), "format", "output"); + requireNonEmpty(output.format, "format", "output"); + } + return output; +} +} // namespace + +ExperimentConfig ConfigParser::parse(const std::string& filePath) { + std::ifstream file(filePath); + if (!file.is_open()) { + throw std::runtime_error("ConfigParser: cannot open file: " + filePath); + } + + try { + return parseStream(file); + } catch (const std::exception& e) { + throw std::runtime_error("ConfigParser: failed to parse file " + filePath + " - " + + e.what()); + } +} + +ExperimentConfig ConfigParser::parseStream(std::istream& stream) { + json root; + try { + root = json::parse(stream); + } catch (const json::parse_error& e) { + throw std::runtime_error(std::string("ConfigParser: invalid JSON: ") + e.what()); + } + + if (!root.is_object()) { + throw std::invalid_argument("ConfigParser: config root must be a JSON object"); + } + + try { + ExperimentConfig config; + config.configVersion = requireField(root, "config_version", "config root"); + config.deviceName = requireField(root, "device_name", "config root"); + config.montageStandard = requireField(root, "montage_standard", "config root"); + + requireNonEmpty(config.deviceName, "device_name", "config root"); + + config.lsl = buildLSLConfig(root); + config.reference = buildReference(root); + config.ground = buildGround(root); + config.impedance = buildImpedance(root); + config.output = buildOutput(root); + + return config; + } catch (const json::type_error& e) { + throw std::invalid_argument(std::string("ConfigParser: field has wrong type: ") + e.what()); + } +} diff --git a/src/datawriter/CMakeLists.txt b/src/datawriter/CMakeLists.txt index c0b9322..b9a63e9 100644 --- a/src/datawriter/CMakeLists.txt +++ b/src/datawriter/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(datawriter OBJECT CSVFormatStrategy.cpp + DataFormatStrategyFactory.cpp DataWriter.cpp ) diff --git a/src/datawriter/DataFormatStrategyFactory.cpp b/src/datawriter/DataFormatStrategyFactory.cpp new file mode 100644 index 0000000..d9560ea --- /dev/null +++ b/src/datawriter/DataFormatStrategyFactory.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char character) { return std::tolower(character); }); + return value; +} + +using StrategyCreator = std::function()>; + +const std::unordered_map& creators() { + static const std::unordered_map registry = { + {"csv", [] { return std::make_unique(); }}, + }; + return registry; +} +} // namespace + +std::unique_ptr DataFormatStrategyFactory::create(const std::string& format) { + const auto entry = creators().find(toLower(format)); + if (entry == creators().end()) { + throw std::invalid_argument("DataFormatStrategyFactory: unknown output format '" + format + + "'"); + } + return entry->second(); +} diff --git a/src/lslreader/CMakeLists.txt b/src/lslreader/CMakeLists.txt new file mode 100644 index 0000000..0f88626 --- /dev/null +++ b/src/lslreader/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(lslreader OBJECT + LSLReader.cpp +) + +target_include_directories(lslreader PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/lslreader + ${CMAKE_CURRENT_SOURCE_DIR}/../../include/data_structures +) + +target_link_libraries(lslreader PUBLIC + lsl + concurrentqueue + config +) diff --git a/src/lslreader/LSLReader.cpp b/src/lslreader/LSLReader.cpp new file mode 100644 index 0000000..6ef90af --- /dev/null +++ b/src/lslreader/LSLReader.cpp @@ -0,0 +1,93 @@ +#include "lslreader/LSLReader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lsl_cpp.h" + +namespace { +constexpr double kResolveTimeout = 1.0; // seconds per resolve attempt +constexpr double kPullTimeout = 0.2; // seconds; bounds stop-token check latency +constexpr int kInletBufferSeconds = 360; // liblsl default inlet buffer length +constexpr double kSampleRateTolerance = 0.5; // Hz + +void validateStream(const lsl::stream_info& info, const LSLConfig& config) { + if (info.channel_count() != config.expectedChannelCount) { + throw std::runtime_error("LSLReader: stream '" + config.name + "' exposes " + + std::to_string(info.channel_count()) + + " channels but config expects " + + std::to_string(config.expectedChannelCount)); + } + + const double srate = info.nominal_srate(); + if (std::abs(srate - config.expectedSampleRateHz) > kSampleRateTolerance) { + throw std::runtime_error("LSLReader: stream '" + config.name + "' reports " + + std::to_string(srate) + " Hz but config expects " + + std::to_string(config.expectedSampleRateHz) + " Hz"); + } +} + +std::optional resolveStream(const LSLConfig& config, + const std::stop_token& stopToken) { + while (!stopToken.stop_requested()) { + std::vector results = + lsl::resolve_stream("name", config.name, 1, kResolveTimeout); + + if (!results.empty()) { + validateStream(results.front(), config); + return results.front(); + } + } + return std::nullopt; +} +} // namespace + +LSLReader::LSLReader(LSLConfig config) : config(std::move(config)) {} + +LSLReader::~LSLReader() { stop(); } + +void LSLReader::start(std::shared_ptr> eegQueue) { + stop(); + + this->eegQueue = std::move(eegQueue); + readerThread = std::jthread([this](const std::stop_token& stopToken) { + try { + readLoop(stopToken); + } catch (const std::exception& e) { + std::cerr << "LSLReader: fatal error, stopping acquisition: " << e.what() << "\n"; + } + }); +} + +void LSLReader::stop() { + if (readerThread.joinable()) { + readerThread.request_stop(); + } + if (readerThread.joinable()) { + readerThread.join(); + } +} + +void LSLReader::readLoop(const std::stop_token& stopToken) { + const std::optional info = resolveStream(config, stopToken); + if (!info.has_value()) { + return; + } + + lsl::stream_inlet inlet(*info, kInletBufferSeconds); + inlet.set_postprocessing(lsl::post_clocksync | lsl::post_dejitter | lsl::post_monotonize); + + while (!stopToken.stop_requested()) { + std::vector sample; + const double timestamp = inlet.pull_sample(sample, kPullTimeout); + if (timestamp != 0.0) { + eegQueue->enqueue(EEGData{timestamp, std::move(sample)}); + } + } +} diff --git a/src/main.cpp b/src/main.cpp index 824bdc3..3dcb40c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,42 @@ #include +#include +#include +#include +#include +#include + +namespace { +constexpr const char* kDefaultConfigPath = "config.json"; + +void printUsage(const char* program) { + std::cerr << "Usage: " << program << " [config.json] \n" + << " config.json defaults to '" << kDefaultConfigPath + << "' in the working directory.\n"; +} +} // namespace int main(int argc, char* argv[]) { - (void)argc; - (void)argv; - Runtime::start(); + const std::span args(argv, static_cast(argc)); + + RuntimePaths paths{.config = kDefaultConfigPath, .experiment = ""}; + + if (args.size() == 2) { + paths.experiment = args[1]; + } else if (args.size() >= 3) { + paths.config = args[1]; + paths.experiment = args[2]; + } else { + printUsage(args.empty() ? "NeuronIDE" : args[0]); + return 1; + } + + try { + Runtime runtime(paths); + runtime.run(); + } catch (const std::exception& e) { + std::cerr << "NeuronIDE: fatal: " << e.what() << "\n"; + return 1; + } + return 0; -} \ No newline at end of file +} diff --git a/tests/component_tests/dummy_component_test.cpp b/tests/component_tests/dummy_component_test.cpp deleted file mode 100644 index 233d9dd..0000000 --- a/tests/component_tests/dummy_component_test.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#include - -TEST(DummyComponentTest, AlwaysPasses) { EXPECT_TRUE(true); } diff --git a/tests/unit_tests/RendererTest.cpp b/tests/unit_tests/RendererTest.cpp index 88da249..6f552c3 100644 --- a/tests/unit_tests/RendererTest.cpp +++ b/tests/unit_tests/RendererTest.cpp @@ -1,8 +1,10 @@ #include #include +#include #include #include +#include #include "data_structures/Context.hpp" #include "data_structures/Marker.hpp" @@ -16,6 +18,7 @@ constexpr int kDummySurfaceWidth = 10; constexpr int kDummySurfaceHeight = 10; constexpr int kDummySurfaceDepth = 32; constexpr uint32_t kDummySurfaceFlags = 0; +constexpr auto kRenderSpinWait = std::chrono::milliseconds(30); class CustomComponent : public Component { public: @@ -65,11 +68,29 @@ class MarkerComponent : public Component { std::shared_ptr stopSource; }; +class RendererTest : public ::testing::Test { + protected: + void SetUp() override { ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); } + void TearDown() override { SDL_Quit(); } + + static std::shared_ptr makeSoftwareRenderer() { + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat( + kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, kDummySurfaceDepth, + SDL_PIXELFORMAT_RGBA32); + SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); + return std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { + if (renderer != nullptr) { + SDL_DestroyRenderer(renderer); + } + if (surface != nullptr) { + SDL_FreeSurface(surface); + } + }); + } +}; } // namespace -TEST(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeStop) { - ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); - +TEST_F(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeStop) { auto scene = std::make_shared(); auto obj = std::make_shared("obj"); @@ -80,34 +101,16 @@ TEST(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeSto obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); scene->addObject(obj); - SDL_Surface* surface = - SDL_CreateRGBSurfaceWithFormat(kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, - kDummySurfaceDepth, SDL_PIXELFORMAT_RGBA32); - SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); - auto sharedRenderer = - std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { - if (renderer) { - SDL_DestroyRenderer(renderer); - } - if (surface) { - SDL_FreeSurface(surface); - } - }); - auto markerQueue = std::make_shared>(); - Renderer renderer(scene, sharedRenderer, markerQueue); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); renderer.render(stop_source->get_token()); EXPECT_EQ(*updates, 1); EXPECT_EQ(*renders, 1); - - SDL_Quit(); } -TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { - ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); - +TEST_F(RendererTest, RenderLoop_QueuesMarkersFromComponents) { auto scene = std::make_shared(); auto obj = std::make_shared("obj"); auto stop_source = std::make_shared(); @@ -115,23 +118,9 @@ TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { obj->addComponent(std::make_unique(obj, stop_source)); scene->addObject(obj); - SDL_Surface* surface = - SDL_CreateRGBSurfaceWithFormat(kDummySurfaceFlags, kDummySurfaceWidth, kDummySurfaceHeight, - kDummySurfaceDepth, SDL_PIXELFORMAT_RGBA32); - SDL_Renderer* sdlRenderer = SDL_CreateSoftwareRenderer(surface); - auto sharedRenderer = - std::shared_ptr(sdlRenderer, [surface](SDL_Renderer* renderer) { - if (renderer) { - SDL_DestroyRenderer(renderer); - } - if (surface) { - SDL_FreeSurface(surface); - } - }); - auto markerQueue = std::make_shared>(); - Renderer renderer(scene, sharedRenderer, markerQueue); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); renderer.render(stop_source->get_token()); Marker marker; @@ -141,6 +130,67 @@ TEST(RendererTest, RenderLoop_QueuesMarkersFromComponents) { EXPECT_EQ(marker.eventName, "test_marker"); EXPECT_FALSE(markerQueue->try_dequeue(marker)); } +} + +TEST_F(RendererTest, RenderLoop_OnQuitEvent_ReturnsBeforeUpdating) { + auto scene = std::make_shared(); + auto obj = std::make_shared("obj"); + auto updates = std::make_shared>(0); + auto renders = std::make_shared>(0); + auto stop_source = std::make_shared(); + + obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); + scene->addObject(obj); + + auto markerQueue = std::make_shared>(); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + renderer.render(stop_source->get_token()); + + EXPECT_EQ(*updates, 0); + EXPECT_EQ(*renders, 0); +} - SDL_Quit(); -} \ No newline at end of file +TEST_F(RendererTest, RenderLoop_OnNonQuitEvent_ContinuesUpdating) { + auto scene = std::make_shared(); + auto obj = std::make_shared("obj"); + auto updates = std::make_shared>(0); + auto renders = std::make_shared>(0); + auto stop_source = std::make_shared(); + + obj->addComponent(std::make_unique(obj, updates, renders, stop_source)); + scene->addObject(obj); + + auto markerQueue = std::make_shared>(); + + SDL_Event userEvent; + userEvent.type = SDL_USEREVENT; + ASSERT_EQ(SDL_PushEvent(&userEvent), 1); + + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + renderer.render(stop_source->get_token()); + + EXPECT_EQ(*updates, 1); + EXPECT_EQ(*renders, 1); +} + +TEST_F(RendererTest, RenderLoop_WhenSceneExpired_KeepsRunningWithoutCrashing) { + auto scene = std::make_shared(); + auto markerQueue = std::make_shared>(); + Renderer renderer(scene, makeSoftwareRenderer(), markerQueue); + + scene.reset(); + + std::stop_source stopSource; + std::thread worker([&renderer, &stopSource]() { renderer.render(stopSource.get_token()); }); + std::this_thread::sleep_for(kRenderSpinWait); + stopSource.request_stop(); + worker.join(); + + Marker marker; + EXPECT_FALSE(markerQueue->try_dequeue(marker)); +} diff --git a/tests/unit_tests/config_parser_test.cpp b/tests/unit_tests/config_parser_test.cpp new file mode 100644 index 0000000..e79f5ca --- /dev/null +++ b/tests/unit_tests/config_parser_test.cpp @@ -0,0 +1,362 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace fs = std::filesystem; + +constexpr int kExpectedChannelCount = 8; +constexpr double kExpectedSampleRate = 250.0; +constexpr double kImpedanceThreshold = 5.0; +constexpr double kDefaultImpedanceThreshold = 0.0; + +constexpr const char* kSampleConfig = R"json({ + "config_version": "1.0", + "device_name": "OpenBCI Cyton 8ch", + "montage_standard": "10-20", + "lsl_stream": { + "name": "obci_eeg1", + "type": "EEG", + "source_id": "cyton-a1b2c3", + "expected_channel_count": 8, + "expected_sample_rate_hz": 250 + }, + "reference": { "label": "linked_mastoids", "scheme": "physical" }, + "ground": { "label": "Fpz" }, + "channels": [ + { "index": 0, "label": "Fz", "enabled": true, "unit": "microvolts" }, + { "index": 1, "label": "Cz", "enabled": true, "unit": "microvolts" }, + { "index": 2, "label": "Pz", "enabled": true, "unit": "microvolts" }, + { "index": 3, "label": "Oz", "enabled": true, "unit": "microvolts" }, + { "index": 4, "label": "P3", "enabled": true, "unit": "microvolts" }, + { "index": 5, "label": "P4", "enabled": true, "unit": "microvolts" }, + { "index": 6, "label": "O1", "enabled": true, "unit": "microvolts" }, + { "index": 7, "label": "O2", "enabled": false, "unit": "microvolts" } + ], + "impedance_check": { "supported": true, "threshold_kohm": 5.0 } +})json"; + +constexpr const char* kMinimalConfig = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] +})json"; + +ExperimentConfig parseString(const std::string& jsonText) { + std::istringstream stream(jsonText); + return ConfigParser::parseStream(stream); +} + +fs::path writeTempConfig(const std::string& content) { + const auto suffix = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + const fs::path path = fs::temp_directory_path() / ("neuronide_config_" + suffix + ".json"); + std::ofstream out(path); + out << content; + return path; +} +} // namespace + +TEST(ConfigParserTest, ParsesTopLevelMetadata) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.configVersion, "1.0"); + EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); + EXPECT_EQ(config.montageStandard, "10-20"); +} + +TEST(ConfigParserTest, ParsesLslStreamFields) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.lsl.name, "obci_eeg1"); + EXPECT_EQ(config.lsl.type, "EEG"); + EXPECT_EQ(config.lsl.sourceId, "cyton-a1b2c3"); + EXPECT_EQ(config.lsl.expectedChannelCount, kExpectedChannelCount); + EXPECT_DOUBLE_EQ(config.lsl.expectedSampleRateHz, kExpectedSampleRate); +} + +TEST(ConfigParserTest, ParsesAllChannelsIncludingDisabled) { + const ExperimentConfig config = parseString(kSampleConfig); + ASSERT_EQ(config.lsl.channels.size(), static_cast(kExpectedChannelCount)); + + const auto& first = config.lsl.channels.front(); + EXPECT_EQ(first.index, 0); + EXPECT_EQ(first.label, "Fz"); + EXPECT_TRUE(first.enabled); + EXPECT_EQ(first.unit, "microvolts"); + + const auto& last = config.lsl.channels.back(); + EXPECT_EQ(last.label, "O2"); + EXPECT_FALSE(last.enabled); +} + +TEST(ConfigParserTest, ParsesReferenceGroundAndImpedance) { + const ExperimentConfig config = parseString(kSampleConfig); + EXPECT_EQ(config.reference.label, "linked_mastoids"); + EXPECT_EQ(config.reference.scheme, "physical"); + EXPECT_EQ(config.ground.label, "Fpz"); + EXPECT_TRUE(config.impedance.supported); + EXPECT_DOUBLE_EQ(config.impedance.thresholdKohm, kImpedanceThreshold); +} + +TEST(ConfigParserTest, MissingLslStreamThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "channels": [] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, EmptyStreamNameThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelCountMismatchThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 2, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedJsonThrows) { + EXPECT_THROW(parseString("{ this is not json"), std::runtime_error); +} + +TEST(ConfigParserTest, NonObjectRootThrows) { + EXPECT_THROW(parseString("[1, 2, 3]"), std::invalid_argument); +} + +TEST(ConfigParserTest, WrongFieldTypeThrows) { + EXPECT_THROW(parseString(R"json({ "config_version": "1.0", "device_name": 123 })json"), + std::invalid_argument); +} + +TEST(ConfigParserTest, EmptyStreamTypeThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, EmptySourceIdThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NonPositiveChannelCountThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 0, "expected_sample_rate_hz": 250 + }, + "channels": [] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NonPositiveSampleRateThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 0 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelsNotArrayThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": 5 + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ChannelIndexOutOfRangeThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 5, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, NegativeChannelIndexThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": -1, "label": "Fz", "enabled": true, "unit": "uV" } ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, DuplicateChannelIndexThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 2, "expected_sample_rate_hz": 250 + }, + "channels": [ + { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" }, + { "index": 0, "label": "Cz", "enabled": true, "unit": "uV" } + ] + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedReferenceThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "reference": { "label": 5, "scheme": "physical" } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MalformedGroundThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "ground": { "label": 5 } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, OptionalSectionsDefaultWhenAbsent) { + const ExperimentConfig config = parseString(kMinimalConfig); + EXPECT_TRUE(config.reference.label.empty()); + EXPECT_TRUE(config.reference.scheme.empty()); + EXPECT_TRUE(config.ground.label.empty()); + EXPECT_FALSE(config.impedance.supported); + EXPECT_DOUBLE_EQ(config.impedance.thresholdKohm, kDefaultImpedanceThreshold); +} + +TEST(ConfigParserTest, OutputFormatDefaultsToCsvWhenAbsent) { + const ExperimentConfig config = parseString(kMinimalConfig); + EXPECT_EQ(config.output.format, "csv"); +} + +TEST(ConfigParserTest, ParsesOutputFormat) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "output": { "format": "csv" } + })json"; + const ExperimentConfig config = parseString(jsonText); + EXPECT_EQ(config.output.format, "csv"); +} + +TEST(ConfigParserTest, EmptyOutputFormatThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "output": { "format": "" } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, MissingOutputFormatFieldThrows) { + const std::string jsonText = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "s", "type": "EEG", "source_id": "x", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "output": { } + })json"; + EXPECT_THROW(parseString(jsonText), std::invalid_argument); +} + +TEST(ConfigParserTest, ParsesFromFilePath) { + const fs::path path = writeTempConfig(kSampleConfig); + + const ExperimentConfig config = ConfigParser::parse(path.string()); + EXPECT_EQ(config.deviceName, "OpenBCI Cyton 8ch"); + EXPECT_EQ(config.lsl.name, "obci_eeg1"); + + fs::remove(path); +} + +TEST(ConfigParserTest, MissingFileThrows) { + EXPECT_THROW(ConfigParser::parse("/no/such/neuronide_config_file.json"), std::runtime_error); +} + +TEST(ConfigParserTest, InvalidFileContentThrows) { + const fs::path path = writeTempConfig("{ this is not json"); + + EXPECT_THROW(ConfigParser::parse(path.string()), std::runtime_error); + + fs::remove(path); +} diff --git a/tests/unit_tests/data_format_strategy_factory_test.cpp b/tests/unit_tests/data_format_strategy_factory_test.cpp new file mode 100644 index 0000000..040bb55 --- /dev/null +++ b/tests/unit_tests/data_format_strategy_factory_test.cpp @@ -0,0 +1,25 @@ +#include + +#include +#include +#include + +TEST(DataFormatStrategyFactoryTest, CreatesCsvStrategy) { + const auto strategy = DataFormatStrategyFactory::create("csv"); + ASSERT_NE(strategy, nullptr); + EXPECT_EQ(strategy->fileExtension(), "csv"); +} + +TEST(DataFormatStrategyFactoryTest, FormatMatchingIsCaseInsensitive) { + const auto strategy = DataFormatStrategyFactory::create("CSV"); + ASSERT_NE(strategy, nullptr); + EXPECT_EQ(strategy->fileExtension(), "csv"); +} + +TEST(DataFormatStrategyFactoryTest, UnknownFormatThrows) { + EXPECT_THROW(DataFormatStrategyFactory::create("parquet"), std::invalid_argument); +} + +TEST(DataFormatStrategyFactoryTest, EmptyFormatThrows) { + EXPECT_THROW(DataFormatStrategyFactory::create(""), std::invalid_argument); +} diff --git a/tests/unit_tests/dummy_unit_test.cpp b/tests/unit_tests/dummy_unit_test.cpp deleted file mode 100644 index b7a05f3..0000000 --- a/tests/unit_tests/dummy_unit_test.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#include - -TEST(DummyUnitTest, AlwaysPasses) { EXPECT_TRUE(true); } diff --git a/tests/unit_tests/lslreader_test.cpp b/tests/unit_tests/lslreader_test.cpp new file mode 100644 index 0000000..297ca1e --- /dev/null +++ b/tests/unit_tests/lslreader_test.cpp @@ -0,0 +1,177 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lsl_cpp.h" + +namespace { +constexpr int kChannelCount = 4; +constexpr double kSampleRate = 250.0; +constexpr int kSamplesToPush = 20; +constexpr int kMismatchedChannels = kChannelCount + 1; +constexpr double kMismatchedSampleRate = 100.0; +constexpr auto kSubscribeWait = std::chrono::seconds(3); +constexpr auto kPushInterval = std::chrono::milliseconds(10); +constexpr auto kDrainWait = std::chrono::milliseconds(300); +constexpr auto kSubscribePoll = std::chrono::milliseconds(20); + +constexpr auto kValidationWait = std::chrono::milliseconds(1500); + +class ScopedStreamRedirect { + public: + ScopedStreamRedirect(std::ostream& stream, std::streambuf* buffer) + : stream(stream), previous(stream.rdbuf(buffer)) {} + ~ScopedStreamRedirect() { stream.rdbuf(previous); } + + ScopedStreamRedirect(const ScopedStreamRedirect&) = delete; + ScopedStreamRedirect& operator=(const ScopedStreamRedirect&) = delete; + ScopedStreamRedirect(ScopedStreamRedirect&&) = delete; + ScopedStreamRedirect& operator=(ScopedStreamRedirect&&) = delete; + + private: + std::ostream& stream; + std::streambuf* previous; +}; + +LSLConfig makeConfig() { + LSLConfig config; + config.name = "neuronide_test_stream"; + config.type = "EEG"; + config.sourceId = "neuronide-test-src"; + config.expectedChannelCount = kChannelCount; + config.expectedSampleRateHz = kSampleRate; + return config; +} + +std::vector makeSample() { + std::vector sample(kChannelCount); + for (int i = 0; i < kChannelCount; ++i) { + sample[i] = static_cast(i + 1); + } + return sample; +} + +lsl::stream_outlet makeOutletWithShape(const LSLConfig& config, int channelCount, + double sampleRate) { + const lsl::stream_info info(config.name, config.type, channelCount, sampleRate, + lsl::cf_double64, config.sourceId); + return lsl::stream_outlet(info); +} + +lsl::stream_outlet makeOutlet(const LSLConfig& config) { + return makeOutletWithShape(config, config.expectedChannelCount, config.expectedSampleRateHz); +} + +// Waits (bounded) for the reader's inlet to subscribe to the outlet. +bool waitForConsumer(lsl::stream_outlet& outlet) { + const auto deadline = std::chrono::steady_clock::now() + kSubscribeWait; + while (!outlet.have_consumers()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + std::this_thread::sleep_for(kSubscribePoll); + } + return true; +} + +void pushSamples(lsl::stream_outlet& outlet, const std::vector& sample, int count) { + for (int i = 0; i < count; ++i) { + outlet.push_sample(sample); + std::this_thread::sleep_for(kPushInterval); + } +} + +std::string runAndCaptureDiagnostics(const LSLConfig& config, int channelCount, double sampleRate) { + lsl::stream_outlet outlet = makeOutletWithShape(config, channelCount, sampleRate); + auto eegQueue = std::make_shared>(); + LSLReader reader(config); + + std::ostringstream captured; + { + const ScopedStreamRedirect redirect(std::cerr, captured.rdbuf()); + reader.start(eegQueue); + std::this_thread::sleep_for(kValidationWait); + reader.stop(); + } + + EEGData received; + EXPECT_FALSE(eegQueue->try_dequeue(received)) << "a rejected stream must yield no samples"; + return captured.str(); +} +} // namespace + +TEST(LSLReaderTest, ReadsSamplesFromStreamIntoQueue) { + const LSLConfig config = makeConfig(); + lsl::stream_outlet outlet = makeOutlet(config); + + auto eegQueue = std::make_shared>(); + + LSLReader reader(config); + reader.start(eegQueue); + + ASSERT_TRUE(waitForConsumer(outlet)) << "LSLReader did not subscribe (needs loopback)"; + + const std::vector sample = makeSample(); + pushSamples(outlet, sample, kSamplesToPush); + + std::this_thread::sleep_for(kDrainWait); + reader.stop(); + + EEGData received; + ASSERT_TRUE(eegQueue->try_dequeue(received)); + EXPECT_EQ(received.channels.size(), static_cast(kChannelCount)); + EXPECT_DOUBLE_EQ(received.channels.front(), sample.front()); + EXPECT_DOUBLE_EQ(received.channels.back(), sample.back()); + EXPECT_NE(received.timestamp, 0.0); +} + +TEST(LSLReaderTest, RejectsStreamWithMismatchedChannelCount) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_chan_mismatch"; + config.sourceId = "neuronide-test-chan"; + + const std::string log = runAndCaptureDiagnostics(config, kMismatchedChannels, kSampleRate); + EXPECT_NE(log.find("channels"), std::string::npos) + << "expected a channel-count rejection, got: " << log; +} + +TEST(LSLReaderTest, RejectsStreamWithMismatchedSampleRate) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_rate_mismatch"; + config.sourceId = "neuronide-test-rate"; + + const std::string log = runAndCaptureDiagnostics(config, kChannelCount, kMismatchedSampleRate); + EXPECT_NE(log.find("Hz"), std::string::npos) + << "expected a sample-rate rejection, got: " << log; +} + +TEST(LSLReaderTest, StopBeforeStreamResolvedExitsCleanly) { + LSLConfig config = makeConfig(); + config.name = "neuronide_test_absent_stream"; + config.sourceId = "neuronide-test-absent"; + + auto eegQueue = std::make_shared>(); + LSLReader reader(config); + + reader.start(eegQueue); + reader.stop(); + + EEGData received; + EXPECT_FALSE(eegQueue->try_dequeue(received)); +} + +TEST(LSLReaderTest, DestroyingUnstartedReaderIsSafe) { + EXPECT_NO_THROW({ const LSLReader reader(makeConfig()); }); +} diff --git a/tests/unit_tests/runtime_test.cpp b/tests/unit_tests/runtime_test.cpp new file mode 100644 index 0000000..cd8da38 --- /dev/null +++ b/tests/unit_tests/runtime_test.cpp @@ -0,0 +1,232 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/ParserTestUtils.hpp" + +namespace { +namespace fs = std::filesystem; + +constexpr int kSurfaceSize = 10; +constexpr int kSurfaceDepth = 32; +constexpr auto kRenderSpinWait = std::chrono::milliseconds(50); + +constexpr const char* kValidConfig = R"json({ + "config_version": "1.0", + "device_name": "Dev", + "montage_standard": "10-20", + "lsl_stream": { + "name": "runtime_test_stream", "type": "EEG", "source_id": "runtime-test-src", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ] +})json"; + +// A software renderer backed by an in-memory surface: no window, GPU, or +// display required, so it runs anywhere (including headless CI). Injected in +// place of Runtime's production SDL window/vsync-renderer factory. +Runtime::RenderTargetFactory softwareRenderTargetFactory() { + return [](const std::string&) -> std::shared_ptr { + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat( + 0, kSurfaceSize, kSurfaceSize, kSurfaceDepth, SDL_PIXELFORMAT_RGBA32); + SDL_Renderer* renderer = SDL_CreateSoftwareRenderer(surface); + return {renderer, [surface](SDL_Renderer* target) { + if (target != nullptr) { + SDL_DestroyRenderer(target); + } + if (surface != nullptr) { + SDL_FreeSurface(surface); + } + }}; + }; +} + +void writeText(const fs::path& path, const std::string& content) { + std::ofstream out(path, std::ios::binary); + out << content; +} + +void writeExperimentFile(const fs::path& path, const NeuronIDE::Scene& scene) { + std::ofstream out(path, std::ios::binary); + scene.SerializeToOstream(&out); +} + +std::vector readAllLines(const fs::path& path) { + std::ifstream input(path); + std::vector lines; + std::string line; + while (std::getline(input, line)) { + lines.push_back(line); + } + return lines; +} + +// Owns the temp files that back a Runtime so a test never leaves artifacts on +// disk, and provides a valid config + experiment scene by default. +class RuntimeFixture : public ::testing::Test { + protected: + void SetUp() override { + // SdlSession initializes SDL_INIT_VIDEO; the dummy driver makes that + // succeed without a display. The software renderer is unaffected by it. + setenv("SDL_VIDEODRIVER", "dummy", 1); + + tempDir = fs::temp_directory_path() / + ("neuronide_runtime_" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + fs::create_directories(tempDir); + + writeText(tempDir / "config.json", kValidConfig); + writeExperimentFile(tempDir / "experiment.pb", utils::buildSimpleScene()); + } + + void TearDown() override { + std::error_code errorCode; + fs::remove_all(tempDir, errorCode); + } + + const fs::path& dir() const { return tempDir; } + + RuntimePaths paths() const { + return RuntimePaths{.config = (tempDir / "config.json").string(), + .experiment = (tempDir / "experiment.pb").string(), + .outputDir = tempDir.string()}; + } + + private: + fs::path tempDir; +}; +} // namespace + +TEST_F(RuntimeFixture, ConstructsWithValidInputs) { + EXPECT_NO_THROW({ const Runtime runtime(paths(), softwareRenderTargetFactory()); }); +} + +TEST_F(RuntimeFixture, OutputPathEmptyBeforeRun) { + const Runtime runtime(paths(), softwareRenderTargetFactory()); + EXPECT_TRUE(runtime.outputPath().empty()); +} + +TEST_F(RuntimeFixture, MissingConfigThrows) { + RuntimePaths badPaths = paths(); + badPaths.config = (dir() / "does_not_exist.json").string(); + + EXPECT_THROW(Runtime(badPaths, softwareRenderTargetFactory()), std::exception); +} + +TEST_F(RuntimeFixture, MissingExperimentThrows) { + RuntimePaths badPaths = paths(); + badPaths.experiment = (dir() / "does_not_exist.pb").string(); + + EXPECT_THROW(Runtime(badPaths, softwareRenderTargetFactory()), std::exception); +} + +TEST_F(RuntimeFixture, UnknownOutputFormatThrows) { + const std::string badFormatConfig = R"json({ + "config_version": "1.0", "device_name": "Dev", "montage_standard": "10-20", + "lsl_stream": { + "name": "runtime_test_stream", "type": "EEG", "source_id": "runtime-test-src", + "expected_channel_count": 1, "expected_sample_rate_hz": 250 + }, + "channels": [ { "index": 0, "label": "Fz", "enabled": true, "unit": "uV" } ], + "output": { "format": "parquet" } + })json"; + writeText(dir() / "config.json", badFormatConfig); + + EXPECT_THROW(Runtime(paths(), softwareRenderTargetFactory()), std::invalid_argument); +} + +TEST_F(RuntimeFixture, NullRenderTargetFactoryThrows) { + EXPECT_THROW(Runtime(paths(), Runtime::RenderTargetFactory{}), std::invalid_argument); +} + +TEST_F(RuntimeFixture, FactoryReturningNullRendererThrows) { + const auto nullFactory = [](const std::string&) -> std::shared_ptr { + return nullptr; + }; + EXPECT_THROW(Runtime(paths(), nullFactory), std::runtime_error); +} + +TEST_F(RuntimeFixture, OutputFilenameDerivedFromExperimentNameAndDir) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + // Stop the render loop immediately so run() returns after a single pass. + runtime.requestStop(); + runtime.run(); + + const fs::path output = runtime.outputPath(); + EXPECT_EQ(output.parent_path(), dir()); + // Scene project name is "TestProject" (see ParserTestUtils::buildSimpleScene). + EXPECT_EQ(output.filename().string().rfind("TestProject_", 0), 0U); + EXPECT_EQ(output.extension(), ".csv"); +} + +TEST_F(RuntimeFixture, RunWritesRecordingWithCsvHeader) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + runtime.requestStop(); + runtime.run(); + + const fs::path output = runtime.outputPath(); + ASSERT_TRUE(fs::exists(output)); + + const auto lines = readAllLines(output); + ASSERT_FALSE(lines.empty()); + EXPECT_EQ(lines.front(), "type,timestamp,payload"); +} + +TEST_F(RuntimeFixture, RunStopsOnQuitEvent) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + + // Must return promptly on SDL_QUIT even though no stop was requested. + runtime.run(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); +} + +TEST_F(RuntimeFixture, RequestStopFromAnotherThreadEndsRun) { + Runtime runtime(paths(), softwareRenderTargetFactory()); + + // Ensure no stale SDL_QUIT from a previous test ends the loop early. + SDL_FlushEvent(SDL_QUIT); + + std::thread worker([&runtime] { runtime.run(); }); + std::this_thread::sleep_for(kRenderSpinWait); + runtime.requestStop(); + worker.join(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); +} + +// Covers the production SDL window + vsync renderer factory where the platform +// can provide an accelerated renderer; skipped on headless machines that can't. +TEST_F(RuntimeFixture, DefaultRenderTargetFactoryOnCapablePlatform) { + // Let SDL pick the best available driver instead of the forced dummy one, + // which cannot provide an accelerated renderer. + unsetenv("SDL_VIDEODRIVER"); + + try { + Runtime runtime(paths(), Runtime::defaultRenderTargetFactory()); + + SDL_Event quit; + quit.type = SDL_QUIT; + ASSERT_EQ(SDL_PushEvent(&quit), 1); + runtime.run(); + + EXPECT_TRUE(fs::exists(runtime.outputPath())); + } catch (const std::exception& e) { + GTEST_SKIP() << "No accelerated render target on this platform: " << e.what(); + } +}