Skip to content

Commit 6eb5b99

Browse files
cboulayclaude
andcommitted
Add Net Amps 500 (GES 500) support
Verified against real NA500 hardware (fw 0.4.9, serial 0007) and Net Station 6.0.0.60960. NA400 behavior is unchanged: model-specific logic is gated on the amplifier type reported by cmd_GetAmpDetails. Protocol fixes (benefit all models): - Send each command in a single TCP write. The NA500's AmpServer parses every TCP segment as a complete message, so commands split across segments (which multiple stream insertions could produce) desynced its request/response handling. Also resync by skipping stale non-response lines, and bound the response wait at 10 s so a hung AmpServer can no longer freeze the client forever. - GUI links on a background thread with progress via queued signals; the UI stays responsive through the NA500's ~10 s cold power-on wait and shows a clean error if the server is unresponsive. NA500-specific handling: - Identify NA500 from amp_type; scaling factor 0.044703 uV/bit (0.75 V / 2^24), confirmed by the GES 500 datasheet, Net Station 6's NA500ScaleFactor(), and a square-wave calibration measurement. - Wait for ntn_AmpPowerOn before configuring: the NA500 silently drops commands issued during power-up. - Keep one persistent listening data connection per session and never send cmd_StopListeningToAmp: the NA500's AmpServer stops serving new listeners after any listener drop (until amp restart) and wedges the session outright on StopListening. - Poll the data port for passive recovery: the NA500 emits no ntn_AmpStarted notification. - Rate tables: decimated 250-2000 Hz, native 250-16000 Hz, no duplicate packets at low rates (all verified streaming). Rate detection and rate-change snapping extended to 16 kHz; detection bounded by measured duration and robust to stale buffered samples. - Zero anti-alias filter delay compensation, matching Net Station 6 (its calculateShift() applies no shift for the NA500). GUI: - Sample-rate dropdown now offers the NA400+NA500 union (adds 250 Hz low-latency, 2000 Hz decimated/low-latency split, 16000 Hz) with "NA500 only" labels; selections are validated against the identified model at link time (supportsSampleRate) and refused with a clear error before any command touches the amp. - LSL stream names derive from the address's last octet (.51/.52 keep their historic names). Mock server emulates NA500 rate validation and details response. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 88ab8d3 commit 6eb5b99

15 files changed

Lines changed: 659 additions & 141 deletions

README.md

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The CLI provides a lightweight alternative to the GUI:
3333
- `--cmd-port <port>` - Command port (default: 9877)
3434
- `--data-port <port>` - Data port (default: 9879)
3535
- `--amp-id <id>` - Amplifier ID (default: 0)
36-
- `--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).
36+
- `--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.
3737
- `--fast-recovery` - Use native rate mode (no FPGA anti-alias filter) for lower latency. See [Sample Rate Modes](#sample-rate-modes).
3838
- `--impedance` - Enable impedance testing mode
3939
- `--native-format` - Transmit raw int32 ADC counts instead of float microvolts
@@ -92,15 +92,15 @@ If the amplifier is stopped or restarted externally while the CLI is streaming:
9292

9393
## Sample Rate Modes
9494

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

9797
### Decimated Mode (Default)
9898

9999
Uses the FPGA's digital anti-aliasing filter to downsample from the ADC's native rate. This provides:
100100
- Better frequency response (~400 Hz bandwidth at 1000 Hz sample rate)
101-
- 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)
101+
- 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)
102102

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

105105
### Native Mode (Fast Recovery)
106106

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

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

114-
Use `--fast-recovery` to enable native mode for rates that support both modes (500, 1000 Hz).
114+
Use `--fast-recovery` (or a "Low Latency" dropdown entry) to select native mode for rates where the model offers both.
115+
116+
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.
115117

116118
### Filter Delay by Sample Rate
117119

