Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ _deps/
.Trashes
ehthumbs.db
Thumbs.db

# config files
*.json
64 changes: 55 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ otherwise the same experiment stops being portable between labs.
| Stimulus timing, trial structure, marker/event names | Expected stream shape: channel count, sample rate |
| Anything the editor authors and versions with the study | Channel table: index, label, enabled, unit |
| | Reference / ground electrodes, impedance check thresholds |
| | *(planned)* output file format for `DataWriter` |
| | Participant display: monitor, fullscreen, window size (`display`) |
| | Output format for `DataWriter` (`output.format`) |

Consequences of the split:

Expand All @@ -210,7 +211,7 @@ way — `channels` is a top-level key, so it is a top-level `DeviceConfig` field

```jsonc
{
"config_version": "1.0", // "MAJOR.MINOR", checked first (see below)
"config_version": "1.1", // "MAJOR.MINOR", checked first (see below)
"device_name": "OpenBCI Cyton 8ch",
"montage_standard": "10-20",
"lsl_stream": { // -> DeviceConfig::lsl (LSLConfig)
Expand All @@ -227,12 +228,23 @@ way — `channels` is a top-level key, so it is a top-level `DeviceConfig` field
{ "index": 1, "label": "Oz", "enabled": false, "unit": "microvolts" }
// ... one entry per expected_channel_count, indices unique and in range
],
"impedance_check": { "supported": true, "threshold_kohm": 5.0 }
"impedance_check": { "supported": true, "threshold_kohm": 5.0 },
"display": { // -> DeviceConfig::display; every key optional
"index": 1, // monitor facing the participant (default 0)
"fullscreen": true, // native-resolution fullscreen (default true)
"width": 1280, "height": 720 // window size, used only when not fullscreen
},
"output": { "format": "csv" } // -> DataFormatStrategyFactory, defaults to csv
}
```

`config_version`, `device_name`, `montage_standard`, `lsl_stream` and `channels`
are required; `reference`, `ground` and `impedance_check` default when absent.
are required; `reference`, `ground`, `impedance_check`, `display` and `output`
default when absent. Unlike the other optional sections, each key inside `display`
is optional on its own (`"display": { "index": 1 }` is complete). If `output` is
present it must carry a non-empty `format`; whether that
format is *known* is decided by `DataFormatStrategyFactory` when the writer is
built, not by config validation.
Channels with `"enabled": false` stay in the config (they document the cap) but
are **not** acquired: `LSLReader` drops them from every sample.

Expand Down Expand Up @@ -306,6 +318,39 @@ the re-resolved stream is re-validated (channel count, sample rate) before
acquisition continues. Config errors (mismatched stream shape, no enabled channels)
stay fatal — they are logged and the worker exits instead of retrying forever.

`LSLReader` reports where it is as a typed `AcquisitionState`: `Idle` → `Resolving`
→ `Streaming` (set on the first *queued sample*, not on resolve), back to
`Resolving` after a lost stream, or `Failed` for good. `waitWhileResolving(deadline,
stop_token)` blocks until the state changes; `failure()` holds the reason, and an
optional callback passed to `start()` fires once on the reader thread on failure.
A `Failed` state survives `stop()` so the reason can be read after joining.

### `Runtime::run()` sequence

`Runtime` builds everything that can reject the configuration (both config files,
output directory, format strategy, `LSLReader`) **before** it creates the window, so a
bad config never flashes a window at the participant. `run()` then:

1. **Acquisition gate.** Starts `LSLReader` and waits for the first EEG sample,
handling window events meanwhile. **No stimulus is shown without EEG**: a stream
that fails validation, or delivers nothing within the acquisition timeout
(default 30 s), throws; closing the window or `requestStop()` returns without
recording.
2. **Recording.** Creates `<experiment>_<YYYYMMDDTHHMMSS>[_N].<ext>` in the output
directory and runs the render loop. Format strategies must create the file
exclusively — an existing recording is never overwritten.
3. **Shutdown.** Stops `LSLReader`, then `DataWriter` (producer before consumer, so
the writer drains everything). If acquisition failed for good mid-experiment,
the failure callback has already stopped the render loop, and `run()` throws
once the recording is closed.

`run()` is single-shot and must be called on the thread that constructed the
`Runtime` (SDL handles window events only there); both are enforced with
`std::logic_error`. The default render target opens the window on
`display.index` and **refuses to run without vsync** — SDL silently drops
`PRESENTVSYNC` when the driver can't honour it, and marker timestamps are only
correct when `SDL_RenderPresent` waits for the refresh.

## 7. Implementation status

| Area / class | Status | Notes |
Expand All @@ -316,11 +361,12 @@ stay fatal — they are logged and the worker exits instead of retrying forever.
| `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); driven by `DeviceConfig`, enabled channels only, re-resolves lost streams |
| `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `DeviceConfig`, enabled channels only, re-resolves lost streams, reports `AcquisitionState` |
| `ConfigParser` | Implemented | `config.json` → `DeviceConfig` (1:1 mapping, major-version checked, see §5), nlohmann/json |
| Config `validate()` | Implemented | semantic rules on the config types themselves, independent of JSON (see §5) |
| `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` |
| `Runtime` orchestration | **Stub** | currently does nothing |
| `DataFormatStrategyFactory` | Implemented | `output.format` → format strategy; unknown formats rejected |
| `Runtime` orchestration | Implemented | owns SDL session, parses both config files, gates the experiment on live EEG, records to a fresh file, drives the render loop, stops workers (see §6) |

