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
14 changes: 10 additions & 4 deletions Impedance.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ After measurement, return channel to excitation state:
e. Calculate impedance from amplitude
f. Reset channel to Excitation State (drive ON, 10K OFF)

3. Optionally measure Reference (Cz) and COM electrodes
3. Measure the Reference (Cz) electrode each cycle (COM is optional)

4. When done, reset amplifier to Default Acquisition State
```
Expand All @@ -98,9 +98,13 @@ After measurement, return channel to excitation state:
| Filter Time | 1.0 s | Duration for filter/measurement |
| Peak-to-Peak Samples | 51 | Samples for amplitude calculation |

Total time per channel: ~1.03 seconds
Total time per collection: ~1.03 seconds

For a 256-channel net, a full scan takes approximately 4-5 minutes.
Each collection measures a whole tiling set at once, so a full scan costs roughly
one collection per tiling set (plus one for the reference) rather than one per
channel. A 256-channel net has 6 tiling sets, so a full scan is ~6 collections
(about a minute). Nets without a tiling layout fall back to one collection per
channel (~4-5 minutes for 256 channels).

## LSL Streaming

Expand All @@ -111,7 +115,9 @@ Impedance values are streamed via LSL at 1 Hz:
- **Data Format**: float32 (kΩ)
- **Channel Labels**: E1, E2, ..., E256, Cz

The stream publishes the **current known values** for all channels every second, even during scanning. Channels not yet measured show 1000 kΩ (maximum/invalid value).
The last channel, `Cz`, is the **reference electrode** and is always appended after the per-electrode channels (so the impedance stream has one more channel than a standard-net EEG stream). Unlike the E-channels, its impedance is derived from the amplifier's dedicated reference monitor field (`refMonitor`) rather than a regular EEG channel — see `measureReference()`.

The stream publishes the **current known values** for all channels every second, even during scanning. Channels not yet measured show the maximum/invalid sentinel value.

### Stream Metadata

Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,10 @@ When `--native-format` is enabled:
- **Name**: `EGI NetAmp <amp_id> Impedance`
- **Type**: `Impedance`
- **Rate**: 1 Hz (regular rate)
- **Channels**: Same count and labels as EEG stream
- **Channels**: One per electrode (E1, E2, ...) **plus a trailing reference channel labeled `Cz`** — i.e. one more channel than the EEG stream carries for a standard net. The `Cz` value is the reference-electrode impedance, measured via the amplifier's dedicated reference monitor once per scan cycle.
- **Unit**: `kohms` (kilo-ohms)
- **Behavior**: Publishes current known impedance values every second
- Initially, all channels show 1000 kOhms (not yet measured)
- Initially, all channels (including `Cz`) show the not-measured sentinel value
- As each channel is measured, its value updates
- Values persist until the next measurement of that channel

Expand Down Expand Up @@ -278,11 +278,10 @@ cmd_TurnChannel10KOhms(N, 0) - Turn OFF 10K resistor (reset)

### Limitations and Notes

- **Scan Duration**: A full 256-channel scan takes approximately 5 minutes in single-channel mode
- **No tiling sets yet**: The current implementation measures one channel at a time. Tiling set support (faster, ~1 minute for 256 channels) is planned for a future release
- **Tiling-based scanning**: When a tiling layout is available for the detected net (32/64/128/256 HydroCel GSN nets), channels are measured in groups (~5-6 collections per full scan) rather than one at a time — no flag required, it engages automatically. A full 256-channel scan drops from ~5 minutes to roughly a minute. Nets without a tiling layout fall back to single-channel scanning.
- **Ideal signal estimation**: The "ideal signal" (expected amplitude with 0 impedance) is estimated from the first measurement. For more accurate results, gains calibration should be performed first
- **Net Station compatibility**: Running impedance mode will interfere with Net Station Acquisition if it's connected to the same amplifier
- **Channel labels**: Both streams use identical channel labels (E1, E2, ..., En)
- **Channel labels**: The E-channels share labels with the EEG stream (E1, E2, ..., En); the impedance stream additionally carries a trailing `Cz` reference channel

### Downstream Processing

Expand Down
24 changes: 23 additions & 1 deletion mock/src/MockAmplifier.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,29 @@ void MockAmplifier::generatePacketFormat2(PacketFormat2_SamplePacket& packet) {
}

// Monitor channels
packet.refMonitor = 0;
// Reference (Cz) monitor: in impedance mode, mirror the per-channel
// voltage-divider behavior so measureReference() sees a realistic signal.
// The reference is "measuring" when its drive is off and its 10K is on
// (set by ImpedanceMeasurement::setReferenceDriving(false)).
if (impedanceMode) {
double refFreq = static_cast<double>(state_.calibrationSignalFreq);
double refNoise = (rand() % 1000 - 500) / 500.0 * 2.0;
double refAmplitude;
if (!state_.referenceDriveSignal && state_.reference10KOhms) {
// Measurement mode: fixed simulated reference impedance (~12 kOhms)
constexpr double simulatedRefImpedance = 12.0;
refAmplitude = IDEAL_SIGNAL_UV * REFERENCE_RESISTOR_KOHMS /
(REFERENCE_RESISTOR_KOHMS + simulatedRefImpedance);
} else if (state_.referenceDriveSignal) {
refAmplitude = IDEAL_SIGNAL_UV; // driving the calibration signal
} else {
refAmplitude = 5.0; // neither driving nor measuring
}
double refValue = refAmplitude * std::sin(2.0 * M_PI * refFreq * phase_) + refNoise;
packet.refMonitor = static_cast<int32_t>(refValue / scaleFactor);
} else {
packet.refMonitor = 0;
}
packet.comMonitor = 0;
packet.driveMonitor = 0;
packet.diagnosticsChannel = 0;
Expand Down
5 changes: 5 additions & 0 deletions scripts/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ pylsl>=1.16.0
numpy>=1.20.0
matplotlib>=3.5.0
pyserial>=3.5

# Optional: for notebooks/delay_inspection.ipynb (delay_capture_sweep.py analysis)
jupyterlab>=4.0
ipykernel>=6.0
nbformat>=5.0
3 changes: 2 additions & 1 deletion src/core/src/EGIAmpClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,9 @@ void EGIAmpClient::readPacketFormat2() {
config_.serverAddress, details_);
emitStatus("Impedance stream created.\n");

// Configure and start the impedance measurement
// Configure and start the impedance measurement.
impedanceMeasurement_->setChannelCount(nChannels);
impedanceMeasurement_->setNetSize(getChannelCountFromNetCode(details_.netCode));
impedanceMeasurement_->startContinuousScan(impedanceStreamer_);
emitStatus("Impedance scanning started.\n");
impedanceModeActive_ = true;
Expand Down
39 changes: 27 additions & 12 deletions src/core/src/ImpedanceMeasurement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -572,10 +572,12 @@ bool ImpedanceMeasurement::startContinuousScan(LSLStreamer& impedanceStreamer) {
return false;
}

// Initialize current impedances to max value (not yet measured)
// Initialize current impedances to max value (not yet measured).
// One extra slot at index channelCount_ holds the reference (Cz) impedance,
// matching the trailing Cz channel added by createImpedanceOutlet().
{
std::lock_guard<std::mutex> lock(impedancesMutex_);
currentImpedances_.assign(channelCount_, MAX_IMPEDANCE_KOHMS);
currentImpedances_.assign(channelCount_ + 1, MAX_IMPEDANCE_KOHMS);
}

stopFlag_ = false;
Expand Down Expand Up @@ -668,17 +670,21 @@ void ImpedanceMeasurement::scanThread(LSLStreamer& /* impedanceStreamer */) {
std::vector<ChannelImpedance> results = measureTilingSet(ts);

for (const auto& result : results) {
if (result.valid && result.channel >= 0) {
{
std::lock_guard<std::mutex> lock(impedancesMutex_);
if (result.channel < static_cast<int>(currentImpedances_.size())) {
currentImpedances_[result.channel] = result.impedanceKOhms;
}
}
if (impedanceCallback_) {
impedanceCallback_(result.channel, result.impedanceKOhms);
if (!result.valid) {
continue;
}
// measureReference() reports channel == -1; store it in the
// trailing reference (Cz) slot at index channelCount_.
int slot = (result.channel >= 0) ? result.channel : channelCount_;
{
std::lock_guard<std::mutex> lock(impedancesMutex_);
if (slot < static_cast<int>(currentImpedances_.size())) {
currentImpedances_[slot] = result.impedanceKOhms;
}
}
if (impedanceCallback_) {
impedanceCallback_(slot, result.impedanceKOhms);
}
}
}

Expand Down Expand Up @@ -714,10 +720,19 @@ void ImpedanceMeasurement::scanThread(LSLStreamer& /* impedanceStreamer */) {
}
}

// Measure reference
// Measure reference (Cz) and publish it in the trailing slot
if (!stopFlag_) {
ChannelImpedance refResult = measureReference();
if (refResult.valid) {
{
std::lock_guard<std::mutex> lock(impedancesMutex_);
if (channelCount_ < static_cast<int>(currentImpedances_.size())) {
currentImpedances_[channelCount_] = refResult.impedanceKOhms;
}
}
if (impedanceCallback_) {
impedanceCallback_(channelCount_, refResult.impedanceKOhms);
}
emitStatus(" Reference: " + std::to_string(refResult.impedanceKOhms) + " kOhms\n");
}
}
Expand Down
28 changes: 26 additions & 2 deletions src/core/src/LSLStreamer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,17 @@ void LSLStreamer::createImpedanceOutlet(const std::string& streamName, int chann
// Close existing outlet if any
closeOutlet();

// The stream carries the channelCount electrode channels (E1..En) plus one
// trailing reference (Cz) channel, whose value is filled by
// ImpedanceMeasurement::measureReference().
const int totalChannelCount = channelCount + 1;

// Create stream info with unique source ID for impedance
// Use 1 Hz rate - values are pushed every second with current known impedances
std::string sourceId = "EGI_" + hostname +
"_ch" + std::to_string(channelCount) +
"_ch" + std::to_string(totalChannelCount) +
"_impedance";
lsl::stream_info info(streamName, "Impedance", channelCount,
lsl::stream_info info(streamName, "Impedance", totalChannelCount,
1.0, // 1 Hz - current impedance values pushed every second
lsl::cf_float32, sourceId);

Expand Down Expand Up @@ -317,6 +322,25 @@ void LSLStreamer::createImpedanceOutlet(const std::string& streamName, int chann
}
}

// Trailing reference (Cz) channel. Its impedance is measured separately via
// the amplifier's dedicated reference monitor and lands in the last slot of
// the pushed sample (index channelCount).
{
lsl::xml_element ref = channels.append_child("channel");
ref.append_child_value("label", "Cz");
ref.append_child_value("type", "Impedance");
ref.append_child_value("unit", "kohms");

// The extended montage array stores Cz at index == base net size.
const ElectrodePosition* pos = getElectrodePosition(channelCount, channelCount);
if (pos) {
lsl::xml_element loc = ref.append_child("location");
loc.append_child_value("X", std::to_string(pos->x * 10.0f));
loc.append_child_value("Y", std::to_string(pos->y * 10.0f));
loc.append_child_value("Z", std::to_string(pos->z * 10.0f));
}
}

// Add description note
desc.append_child("description").append_child_value("note",
"Electrode impedance values in kilo-ohms. Values of 1000 indicate no signal or bad electrode.");
Expand Down
Loading