118-
DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only), measured with `scripts/delay_capture_sweep.py`:
120+
DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only). NA400 values measured with `scripts/delay_capture_sweep.py`:
121+
122+
| Amp | Mode | Sample Rate | Filter delay (ms) | (samples) |
123+
|-----|------|-------------|-------------------|-----------|
124+
| NA400 | Decimated | 250 Hz | 111 | ~28 |
125+
| NA400 | Decimated | 500 Hz | 61 | ~30 |
126+
| NA400 | Decimated | 1000 Hz | 36 | 36 |
127+
| NA400 | Native | 500-8000 Hz | 0 | 0 |
128+
| NA500 | any | all rates | 0 | 0 |
129+
130+
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`.
119131

120-
| Mode | Sample Rate | Filter delay (ms) | (samples) |
121-
|------|-------------|-------------------|-----------|
122-
| Decimated | 250 Hz | 111 | ~28 |
123-
| Decimated | 500 Hz | 61 | ~30 |
124-
| Decimated | 1000 Hz | 36 | 36 |
125-
| Native | 500-8000 Hz | 0 | 0 |
132+
## Net Amps 500 (GES 500) Support
133+
134+
NA500 support was verified against real hardware (firmware 0.4.9). Differences from the NA400 that this app handles automatically:
135+
136+
- **Scaling**: 0.044703 µV/bit (±375 mV over 24 bits, matching the GES 500 datasheet and Net Station 6).
137+
- **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).
138+
- **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.
139+
- **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`.
140+
- **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).
141+
- **Recovery**: the NA500 emits no `ntn_AmpStarted` notification, so external-restart recovery uses data-port polling instead.
142+
- **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.
126143

127144
## Timestamp Compensation
128145

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

382399
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.
383400

384-
The `cmd_SetDigitalInOutDirection` command can configure specific bits for output if needed (consult EGI documentation).
401+
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).
402+
403+
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.
385404

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

467486
## Sample Rate Auto-Detection
468487

469-
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.
488+
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.

