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
49 changes: 34 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The CLI provides a lightweight alternative to the GUI:
- `--cmd-port <port>` - Command port (default: 9877)
- `--data-port <port>` - Data port (default: 9879)
- `--amp-id <id>` - Amplifier ID (default: 0)
- `--sample-rate <hz>` - Sample rate in Hz (default: 1000). Forces amplifier to this rate if already running at a different rate. Valid rates: 250, 500, 1000 (decimated) or 500, 1000, 2000, 4000, 8000 (native).
- `--sample-rate <hz>` - Sample rate in Hz (default: 1000). Forces amplifier to this rate if already running at a different rate. Valid rates depend on the amplifier model — see [Sample Rate Modes](#sample-rate-modes). Unsupported combinations are rejected with an error before the amplifier is touched.
- `--fast-recovery` - Use native rate mode (no FPGA anti-alias filter) for lower latency. See [Sample Rate Modes](#sample-rate-modes).
- `--impedance` - Enable impedance testing mode
- `--native-format` - Transmit raw int32 ADC counts instead of float microvolts
Expand Down Expand Up @@ -92,15 +92,15 @@ If the amplifier is stopped or restarted externally while the CLI is streaming:

## Sample Rate Modes

The NA400/NA410 amplifiers support two operating modes that affect anti-aliasing and latency:
The NA400/NA410/NA500 amplifiers support two operating modes that affect anti-aliasing and latency:

### Decimated Mode (Default)

Uses the FPGA's digital anti-aliasing filter to downsample from the ADC's native rate. This provides:
- Better frequency response (~400 Hz bandwidth at 1000 Hz sample rate)
- Higher latency due to the filter group delay (36-111 ms depending on rate), which the app compensates for automatically — see [Timestamp Compensation](#timestamp-compensation)
- On the NA400, higher latency due to the filter group delay (36-111 ms depending on rate), which the app compensates for automatically — see [Timestamp Compensation](#timestamp-compensation)

Available decimated rates: 250, 500, 1000 Hz
Available decimated rates: 250, 500, 1000 Hz (NA400/NA410); 250, 500, 1000, 2000 Hz (NA500)

### Native Mode (Fast Recovery)

Expand All @@ -109,20 +109,37 @@ Bypasses the FPGA filter and samples directly at the requested rate. This provid
- Reduced bandwidth (~1/4 of sample rate, e.g., 250 Hz at 1000 Hz sample rate)
- Optimized for EEG-TMS and real-time BCI applications

Available native rates: 500, 1000, 2000, 4000, 8000 Hz
Available native rates: 500, 1000, 2000, 4000, 8000 Hz (NA400); 250, 500, 1000, 2000, 4000, 8000, 16000 Hz (NA500)

Use `--fast-recovery` to enable native mode for rates that support both modes (500, 1000 Hz).
Use `--fast-recovery` (or a "Low Latency" dropdown entry) to select native mode for rates where the model offers both.

The GUI dropdown shows the union of both models' rates; the client validates the selection against the amplifier's actual model right after it identifies itself and refuses unsupported combinations with a clear error.

### Filter Delay by Sample Rate

DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only), measured with `scripts/delay_capture_sweep.py`:
DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only). NA400 values measured with `scripts/delay_capture_sweep.py`:

| Amp | Mode | Sample Rate | Filter delay (ms) | (samples) |
|-----|------|-------------|-------------------|-----------|
| NA400 | Decimated | 250 Hz | 111 | ~28 |
| NA400 | Decimated | 500 Hz | 61 | ~30 |
| NA400 | Decimated | 1000 Hz | 36 | 36 |
| NA400 | Native | 500-8000 Hz | 0 | 0 |
| NA500 | any | all rates | 0 | 0 |

The NA500 rows are zero deliberately: Net Station 6 applies no anti-alias alignment shift for the NA500 (verified against Net Station 6.0.0.60960), so this app matches that behavior. If a real NA500 filter delay is ever measured, update `resources/sampling_rates.json`.

| Mode | Sample Rate | Filter delay (ms) | (samples) |
|------|-------------|-------------------|-----------|
| Decimated | 250 Hz | 111 | ~28 |
| Decimated | 500 Hz | 61 | ~30 |
| Decimated | 1000 Hz | 36 | 36 |
| Native | 500-8000 Hz | 0 | 0 |
## Net Amps 500 (GES 500) Support

NA500 support was verified against real hardware (firmware 0.4.9). Differences from the NA400 that this app handles automatically:

- **Scaling**: 0.044703 µV/bit (±375 mV over 24 bits, matching the GES 500 datasheet and Net Station 6).
- **Rates**: decimated up to 2000 Hz; native from 250 Hz up to 16000 Hz. No duplicate packets are sent at low rates (the NA400 duplicates samples below 1000 Hz).
- **Initialization**: the NA500 silently ignores commands sent while it is powering up (~10 s from cold), so the app waits for the power-on notification before configuring it.
- **Data connection handling**: the NA500's AmpServer stops serving data to new listeners once any listener disconnects (until the amp restarts), so the app maintains one persistent data connection per session and never issues `cmd_StopListeningToAmp`.
- **Single client**: while Net Station (or any other client) is linked to the NA500, this app's connections are accepted but not serviced — and vice versa. Unlink Net Station before using this app; concurrent monitoring is not possible on the NA500 (unlike the NA400).
- **Recovery**: the NA500 emits no `ntn_AmpStarted` notification, so external-restart recovery uses data-port polling instead.
- **Robustness**: the NA500's embedded AmpServer (fw 0.4.9) can crash or stop responding after abrupt client disconnects; commands now time out after 10 s instead of hanging, and the GUI performs linking on a background thread so the UI never freezes. A crashed AmpServer requires power-cycling the amplifier.

## Timestamp Compensation

Expand Down Expand Up @@ -381,7 +398,9 @@ The amplifier's internal DIN ADC samples at a fixed 1 kHz rate, regardless of th

The NA400/NA410 amplifiers have a 16-bit digital I/O port with active-low inputs (internal pull-ups). When no trigger device is connected, all lines are pulled high and the DIN stream reports `0` (idle). Grounding a line activates the corresponding bit.

The `cmd_SetDigitalInOutDirection` command can configure specific bits for output if needed (consult EGI documentation).
The NA500's DIN register idles at raw `0` (the NA400 idles at raw `0xFFFF`); its input polarity has not yet been verified with a working trigger source. Its BNC "sync in" connector expects TTL-level pulses (a line-level audio output is not sufficient to drive it).

The `cmd_SetDigitalInOutDirection` command can configure specific bits for output if needed on the NA400 (consult EGI documentation). Do **not** send it to an NA500 — firmware 0.4.9 crashed when it was issued during testing.

# Acknowledgements
This application was written to behave near-identically to the BCI2000 AmpServer module that was originally created by EGI.
Expand Down Expand Up @@ -466,4 +485,4 @@ After Net Station shuts down the amplifier (via "Shutdown" command), immediately

## Sample Rate Auto-Detection

The app automatically detects the sample rate when connecting to an already-running amplifier by measuring packet timing. This detection snaps to standard rates (250, 500, or 1000 Hz). If the amplifier is idle when connecting, the app uses the sample rate configured in the UI/config file.
The app automatically detects the sample rate when connecting to an already-running amplifier by measuring packet timing. This detection snaps to standard rates (250 Hz up to 16000 Hz). If the amplifier is idle when connecting, the app uses the sample rate configured in the UI/config file.
2 changes: 1 addition & 1 deletion mock/AmpServerAPI.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"NA300": { "id": 0, "name": "Net Amps 300", "scaling_factor": 0.0244140625 },
"NA400": { "id": 1, "name": "Net Amps 400", "scaling_factor": 0.000155220429 },
"NA410": { "id": 2, "name": "Net Amps 410", "scaling_factor": 0.00009636188 },
"NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 1.0 },
"NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 0.04470348358154297 },
"GTEN200": { "id": 1, "name": "NA GTEN 200", "scaling_factor": 0.000155220429 }
},
"net_codes": {
Expand Down
3 changes: 3 additions & 0 deletions mock/include/AmpServerProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ inline float getScalingFactor(AmplifierType type) {
return 0.000155220429f;
case AmplifierType::NA410:
return 0.00009636188f;
case AmplifierType::NA500:
// 0.75 V range / 2^24 codes in uV (Net Station 6 NA500ScaleFactor)
return 750000.0f / 16777216.0f;
default:
return 1.0f;
}
Expand Down
13 changes: 11 additions & 2 deletions mock/src/CommandHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,17 @@ std::string CommandHandler::cmdGetAmpDetails(int64_t ampId, int16_t channel, con
<< "(amp_type " << amplifierTypeName(state.amplifierType) << ") "
<< "(legacy_board " << (state.legacyBoard ? "true" : "false") << ") "
<< "(packet_format " << static_cast<int>(state.packetFormat) << ") "
<< "(system_version " << state.firmwareVersion << ") "
<< "(number_of_channels " << state.channelCount << "))";
<< "(system_version " << state.firmwareVersion << ") ";
if (state.amplifierType == AmplifierType::NA500) {
// Extra tokens the real NA500 (fw 0.4.9) reports
oss << "(fpga_version 0.6.1.f0) "
<< "(pcb_version 4.1.0.0) ";
}
oss << "(number_of_channels " << state.channelCount << ")";
if (state.amplifierType == AmplifierType::NA500) {
oss << " (feature_flags 1)";
}
oss << ")";
return responseWithData(oss.str());
}

Expand Down
9 changes: 8 additions & 1 deletion mock/src/MockAmplifier.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,15 @@ bool MockAmplifier::setNativeRate(int rate) {
// Real amplifier validates native rate
// NA400: 500, 1000, 2000, 4000, 8000
// NA410: 20000 only
// NA500: 250, 500, 1000, 2000, 4000, 8000, 16000 (verified on hardware;
// its AmpServer answers "complete" regardless, but only these stream)
if (state_.amplifierType == AmplifierType::NA410) {
if (rate != 20000) return false;
} else if (state_.amplifierType == AmplifierType::NA500) {
if (rate != 250 && rate != 500 && rate != 1000 && rate != 2000 &&
rate != 4000 && rate != 8000 && rate != 16000) {
return false;
}
} else {
if (rate != 500 && rate != 1000 && rate != 2000 &&
rate != 4000 && rate != 8000) {
Expand All @@ -133,7 +140,7 @@ bool MockAmplifier::setNativeRate(int rate) {
bool MockAmplifier::setDecimatedRate(int rate) {
std::lock_guard<std::mutex> lock(mutex_);
// Real amplifier accepts any rate value without validation
// Valid rates: 250, 500, 1000
// Valid rates: 250, 500, 1000 (NA400); NA500 additionally supports 2000
state_.decimatedRate = rate;
state_.decimated = true; // decimated mode: FPGA filter introduces EEG/physio skew
return true;
Expand Down
4 changes: 4 additions & 0 deletions resources/amplifier_scaling.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"microvolt_scale": 0.00009636188,
"description": "Net Amps 410 series"
},
"NA500": {
"microvolt_scale": 0.04470348358154297,
"description": "Net Amps 500 series — 0.75 V total input range / 2^24 codes, in µV. Extracted 2026-08-31 from Net Station 6.0.0.60960 libEgiDataSource.dll PhysicalAmp::NA500ScaleFactor() (computes rangeVolts 0.75 / 2^24 * 1e6; a Software\\Magstim\\AmpScaleFactor registry value can override it in Net Station)."
},
"TI": {
"microvolt_scale": 0.02384185791016,
"description": "TI-based amplifier"
Expand Down
46 changes: 46 additions & 0 deletions resources/sampling_rates.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,52 @@
"8000": 0
}
},
"NA500": {
"_note": "Rates verified against hardware on 2026-08-31 (serial 0007, system_version 0.4.9, fpga_version 0.6.1.f0). Unlike the NA400, the NA500 sends no duplicate packets at sub-1000 Hz decimated rates, and its native mode extends down to 250 Hz and up to 16000 Hz.",
"_delay_note": "All shifts 0 = no timestamp compensation in decimated mode. This matches Net Station 6.0.0.60960: libEgiDataSource.dll PhysicalAmp::calculateShift() applies anti-alias shifts only for NA400 (112/66/36 ms eeg, 80/28/3 ms pns) and NA410 (76/34/13, 64/24/3) and leaves the NA500 at zero (verified by decompilation 2026-08-31). If a real DIN->EEG delay is ever measured for NA500 decimated mode (scripts/delay_capture_sweep.py), update these values.",
"available_rates": [250, 500, 1000, 2000],
"fast_recovery_rates": [250, 500, 1000, 2000, 4000, 8000, 16000],
"source_rate": 1000,
"eeg_shift_msec": {
"250": 0,
"500": 0,
"1000": 0,
"2000": 0,
"250_fast_recovery": 0,
"500_fast_recovery": 0,
"1000_fast_recovery": 0,
"2000_fast_recovery": 0,
"4000": 0,
"8000": 0,
"16000": 0
},
"pns_shift_msec": {
"250": 0,
"500": 0,
"1000": 0,
"2000": 0,
"250_fast_recovery": 0,
"500_fast_recovery": 0,
"1000_fast_recovery": 0,
"2000_fast_recovery": 0,
"4000": 0,
"8000": 0,
"16000": 0
},
"pns_diff_msec": {
"250": 0,
"500": 0,
"1000": 0,
"2000": 0,
"250_fast_recovery": 0,
"500_fast_recovery": 0,
"1000_fast_recovery": 0,
"2000_fast_recovery": 0,
"4000": 0,
"8000": 0,
"16000": 0
}
},
"NA410": {
"available_rates": [250, 500, 1000],
"fast_recovery_rates": [1000],
Expand Down
5 changes: 3 additions & 2 deletions src/core/include/egiamp/AmpServerConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ struct AmpServerConfig {

/// Suffix for LSL source ID to distinguish native/decimated mode and timestamp alignment.
/// Ensures LSL consumers won't auto-reconnect across incompatible configurations.
std::string modeSuffix() const {
const bool native = sampleRate > 1000 || (fastRecovery && sampleRate >= 500);
/// The caller decides `native` via usesNativeRate(), which needs the amp model
/// (rate/mode capabilities differ between NA400 and NA500).
std::string modeSuffix(bool native) const {
// Decimated streams are always alignment-corrected, so "_decimated"
// already implies aligned — no separate suffix needed.
return native ? "_native" : "_decimated";
Expand Down
56 changes: 56 additions & 0 deletions src/core/include/egiamp/AmpServerProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define EGIAMP_AMPSERVERPROTOCOL_H

#include <cstdint>
#include <initializer_list>

namespace egiamp {

Expand Down Expand Up @@ -136,11 +137,66 @@ inline float getScalingFactor(AmplifierType type) {
return 0.00015522042f;
case AmplifierType::NA410:
return 0.00009636188f;
case AmplifierType::NA500:
// 0.75 V total input range over 2^24 codes (24-bit), in microvolts.
// Extracted from Net Station 6.0.0.60960's
// libEgiDataSource.dll PhysicalAmp::NA500ScaleFactor(), which
// computes exactly rangeVolts(0.75) / 2^24 * 1e6.
return 750000.0f / 16777216.0f; // 0.044703484 uV per count
default:
return 1.0f;
}
}

// Rate/mode capabilities per model, verified against hardware where possible.
// NA400 (measured): decimated 250/500/1000; native 500/1000/2000/4000/8000.
// NA500 (measured, fw 0.4.9): decimated 250/500/1000/2000; native
// 250/500/1000/2000/4000/8000/16000. Unlike the NA400, the NA500 never sends
// duplicate packets at sub-1000 Hz decimated rates.
inline int maxDecimatedRate(AmplifierType type) {
return type == AmplifierType::NA500 ? 2000 : 1000;
}

inline int minNativeRate(AmplifierType type) {
return type == AmplifierType::NA500 ? 250 : 500;
}

// Whether the given configuration runs the amp in native mode (no FPGA
// anti-alias filter) as opposed to decimated mode. Rates above the model's
// decimated maximum are necessarily native; otherwise fastRecovery selects
// native where the model supports it.
inline bool usesNativeRate(AmplifierType type, int sampleRate, bool fastRecovery) {
return sampleRate > maxDecimatedRate(type) ||
(fastRecovery && sampleRate >= minNativeRate(type));
}

// Whether the model supports the (rate, fastRecovery) combination, in the
// mode usesNativeRate() would select for it. Unknown models are permissive.
inline bool supportsSampleRate(AmplifierType type, int sampleRate, bool fastRecovery) {
auto contains = [sampleRate](std::initializer_list<int> rates) {
for (int r : rates) if (r == sampleRate) return true;
return false;
};
const bool native = usesNativeRate(type, sampleRate, fastRecovery);
switch (type) {
case AmplifierType::NA300:
// Decimated only; no native mode exists.
return !native && contains({125, 250, 500, 1000});
case AmplifierType::NA400:
return native ? contains({500, 1000, 2000, 4000, 8000})
: contains({250, 500, 1000});
case AmplifierType::NA410:
return native ? contains({20000})
: contains({250, 500, 1000});
case AmplifierType::NA500:
// Verified on hardware (fw 0.4.9)
return native ? contains({250, 500, 1000, 2000, 4000, 8000, 16000})
: contains({250, 500, 1000, 2000});
default:
return true;
}
}

// Physio16 scaling factors (from EGI documentation)
// Channels 1-8 use negative scaling, channels 9-16 use positive scaling
constexpr float PHYSIO_SCALING_1_8 = -0.00111758708f; // PIB channels 1-8
Expand Down
6 changes: 6 additions & 0 deletions src/core/include/egiamp/EGIAmpClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class EGIAmpClient {
void emitSensor(NetCode code) const;

bool queryAmplifierDetails();
bool waitForAmpPowerOn(int timeoutSeconds);
void ensureListening();
bool isAmplifierStreaming();
int detectSampleRate(); // Returns detected sample rate, or 0 if detection failed
bool initAmplifier();
Expand Down Expand Up @@ -132,6 +134,10 @@ class EGIAmpClient {
int detectedSampleRate_{0}; // Sample rate detected from running amp
NetCode detectedNetCode_{NetCode::Unknown}; // Sensor net code detected from running amp
int physioConnectionStatus_{0}; // 0=none, 1=port1(16ch), 2=port2(16ch), 3=both(32ch)
// Whether cmd_ListenToAmp has been sent on the current data connection.
// Used by the NA500 code paths, which keep one persistent listening
// connection for the whole session (see ensureListening()).
bool dataListening_{false};
std::unique_ptr<std::thread> readerThread_;
std::unique_ptr<std::thread> notificationThread_;

Expand Down
11 changes: 9 additions & 2 deletions src/core/src/AmpServerConfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ namespace egiamp {
std::string AmpServerConfig::streamName() const {
if (serverAddress == "localhost" || serverAddress.compare(0, 4, "127.") == 0)
return "EGINetAmp_mock";
if (serverAddress.size() >= 3 && serverAddress.compare(serverAddress.size() - 3, 3, ".52") == 0)
return "EGINetAmp_52";
// Use the last octet of the address so amps at non-default addresses
// (e.g. .62) get distinct stream names. .51/.52 keep their historic names.
const auto lastDot = serverAddress.rfind('.');
if (lastDot != std::string::npos && lastDot + 1 < serverAddress.size()) {
const std::string octet = serverAddress.substr(lastDot + 1);
if (!octet.empty() && octet.find_first_not_of("0123456789") == std::string::npos) {
return "EGINetAmp_" + octet;
}
}
return "EGINetAmp_51";
}

Expand Down
Loading
Loading