From 8b934370281c1d9da8c07d3d496e4309eed8393e Mon Sep 17 00:00:00 2001 From: lvpeng Date: Tue, 9 Jun 2026 22:28:52 +0800 Subject: [PATCH] =?UTF-8?q?2=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XYParser/XYEegParser2.h | 10 + XYParser/XYParser.sln | 10 + XYParser/XYParser.vcxproj | 1 + XYParser/XYParserApi.cpp | 143 ++++++- XYParser/XYParserApi.h | 44 ++- XYParser/XYParserTests/Tests.cpp | 132 +++++++ XYParser/XYParserWorkflowDemo/README.md | 39 +- .../XYParser2Demo.vcxproj | 178 +++++++++ XYParser/XYParserWorkflowDemo/main.cpp | 368 ++++++++++++++++-- XYParserDataFlow.md | 173 +++++++- 10 files changed, 1061 insertions(+), 37 deletions(-) create mode 100644 XYParser/XYEegParser2.h create mode 100644 XYParser/XYParserWorkflowDemo/XYParser2Demo.vcxproj 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`