mock/AmpServerAPI.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"NA300": { "id": 0, "name": "Net Amps 300", "scaling_factor": 0.0244140625 },
1818
"NA400": { "id": 1, "name": "Net Amps 400", "scaling_factor": 0.000155220429 },
1919
"NA410": { "id": 2, "name": "Net Amps 410", "scaling_factor": 0.00009636188 },
20-
"NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 1.0 },
20+
"NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 0.04470348358154297 },
2121
"GTEN200": { "id": 1, "name": "NA GTEN 200", "scaling_factor": 0.000155220429 }
2222
},
2323
"net_codes": {

mock/include/AmpServerProtocol.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ inline float getScalingFactor(AmplifierType type) {
145145
return 0.000155220429f;
146146
case AmplifierType::NA410:
147147
return 0.00009636188f;
148+
case AmplifierType::NA500:
149+
// 0.75 V range / 2^24 codes in uV (Net Station 6 NA500ScaleFactor)
150+
return 750000.0f / 16777216.0f;
148151
default:
149152
return 1.0f;
150153
}

mock/src/CommandHandler.cpp

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,17 @@ std::string CommandHandler::cmdGetAmpDetails(int64_t ampId, int16_t channel, con
378378
<< "(amp_type " << amplifierTypeName(state.amplifierType) << ") "
379379
<< "(legacy_board " << (state.legacyBoard ? "true" : "false") << ") "
380380
<< "(packet_format " << static_cast<int>(state.packetFormat) << ") "
381-
<< "(system_version " << state.firmwareVersion << ") "
382-
<< "(number_of_channels " << state.channelCount << "))";
381+
<< "(system_version " << state.firmwareVersion << ") ";
382+
if (state.amplifierType == AmplifierType::NA500) {
383+
// Extra tokens the real NA500 (fw 0.4.9) reports
384+
oss << "(fpga_version 0.6.1.f0) "
385+
<< "(pcb_version 4.1.0.0) ";
386+
}
387+
oss << "(number_of_channels " << state.channelCount << ")";
388+
if (state.amplifierType == AmplifierType::NA500) {
389+
oss << " (feature_flags 1)";
390+
}
391+
oss << ")";
383392
return responseWithData(oss.str());
384393
}
385394

mock/src/MockAmplifier.cpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,15 @@ bool MockAmplifier::setNativeRate(int rate) {
117117
// Real amplifier validates native rate
118118
// NA400: 500, 1000, 2000, 4000, 8000
119119
// NA410: 20000 only
120+
// NA500: 250, 500, 1000, 2000, 4000, 8000, 16000 (verified on hardware;
121+
// its AmpServer answers "complete" regardless, but only these stream)
120122
if (state_.amplifierType == AmplifierType::NA410) {
121123
if (rate != 20000) return false;
124+
} else if (state_.amplifierType == AmplifierType::NA500) {
125+
if (rate != 250 && rate != 500 && rate != 1000 && rate != 2000 &&
126+
rate != 4000 && rate != 8000 && rate != 16000) {
127+
return false;
128+
}
122129
} else {
123130
if (rate != 500 && rate != 1000 && rate != 2000 &&
124131
rate != 4000 && rate != 8000) {
@@ -133,7 +140,7 @@ bool MockAmplifier::setNativeRate(int rate) {
133140
bool MockAmplifier::setDecimatedRate(int rate) {
134141
std::lock_guard<std::mutex> lock(mutex_);
135142
// Real amplifier accepts any rate value without validation
136-
// Valid rates: 250, 500, 1000
143+
// Valid rates: 250, 500, 1000 (NA400); NA500 additionally supports 2000
137144
state_.decimatedRate = rate;
138145
state_.decimated = true; // decimated mode: FPGA filter introduces EEG/physio skew
139146
return true;

resources/amplifier_scaling.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"microvolt_scale": 0.00009636188,
1515
"description": "Net Amps 410 series"
1616
},
17+
"NA500": {
18+
"microvolt_scale": 0.04470348358154297,
19+
"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)."
20+
},
1721
"TI": {
1822
"microvolt_scale": 0.02384185791016,
1923
"description": "TI-based amplifier"

resources/sampling_rates.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,52 @@
5757
"8000": 0
5858
}
5959
},
60+
"NA500": {
61+
"_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.",
62+
"_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.",
63+
"available_rates": [250, 500, 1000, 2000],
64+
"fast_recovery_rates": [250, 500, 1000, 2000, 4000, 8000, 16000],
65+
"source_rate": 1000,
66+
"eeg_shift_msec": {
67+
"250": 0,
68+
"500": 0,
69+
"1000": 0,
70+
"2000": 0,
71+
"250_fast_recovery": 0,
72+
"500_fast_recovery": 0,
73+
"1000_fast_recovery": 0,
74+
"2000_fast_recovery": 0,
75+
"4000": 0,
76+
"8000": 0,
77+
"16000": 0
78+
},
79+
"pns_shift_msec": {
80+
"250": 0,
81+
"500": 0,
82+
"1000": 0,
83+
"2000": 0,
84+
"250_fast_recovery": 0,
85+
"500_fast_recovery": 0,
86+
"1000_fast_recovery": 0,
87+
"2000_fast_recovery": 0,
88+
"4000": 0,
89+
"8000": 0,
90+
"16000": 0
91+
},
92+
"pns_diff_msec": {
93+
"250": 0,
94+
"500": 0,
95+
"1000": 0,
96+
"2000": 0,
97+
"250_fast_recovery": 0,
98+
"500_fast_recovery": 0,
99+
"1000_fast_recovery": 0,
100+
"2000_fast_recovery": 0,
101+
"4000": 0,
102+
"8000": 0,
103+
"16000": 0
104+
}
105+
},
60106
"NA410": {
61107
"available_rates": [250, 500, 1000],
62108
"fast_recovery_rates": [1000],

src/core/include/egiamp/AmpServerConfig.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ struct AmpServerConfig {
4242

4343
/// Suffix for LSL source ID to distinguish native/decimated mode and timestamp alignment.
4444
/// Ensures LSL consumers won't auto-reconnect across incompatible configurations.
45-
std::string modeSuffix() const {
46-
const bool native = sampleRate > 1000 || (fastRecovery && sampleRate >= 500);
45+
/// The caller decides `native` via usesNativeRate(), which needs the amp model
46+
/// (rate/mode capabilities differ between NA400 and NA500).
47+
std::string modeSuffix(bool native) const {
4748
// Decimated streams are always alignment-corrected, so "_decimated"
4849
// already implies aligned — no separate suffix needed.
4950
return native ? "_native" : "_decimated";

src/core/include/egiamp/AmpServerProtocol.h

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#define EGIAMP_AMPSERVERPROTOCOL_H
33

44
#include <cstdint>
5+
#include <initializer_list>
56

67
namespace egiamp {
78

@@ -136,11 +137,66 @@ inline float getScalingFactor(AmplifierType type) {
136137
return 0.00015522042f;
137138
case AmplifierType::NA410:
138139
return 0.00009636188f;
140+
case AmplifierType::NA500:
141+
// 0.75 V total input range over 2^24 codes (24-bit), in microvolts.
142+
// Extracted from Net Station 6.0.0.60960's
143+
// libEgiDataSource.dll PhysicalAmp::NA500ScaleFactor(), which
144+
// computes exactly rangeVolts(0.75) / 2^24 * 1e6.
145+
return 750000.0f / 16777216.0f; // 0.044703484 uV per count
139146
default:
140147
return 1.0f;
141148
}
142149
}
143150

151+
// Rate/mode capabilities per model, verified against hardware where possible.
152+
// NA400 (measured): decimated 250/500/1000; native 500/1000/2000/4000/8000.
153+
// NA500 (measured, fw 0.4.9): decimated 250/500/1000/2000; native
154+
// 250/500/1000/2000/4000/8000/16000. Unlike the NA400, the NA500 never sends
155+
// duplicate packets at sub-1000 Hz decimated rates.
156+
inline int maxDecimatedRate(AmplifierType type) {
157+
return type == AmplifierType::NA500 ? 2000 : 1000;
158+
}
159+
160+
inline int minNativeRate(AmplifierType type) {
161+
return type == AmplifierType::NA500 ? 250 : 500;
162+
}
163+
164+
// Whether the given configuration runs the amp in native mode (no FPGA
165+
// anti-alias filter) as opposed to decimated mode. Rates above the model's
166+
// decimated maximum are necessarily native; otherwise fastRecovery selects
167+
// native where the model supports it.
168+
inline bool usesNativeRate(AmplifierType type, int sampleRate, bool fastRecovery) {
169+
return sampleRate > maxDecimatedRate(type) ||
170+
(fastRecovery && sampleRate >= minNativeRate(type));
171+
}
172+
173+
// Whether the model supports the (rate, fastRecovery) combination, in the
174+
// mode usesNativeRate() would select for it. Unknown models are permissive.
175+
inline bool supportsSampleRate(AmplifierType type, int sampleRate, bool fastRecovery) {
176+
auto contains = [sampleRate](std::initializer_list<int> rates) {
177+
for (int r : rates) if (r == sampleRate) return true;
178+
return false;
179+
};
180+
const bool native = usesNativeRate(type, sampleRate, fastRecovery);
181+
switch (type) {
182+
case AmplifierType::NA300:
183+
// Decimated only; no native mode exists.
184+
return !native && contains({125, 250, 500, 1000});
185+
case AmplifierType::NA400:
186+
return native ? contains({500, 1000, 2000, 4000, 8000})
187+
: contains({250, 500, 1000});
188+
case AmplifierType::NA410:
189+
return native ? contains({20000})
190+
: contains({250, 500, 1000});
191+
case AmplifierType::NA500:
192+
// Verified on hardware (fw 0.4.9)
193+
return native ? contains({250, 500, 1000, 2000, 4000, 8000, 16000})
194+
: contains({250, 500, 1000, 2000});
195+
default:
196+
return true;
197+
}
198+
}
199+
144200
// Physio16 scaling factors (from EGI documentation)
145201
// Channels 1-8 use negative scaling, channels 9-16 use positive scaling
146202
constexpr float PHYSIO_SCALING_1_8 = -0.00111758708f; // PIB channels 1-8

src/core/include/egiamp/EGIAmpClient.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ class EGIAmpClient {
8585
void emitSensor(NetCode code) const;
8686

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

0 commit comments

Comments
 (0)