diff --git a/XYParser/XYEegParser2.h b/XYParser/XYEegParser2.h
new file mode 100644
index 0000000..f47b782
--- /dev/null
+++ b/XYParser/XYEegParser2.h
@@ -0,0 +1,10 @@
+#pragma once
+
+#include "XYEegParserCommon.h"
+
+using XYEegFrame2 = xyparser::XYEegFrame<2>;
+
+class XYEegParser2 final : public xyparser::XYEegTcpParserCommon<2> {
+public:
+ using Frame = XYEegFrame2;
+};
diff --git a/XYParser/XYParser.sln b/XYParser/XYParser.sln
index 7e3e480..f5af162 100644
--- a/XYParser/XYParser.sln
+++ b/XYParser/XYParser.sln
@@ -11,6 +11,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "XYParser64Demo", "XYParserW
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "XYParser8Demo", "XYParserWorkflowDemo\XYParser8Demo.vcxproj", "{1B7FA4A1-8BC2-4D49-9B5A-BD4C6B8F2107}"
EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "XYParser2Demo", "XYParserWorkflowDemo\XYParser2Demo.vcxproj", "{D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}"
+EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "XYAlgorithmUdpServer", "XYParserWorkflowDemo\XYAlgorithmUdpServer.vcxproj", "{6D6DCD3D-995A-4E79-9338-C1D36A3D2A61}"
EndProject
Global
@@ -53,6 +55,14 @@ Global
{1B7FA4A1-8BC2-4D49-9B5A-BD4C6B8F2107}.Release|x64.Build.0 = Release|x64
{1B7FA4A1-8BC2-4D49-9B5A-BD4C6B8F2107}.Release|x86.ActiveCfg = Release|Win32
{1B7FA4A1-8BC2-4D49-9B5A-BD4C6B8F2107}.Release|x86.Build.0 = Release|Win32
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Debug|x64.ActiveCfg = Debug|x64
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Debug|x64.Build.0 = Debug|x64
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Debug|x86.ActiveCfg = Debug|Win32
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Debug|x86.Build.0 = Debug|Win32
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Release|x64.ActiveCfg = Release|x64
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Release|x64.Build.0 = Release|x64
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Release|x86.ActiveCfg = Release|Win32
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}.Release|x86.Build.0 = Release|Win32
{6D6DCD3D-995A-4E79-9338-C1D36A3D2A61}.Debug|x64.ActiveCfg = Debug|x64
{6D6DCD3D-995A-4E79-9338-C1D36A3D2A61}.Debug|x64.Build.0 = Debug|x64
{6D6DCD3D-995A-4E79-9338-C1D36A3D2A61}.Debug|x86.ActiveCfg = Debug|Win32
diff --git a/XYParser/XYParser.vcxproj b/XYParser/XYParser.vcxproj
index 4d203ae..3dd5f11 100644
--- a/XYParser/XYParser.vcxproj
+++ b/XYParser/XYParser.vcxproj
@@ -146,6 +146,7 @@
+
diff --git a/XYParser/XYParserApi.cpp b/XYParser/XYParserApi.cpp
index b12c15c..d620365 100644
--- a/XYParser/XYParserApi.cpp
+++ b/XYParser/XYParserApi.cpp
@@ -3,6 +3,7 @@
#include "XYImpedanceProcessor.h"
#include "XYWelchProcessor.h"
+#include "XYEegParser2.h"
#include "XYEegParser8.h"
#include "XYEegParser64.h"
@@ -20,6 +21,7 @@ constexpr std::uint8_t kCommandFrameTail = 0x55;
constexpr std::size_t k8ChImpedanceCommandSize = 7;
constexpr std::size_t kTriggerPayloadSize = 3;
constexpr std::size_t kTriggerCommandSize = 1 + kTriggerPayloadSize + 1 + 2;
+constexpr int k2ChLeadCount = 2;
constexpr int k8ChLeadCount = 8;
constexpr int k64ChLeadCount = 64;
constexpr std::uint8_t kAlgorithmChannelCount = 64;
@@ -77,6 +79,9 @@ std::array BuildTriggerCommand(std::uint8_t t
kCommandFrameTail};
}
+constexpr std::array k2ChLeadMap = {
+ LeadChannel_FP1, LeadChannel_FP2};
+
constexpr std::array k8ChLeadMap = {
LeadChannel_PO5, LeadChannel_POZ, LeadChannel_PO6, LeadChannel_PO7,
LeadChannel_O1, LeadChannel_OZ, LeadChannel_O2, LeadChannel_PO8};
@@ -101,6 +106,7 @@ constexpr std::array k64ChLeadMap = {
struct ParserContext {
std::uint8_t channel_count = 0;
+ XYEegParser2 parser2;
XYEegParser8 parser8;
XYEegParser64 parser64;
XYImpedanceProcessor impedance_processor;
@@ -127,6 +133,29 @@ constexpr std::array kWelchBandInfos =
{"gamma", 30.0, 50.0},
}};
+void FillSummary(const XYEegFrame2& frame, XYParserFrameSummary& summary)
+{
+ summary.frame_index = frame.index;
+ summary.channel_count = frame.channel_count;
+ summary.battery = frame.battery;
+ summary.sample_count = static_cast(frame.samples.size());
+ summary.impedance_enabled = frame.impedance_enabled;
+ summary.current_gain = frame.current_gain;
+ summary.current_sample_rate_hz = frame.current_sample_rate_hz;
+ summary.cap_type = frame.cap_type;
+ summary.gnd_detached = frame.gnd_detached;
+ for (std::size_t sample_index = 0; sample_index < XYPARSER_SAMPLES_PER_FRAME; ++sample_index) {
+ summary.sample_trigger_types[sample_index] = frame.samples[sample_index].trigger_type;
+ summary.sample_trigger_indices[sample_index] = frame.samples[sample_index].trigger_index;
+ for (std::size_t channel_index = 0; channel_index < XYPARSER_MAX_CHANNELS; ++channel_index) {
+ summary.channel_values_uv[sample_index][channel_index] =
+ channel_index < frame.channel_count
+ ? frame.samples[sample_index].channel_values_uv[channel_index]
+ : 0.0;
+ }
+ }
+}
+
void FillSummary(const XYEegFrame8& frame, XYParserFrameSummary& summary)
{
summary.frame_index = frame.index;
@@ -173,6 +202,32 @@ void FillSummary(const XYEegFrame64& frame, XYParserFrameSummary& summary)
}
}
+void Convert2ChSummaryTo64ChSummary(const XYParserFrameSummary& input_summary,
+ XYParserFrameSummary& output_summary)
+{
+ std::memset(&output_summary, 0, sizeof(output_summary));
+ output_summary.frame_index = input_summary.frame_index;
+ output_summary.channel_count = 64;
+ output_summary.battery = input_summary.battery;
+ output_summary.sample_count = input_summary.sample_count;
+ output_summary.impedance_enabled = input_summary.impedance_enabled;
+ output_summary.current_gain = input_summary.current_gain;
+ output_summary.current_sample_rate_hz = input_summary.current_sample_rate_hz;
+ output_summary.cap_type = input_summary.cap_type;
+ output_summary.gnd_detached = input_summary.gnd_detached;
+
+ for (std::size_t sample_index = 0; sample_index < XYPARSER_SAMPLES_PER_FRAME; ++sample_index) {
+ output_summary.sample_trigger_types[sample_index] = input_summary.sample_trigger_types[sample_index];
+ output_summary.sample_trigger_indices[sample_index] = input_summary.sample_trigger_indices[sample_index];
+
+ for (std::size_t two_channel_index = 0; two_channel_index < k2ChLeadMap.size(); ++two_channel_index) {
+ const XYParserLeadChannelNumber lead = k2ChLeadMap[two_channel_index];
+ output_summary.channel_values_uv[sample_index][static_cast(lead)] =
+ input_summary.channel_values_uv[sample_index][two_channel_index];
+ }
+ }
+}
+
void Convert8ChSummaryTo64ChSummary(const XYParserFrameSummary& input_summary,
XYParserFrameSummary& output_summary)
{
@@ -306,6 +361,8 @@ bool TryMapGain(std::uint8_t gain, XYEegParser64::GainSwitch& mapped_gain)
int GetLeadMapCount(std::uint8_t channel_count)
{
switch (channel_count) {
+ case 2:
+ return k2ChLeadCount;
case 8:
return k8ChLeadCount;
case 64:
@@ -321,7 +378,7 @@ extern "C" {
XYParserHandle XYParser_CreateParser(std::uint8_t channel_count)
{
- if (channel_count != 8 && channel_count != 64) {
+ if (channel_count != 2 && channel_count != 8 && channel_count != 64) {
return nullptr;
}
@@ -356,7 +413,9 @@ void XYParser_SetAdcParams(XYParserHandle handle, double vref, double gain)
context->adc_gain = gain;
}
- if (context->channel_count == 8) {
+ if (context->channel_count == 2) {
+ context->parser2.SetAdcParams(vref, gain);
+ } else if (context->channel_count == 8) {
context->parser8.SetAdcParams(vref, gain);
} else {
context->parser64.SetAdcParams(vref, gain);
@@ -371,7 +430,9 @@ void XYParser_SetBypassChecksum(XYParserHandle handle, int bypass)
}
const bool enabled = bypass != 0;
- if (context->channel_count == 8) {
+ if (context->channel_count == 2) {
+ context->parser2.SetBypassChecksum(enabled);
+ } else if (context->channel_count == 8) {
context->parser8.SetBypassChecksum(enabled);
} else {
context->parser64.SetBypassChecksum(enabled);
@@ -400,7 +461,9 @@ void XYParser_SetImpedanceDetection(XYParserHandle handle, int enabled)
const double impedance_gain = impedance_enabled ? 24.0 : 6.0;
context->adc_gain = impedance_gain;
- if (context->channel_count == 8) {
+ if (context->channel_count == 2) {
+ context->parser2.SetAdcParams(context->adc_vref, impedance_gain);
+ } else if (context->channel_count == 8) {
context->parser8.SetAdcParams(context->adc_vref, impedance_gain);
} else {
context->parser64.SetAdcParams(context->adc_vref, impedance_gain);
@@ -424,6 +487,11 @@ std::size_t XYParser_Get64ImpedanceCommandSize(void)
return XYEegParser64::kImpedanceCommandSize;
}
+std::size_t XYParser_Get2ChImpedanceCommandSize(void)
+{
+ return XYEegParser64::kImpedanceCommandSize;
+}
+
std::size_t XYParser_GetTriggerCommandSize(void)
{
return kTriggerCommandSize;
@@ -447,6 +515,11 @@ std::size_t XYParser_Get64GainSampleRateCommandSize(void)
return XYEegParser64::kGainSampleRateCommandSize;
}
+std::size_t XYParser_Get2ChGainSampleRateCommandSize(void)
+{
+ return XYEegParser64::kGainSampleRateCommandSize;
+}
+
int XYParser_Serialize64ImpedanceCommand(int open,
std::uint8_t* out_buffer,
std::size_t buffer_size)
@@ -459,6 +532,13 @@ int XYParser_Serialize64ImpedanceCommand(int open,
return static_cast(command.size());
}
+int XYParser_Serialize2ChImpedanceCommand(int open,
+ std::uint8_t* out_buffer,
+ std::size_t buffer_size)
+{
+ return XYParser_Serialize64ImpedanceCommand(open, out_buffer, buffer_size);
+}
+
int XYParser_Serialize64GainSampleRateCommand(std::uint8_t gain,
std::uint16_t sample_rate,
std::uint8_t* out_buffer,
@@ -477,6 +557,14 @@ int XYParser_Serialize64GainSampleRateCommand(std::uint8_t gain,
return static_cast(command.size());
}
+int XYParser_Serialize2ChGainSampleRateCommand(std::uint8_t gain,
+ std::uint16_t sample_rate,
+ std::uint8_t* out_buffer,
+ std::size_t buffer_size)
+{
+ return XYParser_Serialize64GainSampleRateCommand(gain, sample_rate, out_buffer, buffer_size);
+}
+
int XYParser_Feed(XYParserHandle handle,
const std::uint8_t* data,
std::size_t size,
@@ -492,6 +580,21 @@ int XYParser_Feed(XYParserHandle handle,
max_summaries = 0;
}
+ if (context->channel_count == 2) {
+ const std::vector frames = context->parser2.Feed(data, size);
+ context->last_error = context->parser2.LastError();
+ const int write_count = std::min(static_cast(frames.size()), max_summaries);
+ for (std::size_t i = 0; i < frames.size(); ++i) {
+ XYParserFrameSummary summary{};
+ FillSummary(frames[i], summary);
+ context->impedance_processor.PushFrame(summary);
+ if (static_cast(i) < write_count) {
+ out_summaries[static_cast(i)] = summary;
+ }
+ }
+ return write_count;
+ }
+
if (context->channel_count == 8) {
const std::vector frames = context->parser8.Feed(data, size);
context->last_error = context->parser8.LastError();
@@ -540,6 +643,25 @@ int XYParser_Convert8ChFramesTo64Ch(const XYParserFrameSummary* input_summaries,
return convert_count;
}
+int XYParser_Convert2ChFramesTo64Ch(const XYParserFrameSummary* input_summaries,
+ int input_count,
+ XYParserFrameSummary* output_summaries,
+ int max_summaries)
+{
+ if (input_summaries == nullptr || output_summaries == nullptr || input_count <= 0 || max_summaries <= 0) {
+ return 0;
+ }
+
+ const int convert_count = input_count < max_summaries ? input_count : max_summaries;
+ for (int i = 0; i < convert_count; ++i) {
+ if (input_summaries[i].channel_count != 2) {
+ return i;
+ }
+ Convert2ChSummaryTo64ChSummary(input_summaries[i], output_summaries[i]);
+ }
+ return convert_count;
+}
+
std::size_t XYParser_GetAlgorithmDataValueCount()
{
return XYPARSER_FRAME_ALGORITHM_VALUE_COUNT;
@@ -566,8 +688,7 @@ int XYParser_FeedAlgorithmData(XYParserHandle handle,
int max_summaries)
{
ParserContext* context = static_cast(handle);
- if (context == nullptr || context->channel_count != kAlgorithmChannelCount ||
- data == nullptr || size == 0) {
+ if (context == nullptr || data == nullptr || size == 0) {
return 0;
}
@@ -618,7 +739,7 @@ int XYParser_FeedAlgorithmData(XYParserHandle handle,
void XYParser_ResetAlgorithmDataCache(XYParserHandle handle)
{
ParserContext* context = static_cast(handle);
- if (context == nullptr || context->channel_count != kAlgorithmChannelCount) {
+ if (context == nullptr) {
return;
}
@@ -631,8 +752,7 @@ int XYParser_FlushAlgorithmData(XYParserHandle handle,
XYParserFrameSummary* out_summary)
{
ParserContext* context = static_cast(handle);
- if (context == nullptr || context->channel_count != kAlgorithmChannelCount ||
- out_summary == nullptr || context->algorithm_sample_cache.empty()) {
+ if (context == nullptr || out_summary == nullptr || context->algorithm_sample_cache.empty()) {
return 0;
}
@@ -658,6 +778,11 @@ int XYParser_GetLeadMap(std::uint8_t channel_count,
return 0;
}
+ if (channel_count == 2) {
+ std::copy(k2ChLeadMap.begin(), k2ChLeadMap.end(), out_leads);
+ return required_count;
+ }
+
if (channel_count == 8) {
std::copy(k8ChLeadMap.begin(), k8ChLeadMap.end(), out_leads);
return required_count;
diff --git a/XYParser/XYParserApi.h b/XYParser/XYParserApi.h
index d09fcd8..e61063d 100644
--- a/XYParser/XYParserApi.h
+++ b/XYParser/XYParserApi.h
@@ -143,7 +143,7 @@ struct XYParserWelchSummary {
};
// 创建解析器实例。
-// @param channel_count 解析器支持的导联数。
+// @param channel_count 解析器支持的导联数,当前支持 2/8/64。
// @return 创建成功时返回解析器句柄,失败时返回 nullptr。
XYPARSER_API XYParserHandle XYParser_CreateParser(std::uint8_t channel_count);
@@ -248,6 +248,10 @@ XYPARSER_API int XYParser_SerializeTriggerCommand(std::uint8_t trigger_type,
// @return 64 导阻抗命令的字节长度。
XYPARSER_API std::size_t XYParser_Get64ImpedanceCommandSize(void);
+// 获取 2 导阻抗命令的字节长度。
+// @return 2 导阻抗命令的字节长度。
+XYPARSER_API std::size_t XYParser_Get2ChImpedanceCommandSize(void);
+
// 生成 64 导阻抗开关命令。
// @param open 非 0 表示打开阻抗检测,0 表示关闭阻抗检测。
// @param out_buffer 输出的命令缓冲区。
@@ -257,10 +261,24 @@ XYPARSER_API int XYParser_Serialize64ImpedanceCommand(int open,
std::uint8_t* out_buffer,
std::size_t buffer_size);
+// 生成 2 导阻抗开关命令。
+// 协议格式与 64 导控制命令一致,但单独保留 2 导接口用于区分 workflow。
+// @param open 非 0 表示打开阻抗检测,0 表示关闭阻抗检测。
+// @param out_buffer 输出的命令缓冲区。
+// @param buffer_size 输出缓冲区大小,单位为字节。
+// @return 生成成功时返回写入的字节数,失败时返回 0。
+XYPARSER_API int XYParser_Serialize2ChImpedanceCommand(int open,
+ std::uint8_t* out_buffer,
+ std::size_t buffer_size);
+
// 获取 64 导增益和采样率命令的字节长度。
// @return 64 导增益和采样率命令的字节长度。
XYPARSER_API std::size_t XYParser_Get64GainSampleRateCommandSize(void);
+// 获取 2 导增益和采样率命令的字节长度。
+// @return 2 导增益和采样率命令的字节长度。
+XYPARSER_API std::size_t XYParser_Get2ChGainSampleRateCommandSize(void);
+
// 生成 64 导增益和采样率设置命令。
// @param gain 要设置的增益值。
// @param sample_rate 要设置的采样率。
@@ -272,6 +290,18 @@ XYPARSER_API int XYParser_Serialize64GainSampleRateCommand(std::uint8_t gain,
std::uint8_t* out_buffer,
std::size_t buffer_size);
+// 生成 2 导增益和采样率设置命令。
+// 协议格式与 64 导控制命令一致,但单独保留 2 导接口用于区分 workflow。
+// @param gain 要设置的增益值。
+// @param sample_rate 要设置的采样率。
+// @param out_buffer 输出的命令缓冲区。
+// @param buffer_size 输出缓冲区大小,单位为字节。
+// @return 生成成功时返回写入的字节数,失败时返回 0。
+XYPARSER_API int XYParser_Serialize2ChGainSampleRateCommand(std::uint8_t gain,
+ std::uint16_t sample_rate,
+ std::uint8_t* out_buffer,
+ std::size_t buffer_size);
+
// Frame conversion and algorithm data output.
// 将 8 导帧批量转换为 64 导帧,未映射的导联补 0。
// @param input_summaries 输入的 8 导帧数组。
@@ -284,6 +314,18 @@ XYPARSER_API int XYParser_Convert8ChFramesTo64Ch(const XYParserFrameSummary* inp
XYParserFrameSummary* output_summaries,
int max_summaries);
+// 将 2 导帧批量转换为 64 导帧,未映射的导联补 0。
+// 2 导映射固定为 FP1、FP2。
+// @param input_summaries 输入的 2 导帧数组。
+// @param input_count 输入数组中的帧数。
+// @param output_summaries 输出的 64 导帧数组。
+// @param max_summaries 输出数组可写入的最大帧数,用于避免越界写入。
+// @return 实际成功转换的帧数;如果参数无效则返回 0,如果中途遇到非 2 导帧则返回已完成转换的数量。
+XYPARSER_API int XYParser_Convert2ChFramesTo64Ch(const XYParserFrameSummary* input_summaries,
+ int input_count,
+ XYParserFrameSummary* output_summaries,
+ int max_summaries);
+
// 获取算法数据数组所需的元素数量。
// @return 算法数据数组所需的元素数量。
XYPARSER_API std::size_t XYParser_GetAlgorithmDataValueCount();
diff --git a/XYParser/XYParserTests/Tests.cpp b/XYParser/XYParserTests/Tests.cpp
index 40c52a1..c3b6ff4 100644
--- a/XYParser/XYParserTests/Tests.cpp
+++ b/XYParser/XYParserTests/Tests.cpp
@@ -396,6 +396,12 @@ TEST(XYParserApiTests, CreateParserRejectsUnsupportedChannelCount)
EXPECT_EQ(XYParser_CreateParser(7), nullptr);
}
+TEST(XYParserApiTests, CreateParserAccepts2ChannelCount)
+{
+ ParserGuard parser(XYParser_CreateParser(2));
+ EXPECT_NE(parser.get(), nullptr);
+}
+
/// 测试:对空解析器句柄调用 GetLastError 应返回正确错误信息
TEST(XYParserApiTests, GetLastErrorReturnsMessageForNullParser)
{
@@ -484,11 +490,23 @@ TEST(XYParserApiTests, GetAlgorithmDataValueCountMatchesFrameLayout)
TEST(XYParserApiTests, GetLeadMapCountReturnsExpectedCounts)
{
+ EXPECT_EQ(XYParser_GetLeadMapCount(2), 2);
EXPECT_EQ(XYParser_GetLeadMapCount(8), 8);
EXPECT_EQ(XYParser_GetLeadMapCount(64), 64);
EXPECT_EQ(XYParser_GetLeadMapCount(7), 0);
}
+TEST(XYParserApiTests, GetLeadMapReturnsExpected2ChannelSubset)
+{
+ std::array leads{};
+ const int count = XYParser_GetLeadMap(2, leads.data(), static_cast(leads.size()));
+
+ ASSERT_EQ(count, 2);
+ const std::array expected = {
+ LeadChannel_FP1, LeadChannel_FP2};
+ EXPECT_EQ(leads, expected);
+}
+
TEST(XYParserApiTests, GetLeadMapReturnsExpected8ChannelSubset)
{
std::array leads{};
@@ -578,6 +596,34 @@ TEST(XYParserApiTests, Convert8ChFramesTo64ChMapsKnownLeadsAndPadsOthersWithZero
EXPECT_DOUBLE_EQ(output[1].channel_values_uv[1][LeadChannel_PO5], 21.0);
}
+TEST(XYParserApiTests, Convert2ChFramesTo64ChMapsFp1Fp2AndPadsOthersWithZero)
+{
+ XYParserFrameSummary input{};
+ input.frame_index = 11U;
+ input.channel_count = 2U;
+ input.sample_count = 5U;
+ input.impedance_enabled = 1U;
+ input.current_gain = 24U;
+ input.current_sample_rate_hz = 250U;
+ input.cap_type = 4U;
+ input.gnd_detached = 1U;
+ input.sample_trigger_types[0] = 0xBB;
+ input.sample_trigger_indices[0] = 1U;
+ input.channel_values_uv[0][0] = 101.0;
+ input.channel_values_uv[0][1] = 202.0;
+
+ XYParserFrameSummary output{};
+ ASSERT_EQ(XYParser_Convert2ChFramesTo64Ch(&input, 1, &output, 1), 1);
+ EXPECT_EQ(output.channel_count, 64U);
+ EXPECT_EQ(output.channel_values_uv[0][LeadChannel_FP1], 101.0);
+ EXPECT_EQ(output.channel_values_uv[0][LeadChannel_FP2], 202.0);
+ EXPECT_DOUBLE_EQ(output.channel_values_uv[0][LeadChannel_PO6], 0.0);
+ EXPECT_EQ(output.sample_trigger_types[0], 0xBB);
+ EXPECT_EQ(output.sample_trigger_indices[0], 1U);
+ EXPECT_EQ(output.current_gain, 24U);
+ EXPECT_EQ(output.current_sample_rate_hz, 250U);
+}
+
TEST(XYParserApiTests, Convert8ChFramesTo64ChRejectsInvalidArgumentsAndStopsAtNon8ChannelInput)
{
std::array input{};
@@ -700,6 +746,40 @@ TEST(XYParserApiTests, FeedAlgorithmDataCachesSamplesBuildsFramesAndFlushesTailS
EXPECT_EQ(XYParser_FlushAlgorithmData(parser.get(), &tail_summary), 0);
}
+TEST(XYParserApiTests, FeedAlgorithmDataAccepts2ChannelParserAndBuildsAlgorithmFrames)
+{
+ ParserGuard parser(XYParser_CreateParser(2));
+ ASSERT_NE(parser.get(), nullptr);
+
+ constexpr int kTotalSamples = 7;
+ constexpr int kColumnCount = kAlgorithmReturnColumnCount;
+ std::array(kTotalSamples) * kColumnCount> input_data{};
+ for (int sample_index = 0; sample_index < kTotalSamples; ++sample_index) {
+ const std::size_t row_offset = static_cast(sample_index) * kColumnCount;
+ input_data[row_offset + static_cast(LeadChannel_FP1)] = 100.0 + sample_index;
+ input_data[row_offset + static_cast(LeadChannel_FP2)] = 200.0 + sample_index;
+ }
+
+ XYParserFrameSummary frame_summary{};
+ ASSERT_EQ(
+ XYParser_FeedAlgorithmData(parser.get(),
+ reinterpret_cast(input_data.data()),
+ sizeof(input_data),
+ &frame_summary,
+ 1),
+ 1);
+ EXPECT_EQ(frame_summary.channel_count, 64U);
+ EXPECT_EQ(frame_summary.sample_count, 5U);
+ EXPECT_DOUBLE_EQ(frame_summary.channel_values_uv[0][LeadChannel_FP1], 100.0);
+ EXPECT_DOUBLE_EQ(frame_summary.channel_values_uv[4][LeadChannel_FP2], 204.0);
+
+ XYParserFrameSummary tail_summary{};
+ ASSERT_EQ(XYParser_FlushAlgorithmData(parser.get(), &tail_summary), 1);
+ EXPECT_EQ(tail_summary.sample_count, 2U);
+ EXPECT_DOUBLE_EQ(tail_summary.channel_values_uv[0][LeadChannel_FP1], 105.0);
+ EXPECT_DOUBLE_EQ(tail_summary.channel_values_uv[1][LeadChannel_FP2], 206.0);
+}
+
TEST(XYParserApiTests, GetLeadNameReturnsExpectedNames)
{
EXPECT_EQ(std::string(XYParser_GetLeadName(LeadChannel_FP1)), "FP1");
@@ -1865,6 +1945,21 @@ TEST(XYParserApiTests, Serialize64ImpedanceOpenCommandMatchesWirelessEegProtocol
EXPECT_TRUE(std::equal(expected.begin(), expected.end(), buffer.begin()));
}
+TEST(XYParserApiTests, Serialize2ImpedanceOpenCommandMatchesWirelessEegProtocol)
+{
+ std::array buffer{};
+ const int size = XYParser_Serialize2ChImpedanceCommand(1, buffer.data(), buffer.size());
+
+ ASSERT_EQ(size, static_cast(XYParser_Get2ChImpedanceCommandSize()));
+
+ const std::vector expected = {
+ 0xAA, 0x01, 0x00, 0x07,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x01, 0x09, 0x55, 0x55
+ };
+ EXPECT_TRUE(std::equal(expected.begin(), expected.end(), buffer.begin()));
+}
+
/// 测试:64 导增益和采样率命令序列化与 WirelessEEG 协议保持一致
TEST(XYParserApiTests, Serialize64GainAndSampleRateCommandMatchesWirelessEegProtocol)
{
@@ -1881,6 +1976,21 @@ TEST(XYParserApiTests, Serialize64GainAndSampleRateCommandMatchesWirelessEegProt
EXPECT_TRUE(std::equal(expected.begin(), expected.end(), buffer.begin()));
}
+TEST(XYParserApiTests, Serialize2GainAndSampleRateCommandMatchesWirelessEegProtocol)
+{
+ std::array buffer{};
+ const int size = XYParser_Serialize2ChGainSampleRateCommand(24, 1000, buffer.data(), buffer.size());
+
+ ASSERT_EQ(size, static_cast(XYParser_Get2ChGainSampleRateCommandSize()));
+
+ const std::vector expected = {
+ 0xAA, 0x02, 0x00, 0x08,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x18, 0x02, 0x24, 0x55, 0x55
+ };
+ EXPECT_TRUE(std::equal(expected.begin(), expected.end(), buffer.begin()));
+}
+
/// 测试:64 导增益和采样率命令拒绝非法采样率
TEST(XYParserApiTests, Serialize64GainAndSampleRateCommandRejectsUnsupportedSampleRate)
{
@@ -1961,6 +2071,28 @@ TEST(XYParserApiTests, FeedParses64ChannelFrame)
EXPECT_EQ(summaries[0].channel_count, 64U);
}
+TEST(XYParserApiTests, FeedParses2ChannelFrame)
+{
+ ParserGuard parser(XYParser_CreateParser(2));
+ ASSERT_NE(parser.get(), nullptr);
+
+ XYParser_SetAdcParams(parser.get(), 4.5, 6.0);
+ XYParser_SetBypassChecksum(parser.get(), 1);
+
+ const std::vector bytes = BuildMinimalFrame(2);
+ std::array summaries{};
+
+ const int frame_count = XYParser_Feed(
+ parser.get(),
+ bytes.data(),
+ bytes.size(),
+ summaries.data(),
+ static_cast(summaries.size()));
+
+ ASSERT_EQ(frame_count, 1);
+ EXPECT_EQ(summaries[0].channel_count, 2U);
+}
+
TEST(XYParserApiTests, FeedParsesReservedMetadataInto64ChannelSummary)
{
ParserGuard parser(XYParser_CreateParser(64));
diff --git a/XYParser/XYParserWorkflowDemo/README.md b/XYParser/XYParserWorkflowDemo/README.md
index 1be60a1..368899d 100644
--- a/XYParser/XYParserWorkflowDemo/README.md
+++ b/XYParser/XYParserWorkflowDemo/README.md
@@ -1,8 +1,9 @@
-# XYParser64Demo / XYParser8Demo
+# XYParser64Demo / XYParser2Demo / XYParser8Demo
业务流程 demo,可直接连你的设备模拟器验证:
- `XYParser64Demo`:TCP 接收 64 导数据,可额外打开一个串口发送 `TRAIN_0 / TRAIN_1`
+- `XYParser2Demo`:TCP 接收 2 导数据,固定映射 `FP1/FP2`,送算法前扩展为 64 导
- `XYParser8Demo`:串口接收 8 导数据
- `XYAlgorithmUdpServer`:算法 ZMQ 服务端,接收 demo 发来的算法输入并回包
- Welch/PSD 固定走 ZMQ:
@@ -44,7 +45,7 @@
联调顺序:
- 先启动 `XYAlgorithmUdpServer`
-- 再启动 `XYParser64Demo` 或 `XYParser8Demo`
+- 再启动 `XYParser64Demo`、`XYParser2Demo` 或 `XYParser8Demo`
- 如果 demo 端 `rxPackets` 开始增长,说明 ZMQ 链路已打通
## 64 导示例
@@ -72,6 +73,40 @@
- `--algorithm-port 8100`
- `--train-duration-ms 3000`
+## 2 导示例
+
+```powershell
+.\x64\Debug\XYParser2Demo.exe
+```
+
+默认:
+
+- 2 导数据走 TCP
+- 默认 TCP 主机为 `127.0.0.1`
+- 默认 TCP 端口为 `5086`
+- trigger 串口可选,不传则不打标
+- 启动后先按 2 导控制协议发送阻抗开启、`250Hz / 24增益`
+- 关闭阻抗后恢复 `250Hz / 6增益`
+- 算法前固定调用 `XYParser_Convert2ChFramesTo64Ch`
+- 2 个输入通道固定映射到 `FP1`、`FP2`
+
+2 导到 64 导导联映射图:
+
+```text
+2ch[0] -> FP1
+2ch[1] -> FP2
+others -> 0
+```
+
+常用覆盖参数:
+
+- `--tcp-host 127.0.0.1`
+- `--tcp-port 5086`
+- `--trigger-com COM44`
+- `--algorithm-host 127.0.0.1`
+- `--algorithm-port 8100`
+- `--train-duration-ms 3000`
+
## 8 导示例
```powershell
diff --git a/XYParser/XYParserWorkflowDemo/XYParser2Demo.vcxproj b/XYParser/XYParserWorkflowDemo/XYParser2Demo.vcxproj
new file mode 100644
index 0000000..b395b25
--- /dev/null
+++ b/XYParser/XYParserWorkflowDemo/XYParser2Demo.vcxproj
@@ -0,0 +1,178 @@
+
+
+
+
+ Debug
+ Win32
+
+
+ Release
+ Win32
+
+
+ Debug
+ x64
+
+
+ Release
+ x64
+
+
+
+ 17.0
+ Win32Proj
+ {D2E0C130-89D7-4E3A-A2C8-4F86A9A71E23}
+ XYParser2Demo
+ 10.0
+
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ false
+ v143
+ true
+ Unicode
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ false
+ v143
+ true
+ Unicode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ E:\Gitea\Swallow\XYParadigmDll\ZMQ
+ $(ZmqRoot)\include
+ $(ZmqRoot)\msvclib
+ $(ZmqLibDir)\libzmq-v142-mt-4_3_4.dll
+ E:\Gitea\Swallow\SwallowBCI\release\decoder_mainSSVEP\_internal\libsodium.dll
+
+
+ $(SolutionDir)$(Platform)\$(Configuration)\
+ $(SolutionDir)$(Platform)\$(Configuration)\XYParserWorkflow2Demo\
+ XYParser2Demo
+
+
+
+ $(ZmqIncludeDir);%(AdditionalIncludeDirectories)
+
+
+ $(ZmqLibDir);%(AdditionalLibraryDirectories)
+ libzmq-v142-mt-4_3_4.lib;%(AdditionalDependencies)
+
+
+ if exist "$(ZmqDllPath)" (copy /Y "$(ZmqDllPath)" "$(OutDir)" >nul 2>nul || echo Skip copying libzmq dll)
+if exist "$(SodiumDllPath)" (copy /Y "$(SodiumDllPath)" "$(OutDir)" >nul 2>nul || echo Skip copying libsodium dll)
+exit /b 0
+
+
+
+
+ Level3
+ true
+ WIN32;_DEBUG;_CONSOLE;XY_WORKFLOW_DEMO_2;%(PreprocessorDefinitions)
+ true
+ stdcpp17
+ $(SolutionDir);$(ProjectDir);%(AdditionalIncludeDirectories)
+ NotUsing
+
+
+ Console
+ true
+ Ws2_32.lib;%(AdditionalDependencies)
+
+
+
+
+ Level3
+ true
+ true
+ true
+ WIN32;NDEBUG;_CONSOLE;XY_WORKFLOW_DEMO_2;%(PreprocessorDefinitions)
+ true
+ stdcpp17
+ $(SolutionDir);$(ProjectDir);%(AdditionalIncludeDirectories)
+ NotUsing
+
+
+ Console
+ true
+ Ws2_32.lib;%(AdditionalDependencies)
+
+
+
+
+ Level3
+ true
+ _DEBUG;_CONSOLE;XY_WORKFLOW_DEMO_2;%(PreprocessorDefinitions)
+ true
+ stdcpp17
+ $(SolutionDir);$(ProjectDir);%(AdditionalIncludeDirectories)
+ NotUsing
+
+
+ Console
+ true
+ Ws2_32.lib;%(AdditionalDependencies)
+
+
+
+
+ Level3
+ true
+ true
+ true
+ NDEBUG;_CONSOLE;XY_WORKFLOW_DEMO_2;%(PreprocessorDefinitions)
+ true
+ stdcpp17
+ $(SolutionDir);$(ProjectDir);%(AdditionalIncludeDirectories)
+ NotUsing
+
+
+ Console
+ true
+ Ws2_32.lib;%(AdditionalDependencies)
+
+
+
+
+
+
+
+ {CB1FF804-BB1F-41C8-92FA-7B15F6B86347}
+ false
+ true
+ false
+
+
+
+
+
diff --git a/XYParser/XYParserWorkflowDemo/main.cpp b/XYParser/XYParserWorkflowDemo/main.cpp
index 26c79e8..5827ae9 100644
--- a/XYParser/XYParserWorkflowDemo/main.cpp
+++ b/XYParser/XYParserWorkflowDemo/main.cpp
@@ -26,7 +26,8 @@ using Clock = std::chrono::steady_clock;
enum class DemoMode {
Mode64Tcp,
- Mode8Serial
+ Mode8Serial,
+ Mode2Tcp
};
#if defined(XY_WORKFLOW_DEMO_64)
@@ -35,14 +36,23 @@ constexpr wchar_t kProgramName[] = L"XYParser64Demo";
constexpr char kDefaultTcpHost[] = "127.0.0.1";
constexpr wchar_t kDefaultDataComPort[] = L"";
constexpr wchar_t kDefaultTriggerComPort[] = L"COM44";
+constexpr double kDefaultVref = 4.5;
+#elif defined(XY_WORKFLOW_DEMO_2)
+constexpr DemoMode kDemoMode = DemoMode::Mode2Tcp;
+constexpr wchar_t kProgramName[] = L"XYParser2Demo";
+constexpr char kDefaultTcpHost[] = "127.0.0.1";
+constexpr wchar_t kDefaultDataComPort[] = L"";
+constexpr wchar_t kDefaultTriggerComPort[] = L"";
+constexpr double kDefaultVref = 2.42;
#elif defined(XY_WORKFLOW_DEMO_8)
constexpr DemoMode kDemoMode = DemoMode::Mode8Serial;
constexpr wchar_t kProgramName[] = L"XYParser8Demo";
constexpr char kDefaultTcpHost[] = "127.0.0.1";
constexpr wchar_t kDefaultDataComPort[] = L"COM44";
constexpr wchar_t kDefaultTriggerComPort[] = L"";
+constexpr double kDefaultVref = 4.5;
#else
-#error Define either XY_WORKFLOW_DEMO_64 or XY_WORKFLOW_DEMO_8 for this target.
+#error Define either XY_WORKFLOW_DEMO_64, XY_WORKFLOW_DEMO_2 or XY_WORKFLOW_DEMO_8 for this target.
#endif
struct ZmqRoundTripStats {
@@ -58,7 +68,7 @@ struct DemoOptions {
DemoMode mode = kDemoMode;
std::string tcp_host = kDefaultTcpHost;
int tcp_port = 5086;
- std::string algorithm_host = "192.168.254.102";
+ std::string algorithm_host = "127.0.0.1";
int algorithm_port = 8100;
int algorithm_timeout_ms = 1000;
std::wstring data_com_port = kDefaultDataComPort;
@@ -66,7 +76,7 @@ struct DemoOptions {
int serial_baud_rate = 460800;
int sample_rate = 250;
int gain = 6;
- double vref = 4.5;
+ double vref = kDefaultVref;
int bypass_checksum = 1;
int train_duration_ms = 3000;
};
@@ -623,7 +633,7 @@ void PrintUsage()
{
std::wcout
<< kProgramName << L" usage:\n";
-#if defined(XY_WORKFLOW_DEMO_64)
+#if defined(XY_WORKFLOW_DEMO_64) || defined(XY_WORKFLOW_DEMO_2)
std::wcout
<< L" " << kProgramName << L" [--tcp-host 127.0.0.1] [--tcp-port 5086]\n"
<< L" [--trigger-com COM44]\n"
@@ -641,6 +651,9 @@ void PrintUsage()
#if defined(XY_WORKFLOW_DEMO_64)
<< L" - Default TCP host is 127.0.0.1 and default trigger serial is COM44.\n"
<< L" - Receive 64ch stream from TCP, optional trigger serial for TRAIN_0/TRAIN_1.\n"
+#elif defined(XY_WORKFLOW_DEMO_2)
+ << L" - Default TCP host is 127.0.0.1 and trigger serial is optional.\n"
+ << L" - Receive 2ch stream from TCP, map FP1/FP2 to 64ch before algorithm and Welch.\n"
#else
<< L" - Default 8ch data serial is COM44.\n"
<< L" - Receive 8ch stream from serial, then run 8ch -> 64ch -> algorithm data -> Welch.\n"
@@ -705,7 +718,7 @@ bool ParseArguments(int argc, wchar_t* argv[], DemoOptions& options)
return false;
}
-#if defined(XY_WORKFLOW_DEMO_64)
+#if defined(XY_WORKFLOW_DEMO_64) || defined(XY_WORKFLOW_DEMO_2)
return options.tcp_port > 0;
#else
return !options.data_com_port.empty();
@@ -832,21 +845,54 @@ void PrintWelchSummary(const XYParserWelchSummary& summary)
const char* gamma_name = XYParser_GetWelchBandName(4);
const char* lead_name = XYParser_GetLeadName(lead);
- std::cout << "[Welch] sampleRate=" << summary.sample_rate
- << " window=" << summary.window_sample_count
- << " freqCount=" << summary.frequency_count
- << " lead=" << (lead_name != nullptr ? lead_name : "lead?");
+ std::ostringstream stream;
+ stream << "[Welch] sampleRate=" << summary.sample_rate
+ << " window=" << summary.window_sample_count
+ << " freqCount=" << summary.frequency_count
+ << " lead=" << (lead_name != nullptr ? lead_name : "lead?");
if (peak_index >= 0) {
- std::cout << " peakHz=" << summary.frequencies[static_cast(peak_index)]
- << " peakPsd=" << summary.psd_values[lead][static_cast(peak_index)];
+ stream << " peakHz=" << summary.frequencies[static_cast(peak_index)]
+ << " peakPsd=" << summary.psd_values[lead][static_cast(peak_index)];
}
- std::cout << ' ' << (alpha_name != nullptr ? alpha_name : "alpha")
- << '=' << summary.band_values[2][lead]
- << ' ' << (gamma_name != nullptr ? gamma_name : "gamma")
- << '=' << summary.band_values[4][lead]
- << std::endl;
+ stream << ' ' << (alpha_name != nullptr ? alpha_name : "alpha")
+ << '=' << summary.band_values[2][lead]
+ << ' ' << (gamma_name != nullptr ? gamma_name : "gamma")
+ << '=' << summary.band_values[4][lead];
+
+ static std::string last_welch_line;
+ const std::string current_line = stream.str();
+ if (current_line == last_welch_line) {
+ return;
+ }
+
+ last_welch_line = current_line;
+ std::cout << current_line << std::endl;
}
+#if defined(XY_WORKFLOW_DEMO_2)
+void Print2ChAlgorithmPreview(const char* tag,
+ const double* values,
+ std::size_t sample_count,
+ std::size_t stride)
+{
+ if (values == nullptr || stride < 2) {
+ return;
+ }
+
+ std::ostringstream stream;
+ stream << tag;
+ const std::size_t preview_count = (sample_count < 5) ? sample_count : 5;
+ for (std::size_t sample_index = 0; sample_index < preview_count; ++sample_index) {
+ const std::size_t sample_offset = sample_index * stride;
+ stream << " s" << sample_index
+ << "(FP1=" << values[sample_offset + static_cast(LeadChannel_FP1)]
+ << ",FP2=" << values[sample_offset + static_cast(LeadChannel_FP2)]
+ << ')';
+ }
+ std::cout << stream.str() << std::endl;
+}
+#endif
+
void DrainImpedance(XYParserHandle parser)
{
std::array summaries{};
@@ -933,11 +979,26 @@ bool RunAlgorithmZmqRoundTrip(XYParserHandle parser,
std::cout << "[AlgorithmZmqRx] payloadBytes=" << received
<< " doubles=" << (received / static_cast(sizeof(double)))
<< std::endl;
- XYParser_FeedAlgorithmData(parser,
- response_payload.data(),
- response_payload.size(),
- nullptr,
- 0);
+#if defined(XY_WORKFLOW_DEMO_2)
+ static int rx_preview_count = 0;
+ const std::size_t rx_sample_count =
+ response_payload.size() / (static_cast(XYPARSER_MAX_CHANNELS) * sizeof(double));
+ if (rx_preview_count < 3 && rx_sample_count > 0) {
+ Print2ChAlgorithmPreview("[AlgorithmRxPreview]",
+ reinterpret_cast(response_payload.data()),
+ rx_sample_count,
+ XYPARSER_MAX_CHANNELS);
+ ++rx_preview_count;
+ }
+#endif
+ const int algorithm_frame_count = XYParser_FeedAlgorithmData(parser,
+ response_payload.data(),
+ response_payload.size(),
+ nullptr,
+ 0);
+#if defined(XY_WORKFLOW_DEMO_2)
+ std::cout << "[AlgorithmFeed] frameCount=" << algorithm_frame_count << std::endl;
+#endif
}
if (received_any_response || stats.sent_packets <= 3 || (stats.sent_packets % 100) == 0) {
@@ -960,6 +1021,20 @@ bool Send64GainAndSampleRate(TcpClient& client, int gain, int sample_rate)
return client.Send(command.data(), static_cast(size));
}
+bool Send2GainAndSampleRate(TcpClient& client, int gain, int sample_rate)
+{
+ std::array command{};
+ const int size = XYParser_Serialize2ChGainSampleRateCommand(static_cast(gain),
+ static_cast(sample_rate),
+ command.data(),
+ command.size());
+ if (size <= 0) {
+ return false;
+ }
+ PrintBytes("Send2GainSampleRate", command.data(), static_cast(size));
+ return client.Send(command.data(), static_cast(size));
+}
+
bool Send64ImpedanceSwitch(TcpClient& client, bool open)
{
std::array command{};
@@ -973,6 +1048,19 @@ bool Send64ImpedanceSwitch(TcpClient& client, bool open)
return client.Send(command.data(), static_cast(size));
}
+bool Send2ImpedanceSwitch(TcpClient& client, bool open)
+{
+ std::array command{};
+ const int size = XYParser_Serialize2ChImpedanceCommand(open ? 1 : 0, command.data(), command.size());
+ if (size <= 0) {
+ return false;
+ }
+ PrintBytes(open ? "Send2ImpedanceOpen" : "Send2ImpedanceClose",
+ command.data(),
+ static_cast(size));
+ return client.Send(command.data(), static_cast(size));
+}
+
bool Send8ImpedanceSwitch(SerialPort& port, bool open)
{
std::array command{};
@@ -1012,6 +1100,36 @@ bool Process8AlgorithmPath(XYParserHandle parser,
return Process64AlgorithmPath(parser, converted, zmq_client, zmq_stats);
}
+bool Process2AlgorithmPath(XYParserHandle parser,
+ const XYParserFrameSummary& summary,
+ ZmqDuplexClient& zmq_client,
+ ZmqRoundTripStats& zmq_stats)
+{
+ XYParserFrameSummary converted{};
+ if (XYParser_Convert2ChFramesTo64Ch(&summary, 1, &converted, 1) != 1) {
+ std::cerr << "Convert2ChFramesTo64Ch failed" << std::endl;
+ return false;
+ }
+
+ std::array algorithm_data{};
+ if (XYParser_ConvertSampleFramesToAlgorithmData(&converted, algorithm_data.data()) == 0) {
+ std::cerr << "ConvertSampleFramesToAlgorithmData failed: " << DescribeParserError(parser) << std::endl;
+ return false;
+ }
+
+#if defined(XY_WORKFLOW_DEMO_2)
+ static int tx_preview_count = 0;
+ if (tx_preview_count < 3) {
+ Print2ChAlgorithmPreview("[AlgorithmTxPreview]",
+ algorithm_data.data(),
+ XYPARSER_SAMPLES_PER_FRAME,
+ XYPARSER_FRAME_DATA_COLUMN_COUNT);
+ ++tx_preview_count;
+ }
+#endif
+ return RunAlgorithmZmqRoundTrip(parser, zmq_client, algorithm_data.data(), zmq_stats);
+}
+
bool SendTrigger(SerialPort& port, XYParserTriggerType trigger_type)
{
std::array command{};
@@ -1031,7 +1149,7 @@ void PrintFrameProgress(const XYParserFrameSummary* summaries, int count)
return;
}
const XYParserFrameSummary& last = summaries[static_cast(count - 1)];
-#if defined(XY_WORKFLOW_DEMO_64)
+#if defined(XY_WORKFLOW_DEMO_64) || defined(XY_WORKFLOW_DEMO_2)
struct DeviceStateSnapshot {
std::uint8_t impedance_enabled = 0;
std::uint8_t current_gain = 0;
@@ -1347,6 +1465,205 @@ int Run64Workflow(const DemoOptions& options)
return 0;
}
+int Run2Workflow(const DemoOptions& options)
+{
+ constexpr auto kImpedanceDuration = std::chrono::seconds(10);
+ constexpr int kImpedanceSampleRate = 250;
+ constexpr int kImpedanceGain = 24;
+ constexpr int kNormalSampleRate = 250;
+ constexpr int kNormalGain = 6;
+ WinsockRuntime winsock;
+ if (!winsock.ok()) {
+ std::cerr << "WSAStartup failed" << std::endl;
+ return 1;
+ }
+
+ TcpClient data_client;
+ if (!data_client.Connect(options.tcp_host, options.tcp_port)) {
+ std::cerr << "Connect 2ch TCP failed: " << options.tcp_host << ':' << options.tcp_port << std::endl;
+ return 1;
+ }
+ bool tcp_connected = true;
+ const auto close_data_tcp = [&]() {
+ if (!tcp_connected) {
+ return;
+ }
+ std::cout << "Close 2ch TCP connection" << std::endl;
+ data_client.Close();
+ tcp_connected = false;
+ };
+
+ SerialPort trigger_port;
+ const bool has_trigger_port = !options.trigger_com_port.empty();
+ bool trigger_serial_open = false;
+ const auto close_trigger_serial = [&]() {
+ if (!trigger_serial_open) {
+ return;
+ }
+ std::cout << "Close trigger serial" << std::endl;
+ trigger_port.Close();
+ trigger_serial_open = false;
+ };
+ if (has_trigger_port && !trigger_port.Open(options.trigger_com_port, options.serial_baud_rate)) {
+ std::cerr << "Open trigger serial failed: " << Narrow(options.trigger_com_port) << std::endl;
+ close_data_tcp();
+ return 1;
+ }
+ trigger_serial_open = has_trigger_port;
+
+ ParserHandleGuard parser(XYParser_CreateParser(2));
+ if (parser.get() == nullptr) {
+ std::cerr << "Create 2ch parser failed" << std::endl;
+ close_data_tcp();
+ return 1;
+ }
+
+ XYParser_SetAdcParams(parser.get(), options.vref, static_cast(kNormalGain));
+ XYParser_SetSampleRate(parser.get(), kNormalSampleRate);
+ XYParser_SetBypassChecksum(parser.get(), options.bypass_checksum);
+ XYParser_SetWelchDetection(parser.get(), 0);
+ XYParser_SetImpedanceDetection(parser.get(), 0);
+
+ if (!Send2ImpedanceSwitch(data_client, true)) {
+ std::cerr << "Send 2ch impedance open command failed" << std::endl;
+ close_data_tcp();
+ return 1;
+ }
+ if (!Send2GainAndSampleRate(data_client, kImpedanceGain, kImpedanceSampleRate)) {
+ std::cerr << "Send 2ch impedance gain/sample-rate command failed" << std::endl;
+ close_data_tcp();
+ return 1;
+ }
+ XYParser_SetSampleRate(parser.get(), kImpedanceSampleRate);
+ XYParser_SetImpedanceDetection(parser.get(), 1);
+
+ ZmqDuplexClient algorithm_zmq;
+ bool algorithm_zmq_open = false;
+ const auto close_algorithm_zmq = [&]() {
+ if (!algorithm_zmq_open) {
+ return;
+ }
+ std::cout << "Close algorithm ZMQ" << std::endl;
+ algorithm_zmq.Close();
+ algorithm_zmq_open = false;
+ };
+ const auto close_all = [&]() {
+ close_algorithm_zmq();
+ close_trigger_serial();
+ close_data_tcp();
+ };
+ if (!algorithm_zmq.Open(options.algorithm_host,
+ options.algorithm_port,
+ options.algorithm_timeout_ms)) {
+ std::cerr << "Open algorithm ZMQ client failed"
+ << " remote=" << BuildZmqTcpEndpoint(options.algorithm_host, options.algorithm_port)
+ << " zmqError=" << algorithm_zmq.last_error()
+ << std::endl;
+ close_all();
+ return 1;
+ }
+ algorithm_zmq_open = true;
+ std::cout << "Algorithm ZMQ enabled: identity=" << algorithm_zmq.identity()
+ << " algorithmHost=" << options.algorithm_host
+ << " algorithmPort=" << options.algorithm_port
+ << std::endl;
+ std::cout << "2ch impedance enabled for "
+ << std::chrono::duration_cast(kImpedanceDuration).count()
+ << " seconds before 2ch->64ch algorithm path" << std::endl;
+ std::cout << "Welch waiting: impedance phase" << std::endl;
+ if (has_trigger_port) {
+ std::cout << "2ch cyclic trigger armed: alternating TRAIN_0/TRAIN_1 every "
+ << options.train_duration_ms << " ms" << std::endl;
+ }
+
+ XYParserTriggerType next_trigger_type = XYPARSER_TRIGGER_TRAIN_0;
+ bool impedance_phase = true;
+ bool logged_waiting_algorithm_rx = false;
+ const Clock::time_point impedance_end_time = Clock::now() + kImpedanceDuration;
+ Clock::time_point next_trigger_time = Clock::time_point::max();
+
+ std::array read_buffer{};
+ std::array frame_summaries{};
+ ZmqRoundTripStats zmq_stats{};
+
+ while (true) {
+ if (!impedance_phase && has_trigger_port && Clock::now() >= next_trigger_time) {
+ if (!SendTrigger(trigger_port, next_trigger_type)) {
+ std::cerr << "Send "
+ << (next_trigger_type == XYPARSER_TRIGGER_TRAIN_0 ? "TRAIN_0" : "TRAIN_1")
+ << " failed" << std::endl;
+ close_all();
+ return 1;
+ }
+ next_trigger_type = (next_trigger_type == XYPARSER_TRIGGER_TRAIN_0)
+ ? XYPARSER_TRIGGER_TRAIN_1
+ : XYPARSER_TRIGGER_TRAIN_0;
+ next_trigger_time = Clock::now() + std::chrono::milliseconds(options.train_duration_ms);
+ }
+
+ bool disconnected = false;
+ const int received = data_client.Receive(read_buffer.data(), static_cast(read_buffer.size()), disconnected);
+ if (disconnected) {
+ std::cout << "2ch TCP disconnected" << std::endl;
+ break;
+ }
+ if (received <= 0) {
+ continue;
+ }
+
+ const int frame_count = XYParser_Feed(parser.get(),
+ read_buffer.data(),
+ static_cast(received),
+ frame_summaries.data(),
+ static_cast(frame_summaries.size()));
+ if (frame_count > 0) {
+ PrintFrameProgress(frame_summaries.data(), frame_count);
+ }
+ if (impedance_phase) {
+ DrainImpedance(parser.get());
+ if (Clock::now() >= impedance_end_time) {
+ if (!Send2GainAndSampleRate(data_client, kNormalGain, kNormalSampleRate)) {
+ std::cerr << "Send 2ch normal gain/sample-rate command failed" << std::endl;
+ close_all();
+ return 1;
+ }
+ if (!Send2ImpedanceSwitch(data_client, false)) {
+ std::cerr << "Send 2ch impedance close command failed" << std::endl;
+ close_all();
+ return 1;
+ }
+ XYParser_SetSampleRate(parser.get(), kNormalSampleRate);
+ XYParser_SetImpedanceDetection(parser.get(), 0);
+ XYParser_SetWelchDetection(parser.get(), 1);
+ DrainImpedance(parser.get());
+ std::cout << "2ch impedance disabled after "
+ << std::chrono::duration_cast(kImpedanceDuration).count()
+ << " seconds, Welch/PSD and 2ch->64ch algorithm path resumed" << std::endl;
+ std::cout << "Welch waiting: no algorithm rx yet" << std::endl;
+ logged_waiting_algorithm_rx = true;
+ impedance_phase = false;
+ next_trigger_time = Clock::now();
+ }
+ continue;
+ }
+ for (int i = 0; i < frame_count; ++i) {
+ PrintTriggerEvents(frame_summaries[static_cast(i)]);
+ Process2AlgorithmPath(parser.get(),
+ frame_summaries[static_cast(i)],
+ algorithm_zmq,
+ zmq_stats);
+ }
+ if (logged_waiting_algorithm_rx && zmq_stats.received_packets > 0) {
+ std::cout << "Welch input ready: algorithm rx started" << std::endl;
+ logged_waiting_algorithm_rx = false;
+ }
+ DrainWelch(parser.get());
+ }
+
+ close_all();
+ return 0;
+}
+
int Run8Workflow(const DemoOptions& options)
{
constexpr auto kImpedanceDuration = std::chrono::seconds(60);
@@ -1486,6 +1803,9 @@ int wmain(int argc, wchar_t* argv[])
#if defined(XY_WORKFLOW_DEMO_64)
constexpr int kImpedanceEnabled = 0;
constexpr int kParserGainDuringRun = 6;
+#elif defined(XY_WORKFLOW_DEMO_2)
+ constexpr int kImpedanceEnabled = 0;
+ constexpr int kParserGainDuringRun = 6;
#else
constexpr int kImpedanceEnabled = 1;
constexpr int kParserGainDuringRun = 24;
@@ -1513,6 +1833,8 @@ int wmain(int argc, wchar_t* argv[])
#if defined(XY_WORKFLOW_DEMO_64)
return Run64Workflow(options);
+#elif defined(XY_WORKFLOW_DEMO_2)
+ return Run2Workflow(options);
#else
return Run8Workflow(options);
#endif
diff --git a/XYParserDataFlow.md b/XYParserDataFlow.md
index 86d1b85..8b45945 100644
--- a/XYParserDataFlow.md
+++ b/XYParserDataFlow.md
@@ -258,5 +258,174 @@ XYParser_Feed(8导原始数据)
代码依据:
- `XYParser_Convert8ChFramesTo64Ch`
-- `Convert8ChSummaryTo64ChSummary`
-- 8导映射表 `k8ChLeadMap`
+
+### 3.9 2导初始化连接阶段
+
+```mermaid
+%%{init: {'theme': 'default', 'sequence': {'diagramMarginX': 80, 'diagramMarginY': 30, 'actorMargin': 80, 'width': 220, 'height': 80, 'messageMargin': 35}}}%%
+sequenceDiagram
+ participant Dev as 2导EEG采集设备
+ participant Host as 上位机
+ participant Lib as XYParser库
+
+ Dev-->>Host: 设备连接成功
+ Host->>Lib: parser2 = XYParser_CreateParser(2)
+ Host->>Lib: XYParser_SetAdcParams(parser2, 2.42, 6.0)
+ Note over Host,Lib: 2导默认 vref = 2.42,与 8/64 导不同
+ Host->>Lib: XYParser_SetSampleRate(parser2, 250)
+ Host->>Lib: XYParser_SetBypassChecksum(parser2, 1)
+ Host->>Lib: gain_cmd_size = XYParser_Get2ChGainSampleRateCommandSize()
+ Lib-->>Host: gain_cmd_size
+ Host->>Lib: gain_cmd_bytes = XYParser_Serialize2ChGainSampleRateCommand(6, 250, gain_cmd_buf, gain_cmd_size)
+ Lib-->>Host: gain_cmd_bytes
+ Host->>Dev: 下发采样率250、增益6命令
+```
+
+### 3.10 2导阻抗阶段
+
+```mermaid
+%%{init: {'theme': 'default', 'sequence': {'diagramMarginX': 80, 'diagramMarginY': 30, 'actorMargin': 80, 'width': 220, 'height': 80, 'messageMargin': 35}}}%%
+sequenceDiagram
+ participant Dev as 2导EEG采集设备
+ participant Host as 上位机
+ participant Lib as XYParser库
+
+ Host->>Lib: XYParser_SetImpedanceDetection(parser2, 1)
+ Host->>Lib: XYParser_SetAdcParams(parser2, 2.42, 24.0)
+ Note over Host,Lib: 2导阻抗阶段仍使用 vref = 2.42
+ Host->>Lib: impedance_gain_cmd_size = XYParser_Get2ChGainSampleRateCommandSize()
+ Lib-->>Host: impedance_gain_cmd_size
+ Host->>Lib: impedance_gain_cmd_bytes = XYParser_Serialize2ChGainSampleRateCommand(24, 250, impedance_gain_cmd_buf, impedance_gain_cmd_size)
+ Lib-->>Host: impedance_gain_cmd_bytes
+ Host->>Dev: 下发采样率250、增益24命令
+ Host->>Lib: impedance_cmd_size = XYParser_Get2ChImpedanceCommandSize()
+ Lib-->>Host: impedance_cmd_size
+ Host->>Lib: open_impedance_bytes = XYParser_Serialize2ChImpedanceCommand(1, impedance_cmd_buf, impedance_cmd_size)
+ Lib-->>Host: open_impedance_bytes
+ Host->>Dev: 下发阻抗开启命令
+
+ loop 持续获取阻抗
+ Dev-->>Host: 原始EEG字节流
+ Host->>Lib: frame_count = XYParser_Feed(parser2, raw_data, raw_size, frame2_summaries, max_frames)
+ Lib-->>Host: frame_count + frame2_summaries
+ Host->>Lib: impedance_count = XYParser_ReadImpedance(parser2, impedance_summaries, max_impedance)
+ Lib-->>Host: impedance_count + impedance_summaries
+ end
+
+ Host->>Lib: close_impedance_bytes = XYParser_Serialize2ChImpedanceCommand(0, impedance_cmd_buf, impedance_cmd_size)
+ Lib-->>Host: close_impedance_bytes
+ Host->>Dev: 下发阻抗关闭命令
+ Host->>Lib: restore_gain_cmd_size = XYParser_Get2ChGainSampleRateCommandSize()
+ Lib-->>Host: restore_gain_cmd_size
+ Host->>Lib: restore_gain_cmd_bytes = XYParser_Serialize2ChGainSampleRateCommand(6, 250, restore_gain_cmd_buf, restore_gain_cmd_size)
+ Lib-->>Host: restore_gain_cmd_bytes
+ Host->>Dev: 下发采样率250、增益6命令
+ Host->>Lib: XYParser_SetAdcParams(parser2, 2.42, 6.0)
+ Host->>Lib: XYParser_SetImpedanceDetection(parser2, 0)
+```
+
+### 3.11 2导转64导导联映射关系
+
+2导 workflow 在送入算法前,会先调用 `XYParser_Convert2ChFramesTo64Ch` 将 2 导帧扩展为 64 导帧。
+
+- 2 个输入通道按固定导联位置写入 64 导 summary
+- 未覆盖到的其余 62 个 64 导导联全部补 `0`
+- `trigger type` 和 `trigger index` 原样透传
+
+映射图如下:
+
+```text
+2ch[0] -> FP1
+2ch[1] -> FP2
+others -> 0
+```
+
+也可以理解为下面这张对应表:
+
+| 2导索引 | 2导写入到的64导导联 |
+| --- | --- |
+| 0 | FP1 |
+| 1 | FP2 |
+
+转换过程示意:
+
+```text
+XYParser_Feed(2导原始数据)
+ -> frame2_summary
+ -> XYParser_Convert2ChFramesTo64Ch
+ -> frame64_summary
+ -> XYParser_ConvertSampleFramesToAlgorithmData
+ -> algorithm_input_data
+```
+
+代码依据:
+
+- `XYParser_Convert2ChFramesTo64Ch`
+- `Convert2ChSummaryTo64ChSummary`
+- `k2ChLeadMap = { FP1, FP2 }`
+
+### 3.12 2导 workflow 时序图
+
+2导 demo 的完整链路与 64 导 workflow 保持一致,只是前端输入是 2 导,送算法前会先补成 64 导。
+
+```mermaid
+%%{init: {'theme': 'default', 'sequence': {'diagramMarginX': 80, 'diagramMarginY': 30, 'actorMargin': 80, 'width': 220, 'height': 80, 'messageMargin': 35}}}%%
+sequenceDiagram
+ participant Dev as 2导EEG采集设备
+ participant Host as 上位机
+ participant Lib as XYParser库
+ participant Algo as 算法
+
+ Host->>Lib: XYParser_SetWelchDetection(parser2, 1)
+
+ loop 持续采集
+ Dev-->>Host: 原始EEG字节流
+ Host->>Lib: frame_count = XYParser_Feed(parser2, raw_data, raw_size, frame2_summaries, max_frames)
+ Lib-->>Host: frame_count + frame2_summaries
+ Host->>Lib: converted = XYParser_Convert2ChFramesTo64Ch(frame2_summary, 1, frame64_summary, 1)
+ Lib-->>Host: converted + frame64_summary
+ Host->>Lib: value_count = XYParser_GetAlgorithmDataValueCount()
+ Lib-->>Host: value_count
+ Host->>Lib: ok = XYParser_ConvertSampleFramesToAlgorithmData(frame64_summary, algorithm_input_data)
+ Lib-->>Host: ok + algorithm_input_data
+ Host->>Algo: 输入算法数据 algorithm_input_data
+ Algo-->>Host: 算法输出数据 algorithm_output_bytes
+ Host->>Lib: alg_frame_count = XYParser_FeedAlgorithmData(parser2, algorithm_output_bytes, algorithm_output_size, algorithm_frames, max_algorithm_frames)
+ Lib-->>Host: alg_frame_count + algorithm_frames
+ Host->>Lib: welch_count = XYParser_ReadWelch(parser2, welch_summaries, max_welch)
+ Lib-->>Host: welch_count + welch_summaries
+ end
+
+ opt 结束时处理尾数据
+ Host->>Lib: flushed = XYParser_FlushAlgorithmData(parser2, tail_frame_summary)
+ Lib-->>Host: flushed + tail_frame_summary
+ Host->>Lib: welch_count = XYParser_ReadWelch(parser2, welch_summaries, max_welch)
+ Lib-->>Host: welch_count + welch_summaries
+ end
+
+ Host->>Lib: XYParser_DestroyParser(parser2)
+```
+
+时序步骤如下:
+
+1. `XYParser2Demo` 通过 TCP 接收 2 导原始数据。
+2. `XYParser_Feed(handle=2)` 解析出 `frame2_summary`。
+3. `XYParser_Convert2ChFramesTo64Ch` 将 `FP1/FP2` 写入 64 导 summary,其余导联补 `0`。
+4. `XYParser_ConvertSampleFramesToAlgorithmData` 将 64 导 summary 打平成算法输入。
+5. ZMQ 将 64 通道 payload 发给算法服务端。
+6. 算法服务端回 64 通道结果。
+7. `XYParser_FeedAlgorithmData` 将算法回包喂回 parser。
+8. Welch/PSD 输出 `peakHz`、`peakPsd`、各 band 能量等结果。
+
+如果打开阻抗流程,则 2 导还会额外穿插以下控制阶段:
+
+- 发送 2 导阻抗开启命令
+- 发送 `250Hz / 24增益`
+- 打开 parser 阻抗开关
+- 读取并打印阻抗
+- 阻抗结束后发送 `250Hz / 6增益`
+- 发送 2 导阻抗关闭命令
+- 关闭 parser 阻抗开关
+- 恢复 2->64->算法->Welch 主链路
+- `Convert2ChSummaryTo64ChSummary`
+- 2导映射表 `k2ChLeadMap`