The class diagram in older docs is partly aspirational; the table above reflects
the actual code.
Expand Down Expand Up @@ -370,7 +416,7 @@ sudo apt install cmake clang-format clang-tidy libsdl2-dev protobuf-compiler gco
```bash
cmake -B build
cmake --build build
./build/src/NeuronIDE config.json experiment.neuroz # parses the device config, parses scene, starts LSLReader, DataWriter, Renderer.
./build/src/NeuronIDE config.json experiment.neuroz # waits for the EEG stream, then records and runs the experiment
```

`NeuronIDE` takes the path to a device `config.json` (defaults to `config.json` in
Expand All @@ -385,8 +431,8 @@ ctest -L unit --output-on-failure # unit tests only
ctest -L component --output-on-failure
```

> Note: `LSLReader` unit tests open a local LSL stream and exercise a real
> outlet→inlet round-trip over loopback; they need loopback multicast to be
> Note: `LSLReader` and `Runtime` unit tests open local LSL streams and exercise a
> real outlet→inlet round-trip over loopback; they need loopback multicast to be
> available.

### Formatting & static analysis
Expand Down
123 changes: 117 additions & 6 deletions include/Runtime.hpp
Original file line number Diff line number Diff line change
@@ -1,18 +1,129 @@
#ifndef RUNTIME_HPP
#define RUNTIME_HPP

#include <iostream>
#include <concurrentqueue.h>

#include <chrono>
#include <config/DeviceConfig.hpp>
#include <functional>
#include <memory>
#include <stop_token>
#include <string>
#include <thread>

class Scene;
class LSLReader;
class DataWriter;
class Renderer;
struct EEGData;
struct Marker;

// Tag matches the other forward declarations in include/ (Scene, SceneObject,
// Component). SDL declares it as a struct, so all four are technically
// mismatched; changing one alone trips -Wmismatched-tags.
class SDL_Renderer;

struct RuntimePaths {
std::string config; // device config.json
std::string experiment; // serialized experiment scene (protobuf)
std::string outputDir = "."; // existing directory the recording is written to
};

class Runtime {
public:
Runtime() = default;
~Runtime() = default;
using RenderTargetFactory = std::function<std::shared_ptr<SDL_Renderer>(
const std::string& windowTitle, const DisplayConfig& display)>;

// How long run() waits for the first EEG sample before giving up.
static constexpr std::chrono::seconds kDefaultAcquisitionTimeout{30};

// Parses and validates both input files and builds every worker before the
// render target is created, so a bad configuration fails without opening a
// window. The constructing thread becomes the only thread allowed to call
// run().
explicit Runtime(const RuntimePaths& paths);
Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory,
std::chrono::milliseconds acquisitionTimeout = kDefaultAcquisitionTimeout);
~Runtime();

Runtime(const Runtime&) = delete;
Runtime& operator=(const Runtime&) = delete;
Runtime(Runtime&&) = delete;
Runtime& operator=(Runtime&&) = delete;
static void start();
Runtime& operator=(Runtime&&) = delete;

// Runs the experiment on the calling thread, which must be the thread that
// constructed this Runtime: SDL window events can only be handled there.
//
// 1. Starts EEG acquisition and waits for the first sample. The experiment
// never starts without EEG: a stream that fails validation, or delivers
// nothing within the acquisition timeout, throws std::runtime_error.
// Closing the window or requestStop() during the wait returns without
// recording anything.
// 2. Creates a new recording file (an existing file is never overwritten)
// and renders until SDL_QUIT or requestStop().
// 3. Stops the workers. If acquisition failed for good during the
// experiment, rendering stops at once, the recording is closed, and
// run() throws std::runtime_error.
//
// Single-shot: throws std::logic_error when called a second time or from
// the wrong thread.
void run();

// Safe to call from any thread, including while run() is in progress.
void requestStop();

// Path of the recording, or empty if run() has not started one (yet, or
// at all). Not synchronized: read it before run() starts or after it has
// returned.
const std::string& outputPath() const noexcept { return outputFilePath; }

// Opens a window on display.index (fullscreen or display.width x height)
// with an accelerated renderer, and throws std::runtime_error unless that
// renderer actually presents in sync with the display refresh. Marker
// timestamps are only correct when it does.
static RenderTargetFactory defaultRenderTargetFactory();

private:
struct SdlSession {
SdlSession();
~SdlSession();

SdlSession(const SdlSession&) = delete;
SdlSession& operator=(const SdlSession&) = delete;
SdlSession(SdlSession&&) = delete;
SdlSession& operator=(SdlSession&&) = delete;
};

bool awaitAcquisition();
void startRecording();
void shutdown();
[[noreturn]] void throwAcquisitionFailure(const std::string& phase) const;
[[nodiscard]] std::string makeOutputPath() const;

RuntimePaths paths;
DeviceConfig config;
std::chrono::milliseconds acquisitionTimeout;
std::thread::id ownerThread;
bool hasRun = false;

SdlSession sdlSession;

// Declared before `scene` on purpose: members are destroyed in reverse
// order, so the scene - and any SDL_Texture its components hold - is
// released while the SDL_Renderer that owns those textures is still alive.
std::shared_ptr<SDL_Renderer> sdlRenderer;

std::shared_ptr<Scene> scene;
std::shared_ptr<moodycamel::ConcurrentQueue<EEGData>> eegQueue;
std::shared_ptr<moodycamel::ConcurrentQueue<Marker>> markerQueue;

std::unique_ptr<LSLReader> lslReader;
std::unique_ptr<DataWriter> dataWriter;
std::unique_ptr<Renderer> renderer;

std::string outputExtension;
std::string outputFilePath;
std::stop_source stopSource;
};

#endif // RUNTIME_HPP
#endif // RUNTIME_HPP
9 changes: 6 additions & 3 deletions include/config/DeviceConfig.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

#include <config/ChannelConfig.hpp>
#include <config/ConfigVersion.hpp>
#include <config/DisplayConfig.hpp>
#include <config/LSLConfig.hpp>
#include <config/OutputConfig.hpp>
#include <string>
#include <vector>

Expand All @@ -24,8 +26,8 @@ struct ImpedanceConfig {
void validate() const;
};

// Describes the acquisition hardware (the cap and its LSL stream), not the
// experiment - experiment content lives in the protobuf file. Mirrors
// Describes the lab hardware (the cap, its LSL stream, the participant's
// display), not the experiment - experiment content lives in the protobuf file. Mirrors
// `config.json` 1:1: every JSON key maps onto exactly one field below.
struct DeviceConfig {
ConfigVersion configVersion; // config_version
Expand All @@ -36,7 +38,8 @@ struct DeviceConfig {
GroundConfig ground; // ground
std::vector<ChannelConfig> channels; // channels
ImpedanceConfig impedance; // impedance_check
// TODO: DataWriterConfig writer; // EEG output file format strategy
DisplayConfig display; // display
OutputConfig output; // output

// Checks every rule a device config must satisfy, including the cross-field
// ones no single member can check (channel count matching the stream, unique
Expand Down
22 changes: 22 additions & 0 deletions include/config/DisplayConfig.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#ifndef DISPLAYCONFIG_HPP
#define DISPLAYCONFIG_HPP

// The screen the participant looks at (`display` in `config.json`). It belongs
// to the lab setup, not the experiment: the same experiment file runs on
// whichever monitor faces the participant at this particular machine.
struct DisplayConfig {
static constexpr int kDefaultWindowWidth = 1280;
static constexpr int kDefaultWindowHeight = 720;

int index = 0; // display.index: monitor the stimulus window opens on
bool fullscreen = true; // display.fullscreen: native-resolution fullscreen
int width = kDefaultWindowWidth; // display.width: window size, windowed mode only
int height = kDefaultWindowHeight; // display.height

// Throws std::invalid_argument on a negative index or a non-positive window
// size. Whether `index` names a connected monitor can only be checked when
// the window is created.
void validate() const;
};

#endif // DISPLAYCONFIG_HPP
18 changes: 18 additions & 0 deletions include/config/OutputConfig.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#ifndef OUTPUTCONFIG_HPP
#define OUTPUTCONFIG_HPP

#include <string>

// 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";

// Throws std::invalid_argument on an empty format. Whether a non-empty
// format is *known* is DataFormatStrategyFactory's decision, so that check
// stays out of the config layer.
void validate() const;
};

#endif // OUTPUTCONFIG_HPP
6 changes: 4 additions & 2 deletions include/datawriter/CSVFormatStrategy.hpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#ifndef CSVFORMATSTRATEGY_HPP
#define CSVFORMATSTRATEGY_HPP

#include <datawriter/ExclusiveOutputFile.hpp>
#include <datawriter/IDataFormatStrategy.hpp>
#include <fstream>
#include <string>

class CSVFormatStrategy : public IDataFormatStrategy {
Expand All @@ -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;

Expand All @@ -23,7 +25,7 @@ class CSVFormatStrategy : public IDataFormatStrategy {
void writeMarker(const Marker& marker) override;

private:
std::ofstream outputFile;
ExclusiveOutputFile outputFile;
};

#endif // CSVFORMATSTRATEGY_HPP
18 changes: 18 additions & 0 deletions include/datawriter/DataFormatStrategyFactory.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#ifndef DATAFORMATSTRATEGYFACTORY_HPP
#define DATAFORMATSTRATEGYFACTORY_HPP

#include <datawriter/IDataFormatStrategy.hpp>
#include <memory>
#include <string>

// 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<IDataFormatStrategy> create(const std::string& format);
};

#endif // DATAFORMATSTRATEGYFACTORY_HPP
Loading