Skip to content
Open
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 @@ -59,3 +59,6 @@ _deps/
.Trashes
ehthumbs.db
Thumbs.db

# config files
*.json
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ 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` |
| | Output format for `DataWriter` (`output.format`) |

Consequences of the split:

Expand Down Expand Up @@ -227,12 +227,16 @@ 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 },
"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` and `output` default when
absent. 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 @@ -320,7 +324,8 @@ stay fatal — they are logged and the worker exits instead of retrying forever.
| `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, wires the queues, drives the render loop, stops workers |

The class diagram in older docs is partly aspirational; the table above reflects
the actual code.
Expand Down
87 changes: 81 additions & 6 deletions include/Runtime.hpp
Original file line number Diff line number Diff line change
@@ -1,18 +1,93 @@
#ifndef RUNTIME_HPP
#define RUNTIME_HPP

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

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

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 = "."; // directory the recorded CSV is written to
};

class Runtime {
public:
Runtime() = default;
~Runtime() = default;
using RenderTargetFactory =
std::function<std::shared_ptr<SDL_Renderer>(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;

// Drives the experiment on the calling thread until the render loop ends
// (SDL_QUIT or requestStop), then stops the workers. Single-shot: the stop
// source is never re-armed, so a second run() returns immediately.
void run();

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

// Empty until run() has computed it. Not synchronized: read it before run()
// starts or after it has returned.
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;
DeviceConfig config;

SdlSession sdlSession;

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

std::shared_ptr<SDL_Renderer> sdlRenderer;

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
3 changes: 2 additions & 1 deletion include/config/DeviceConfig.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <config/ChannelConfig.hpp>
#include <config/ConfigVersion.hpp>
#include <config/LSLConfig.hpp>
#include <config/OutputConfig.hpp>
#include <string>
#include <vector>

Expand Down Expand Up @@ -36,7 +37,7 @@ struct DeviceConfig {
GroundConfig ground; // ground
std::vector<ChannelConfig> channels; // channels
ImpedanceConfig impedance; // impedance_check
// TODO: DataWriterConfig writer; // EEG output file format strategy
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
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
2 changes: 2 additions & 0 deletions include/datawriter/CSVFormatStrategy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 Down
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
2 changes: 2 additions & 0 deletions include/datawriter/IDataFormatStrategy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
147 changes: 146 additions & 1 deletion src/Runtime.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,148 @@
#include <SDL2/SDL.h>

#include <Runtime.hpp>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <config/ConfigParser.hpp>
#include <data_structures/EEGData.hpp>
#include <data_structures/Marker.hpp>
#include <datawriter/DataFormatStrategyFactory.hpp>
#include <datawriter/DataWriter.hpp>
#include <datawriter/IDataFormatStrategy.hpp>
#include <filesystem>
#include <lslreader/LSLReader.hpp>
#include <memory>
#include <parser/Parser.hpp>
#include <renderer/Renderer.hpp>
#include <scene/Scene.hpp>
#include <stdexcept>
#include <string>
#include <utility>

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(); }

// The experiment name comes from an authored protobuf file and ends up in a file
// name, so anything that is not plainly safe becomes '_'. Left unfiltered, a name
// like "block 1/run" would resolve to a missing subdirectory (and "../x" would
// escape the output directory entirely).
std::string sanitizeForFileName(std::string name) {
if (name.empty()) {
return kFallbackName;
}

std::replace_if(
name.begin(), name.end(),
[](unsigned char character) {
return std::isalnum(character) == 0 && character != '-' && character != '_';
},
'_');
return name;
}
} // 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<SDL_Renderer> {
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<moodycamel::ConcurrentQueue<EEGData>>()),
markerQueue(std::make_shared<moodycamel::ConcurrentQueue<Marker>>()) {
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<LSLReader>(config);
dataWriter = std::make_unique<DataWriter>(std::move(formatStrategy));
renderer = std::make_unique<Renderer>(scene, sdlRenderer, markerQueue);
}

Runtime::~Runtime() { shutdown(); }

std::string Runtime::makeOutputPath() const {
const std::string name = sanitizeForFileName(scene->getExperimentName());

const auto now = std::chrono::system_clock::now().time_since_epoch();
const auto epoch = std::chrono::duration_cast<std::chrono::seconds>(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"; }
if (lslReader) {
lslReader->stop();
}
if (dataWriter) {
dataWriter->stop();
}
}
9 changes: 9 additions & 0 deletions src/config/ConfigParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ GroundConfig buildGround(const json& root) {
return ground;
}

OutputConfig buildOutput(const json& root) {
OutputConfig output;
if (root.contains("output")) {
output.format = requireField<std::string>(root.at("output"), "format", "output");
}
return output;
}

ImpedanceConfig buildImpedance(const json& root) {
ImpedanceConfig impedance;
if (root.contains("impedance_check")) {
Expand Down Expand Up @@ -176,6 +184,7 @@ DeviceConfig ConfigParser::parseStream(std::istream& stream) {
config.ground = buildGround(root);
config.channels = buildChannels(root);
config.impedance = buildImpedance(root);
config.output = buildOutput(root);

// Mapping is done; the semantic rules belong to the types themselves.
config.validate();
Expand Down
Loading
Loading