diff --git a/Impedance.md b/Impedance.md index 4d9d201..c65f67a 100644 --- a/Impedance.md +++ b/Impedance.md @@ -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 ``` @@ -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 @@ -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 diff --git a/README.md b/README.md index 8b09d0f..d73302a 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,10 @@ When `--native-format` is enabled: - **Name**: `EGI NetAmp 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 @@ -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 diff --git a/mock/src/MockAmplifier.cpp b/mock/src/MockAmplifier.cpp index 585c0e3..d0db2a3 100644 --- a/mock/src/MockAmplifier.cpp +++ b/mock/src/MockAmplifier.cpp @@ -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(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(refValue / scaleFactor); + } else { + packet.refMonitor = 0; + } packet.comMonitor = 0; packet.driveMonitor = 0; packet.diagnosticsChannel = 0; diff --git a/scripts/requirements.txt b/scripts/requirements.txt index a25e148..1c8ed67 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -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 diff --git a/src/core/src/EGIAmpClient.cpp b/src/core/src/EGIAmpClient.cpp index fb0dd8f..1976dc0 100644 --- a/src/core/src/EGIAmpClient.cpp +++ b/src/core/src/EGIAmpClient.cpp @@ -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; diff --git a/src/core/src/ImpedanceMeasurement.cpp b/src/core/src/ImpedanceMeasurement.cpp index f01c098..333cba9 100644 --- a/src/core/src/ImpedanceMeasurement.cpp +++ b/src/core/src/ImpedanceMeasurement.cpp @@ -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 lock(impedancesMutex_); - currentImpedances_.assign(channelCount_, MAX_IMPEDANCE_KOHMS); + currentImpedances_.assign(channelCount_ + 1, MAX_IMPEDANCE_KOHMS); } stopFlag_ = false; @@ -668,17 +670,21 @@ void ImpedanceMeasurement::scanThread(LSLStreamer& /* impedanceStreamer */) { std::vector results = measureTilingSet(ts); for (const auto& result : results) { - if (result.valid && result.channel >= 0) { - { - std::lock_guard lock(impedancesMutex_); - if (result.channel < static_cast(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 lock(impedancesMutex_); + if (slot < static_cast(currentImpedances_.size())) { + currentImpedances_[slot] = result.impedanceKOhms; } } + if (impedanceCallback_) { + impedanceCallback_(slot, result.impedanceKOhms); + } } } @@ -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 lock(impedancesMutex_); + if (channelCount_ < static_cast(currentImpedances_.size())) { + currentImpedances_[channelCount_] = refResult.impedanceKOhms; + } + } + if (impedanceCallback_) { + impedanceCallback_(channelCount_, refResult.impedanceKOhms); + } emitStatus(" Reference: " + std::to_string(refResult.impedanceKOhms) + " kOhms\n"); } } diff --git a/src/core/src/LSLStreamer.cpp b/src/core/src/LSLStreamer.cpp index 9c8bd42..13338fb 100644 --- a/src/core/src/LSLStreamer.cpp +++ b/src/core/src/LSLStreamer.cpp @@ -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); @@ -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.");