diff --git a/CMakeLists.txt b/CMakeLists.txt index 790e69cd6..0d2aee725 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ add_library(ariec61850_core src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/compiled_device_profile.cpp + src/sampled_values/m4_three_sv_roster.cpp src/sampled_values/quality.cpp src/sampled_values/payload_inspector.cpp src/sampled_values/sample_counter.cpp @@ -313,6 +314,16 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_scl_tests) add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests) + add_executable(ariec61850_m4_three_sv_roster_tests tests/test_m4_three_sv_roster.cpp) + target_link_libraries(ariec61850_m4_three_sv_roster_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_m4_three_sv_roster_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_m4_three_sv_roster_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_m4_three_sv_roster_tests) + ariec61850_apply_sanitizers(ariec61850_m4_three_sv_roster_tests) + add_test(NAME ariec61850_m4_three_sv_roster_tests + COMMAND ariec61850_m4_three_sv_roster_tests) + add_executable(ariec61850_simulation_tests tests/test_simulation.cpp) target_link_libraries(ariec61850_simulation_tests PRIVATE ARIEC61850::core) target_compile_definitions(ariec61850_simulation_tests PRIVATE diff --git a/include/ariec61850/sampled_values/m4_three_sv_roster.hpp b/include/ariec61850/sampled_values/m4_three_sv_roster.hpp new file mode 100644 index 000000000..98e669ca2 --- /dev/null +++ b/include/ariec61850/sampled_values/m4_three_sv_roster.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// M4.0 intentionally fixes the number of source-backed virtual MUs at three. +// A firmware capability change, rather than an unbounded vector, raises this limit. +inline constexpr std::size_t m4_three_sv_capacity = 3U; +inline constexpr std::uint32_t m4_three_sv_rate_hz = 4000U; + +struct M4CompiledSvStream final { + std::string ied_name; + std::string control_block_reference; + CompiledSvDeviceProfile device_profile; +}; + +struct M4ThreeSvRoster final { + std::array streams{}; + std::uint32_t common_sample_rate_hz{m4_three_sv_rate_hz}; + std::uint32_t total_ethernet_frames_per_second{ + static_cast(m4_three_sv_capacity) * m4_three_sv_rate_hz}; +}; + +struct M4ThreeSvCompileResult final { + std::optional roster; + std::vector errors; + [[nodiscard]] bool ok() const noexcept { return roster.has_value() && errors.empty(); } +}; + +// Pure host-side compiler. No hardware/clock/USB dependency. Never parses XML +// twice: the existing SclParser creates SclDocument and the existing one-stream +// compiler/codec remains the only authority for each stream's wire fields. +// The caller EXPLICITLY selects the 9-2LE family and counter policy. +class M4ThreeSvRosterCompiler final { +public: + [[nodiscard]] static M4ThreeSvCompileResult compile( + const scl::SclDocument& document, + const SvPublisherProfileCompileContext& context); +}; + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/m4_three_sv_roster.cpp b/src/sampled_values/m4_three_sv_roster.cpp new file mode 100644 index 000000000..09ab8dec2 --- /dev/null +++ b/src/sampled_values/m4_three_sv_roster.cpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/m4_three_sv_roster.hpp" +#include "ariec61850/sampled_values/esp32p4_profile_support.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { +namespace { + +[[nodiscard]] bool same_wire_identity( + const CompiledSvDeviceProfile& lhs, + const CompiledSvDeviceProfile& rhs) noexcept { + const auto id = [](const CompiledSvDeviceProfile& p) { + return std::string_view{p.sv_id.data(), p.sv_id_length}; + }; + return lhs.app_id == rhs.app_id && + lhs.destination_mac == rhs.destination_mac && + lhs.vlan_present == rhs.vlan_present && + (!lhs.vlan_present || + (lhs.vlan_id == rhs.vlan_id && + lhs.vlan_priority == rhs.vlan_priority)) && + lhs.transport_mode == rhs.transport_mode && + id(lhs) == id(rhs); +} + +} // namespace + +M4ThreeSvCompileResult M4ThreeSvRosterCompiler::compile( + const scl::SclDocument& document, + const SvPublisherProfileCompileContext& context) { + M4ThreeSvCompileResult result; + if (document.sampled_values_streams.size() != m4_three_sv_capacity) { + result.errors.push_back( + "M4.0 requires exactly three SCL SampledValueControl streams; found " + + std::to_string(document.sampled_values_streams.size())); + return result; + } + + // The family is deliberate engineering intent, never inferred from a + // filename, APPID, DataSet, or vendor metadata. + if (context.profile_family != SvProfileFamily::legacy_9_2le && + context.profile_family != SvProfileFamily::iec61850_9_2) { + result.errors.push_back( + "Select an explicit supported IEC 61850-9-2/9-2LE profile family."); + return result; + } + if (context.sample_counter_modulus != + std::optional{static_cast(4000U)}) { + result.errors.push_back( + "M4.0 needs an explicitly validated 4000-sample counter modulus " + "for one shared PTP-aligned second."); + return result; + } + + M4ThreeSvRoster candidate; + for (std::size_t i = 0U; i < m4_three_sv_capacity; ++i) { + const auto& stream = document.sampled_values_streams[i]; + const std::string name = stream.ied_name + " / " + stream.control_name; + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + for (const auto& error : compiled.errors) { + result.errors.push_back(name + ": " + error); + } + if (!compiled.ok()) continue; + + const SvPublisherProfile& p = *compiled.profile; + if (p.timing.exact_sample_rate_hz() != + std::optional{m4_three_sv_rate_hz} || + p.timing.exact_frame_rate_hz() != + std::optional{m4_three_sv_rate_hz} || + p.timing.asdus_per_frame != 1U || + p.sample_counter_modulus != context.sample_counter_modulus || + !p.asdu_options.sample_synchronized || + p.payload_size_bytes != 64U || p.channels.size() != 16U) { + result.errors.push_back( + name + ": M4.0 needs synchronized 4000 Hz 4I+4V, one " + "ASDU/frame and 64-byte canonical payload."); + continue; + } + if (classify_esp32p4_sv_profile(p) != Esp32P4SvProfileSupport::ready) { + result.errors.push_back( + name + ": host SCL profile is not supported by the " + "ESP32-P4 4I+4V device boundary."); + continue; + } + + const auto device = compile_esp32p4_device_profile(p); + if (!device.ok()) { + result.errors.push_back( + name + ": failed to compile validated binary device profile."); + for (const auto& error : device.errors) { + result.errors.push_back(name + ": " + error); + } + continue; + } + + candidate.streams[i] = M4CompiledSvStream{ + stream.ied_name, stream.control_block_reference, *device.profile}; + + // The real SCD repeats APPID 0x4000 on three DIFFERENT MAC addresses; + // APPID-only uniqueness would wrongly reject this valid lab scenario. + for (std::size_t previous = 0U; previous < i; ++previous) { + if (stream.ied_name == candidate.streams[previous].ied_name && + stream.control_block_reference == + candidate.streams[previous].control_block_reference) { + result.errors.push_back( + name + ": duplicate IED-qualified control block."); + } + if (same_wire_identity(candidate.streams[i].device_profile, + candidate.streams[previous].device_profile)) { + result.errors.push_back( + name + ": duplicate complete SV wire identity."); + } + } + } + if (result.errors.empty()) result.roster = std::move(candidate); + return result; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tests/fixtures/scl/m4-three-9-2le-reference.scd b/tests/fixtures/scl/m4-three-9-2le-reference.scd new file mode 100644 index 000000000..8cfc2a4d7 --- /dev/null +++ b/tests/fixtures/scl/m4-three-9-2le-reference.scd @@ -0,0 +1,106 @@ + + +
+ +

0

4

01-0C-CD-04-00-01

4000

+

0

4

01-0C-CD-04-00-02

4000

+

0

4

01-0C-CD-04-00-03

4000

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_m4_three_sv_roster.cpp b/tests/test_m4_three_sv_roster.cpp new file mode 100644 index 000000000..5ea6d8d72 --- /dev/null +++ b/tests/test_m4_three_sv_roster.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/m4_three_sv_roster.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +#define CHECK(condition) do { if (!(condition)) { \ + throw std::runtime_error(std::string{"M4 CHECK failed: "} + #condition + \ + " at " + std::to_string(__LINE__)); \ +} } while (false) + +using namespace ar::iec61850::sampled_values; + +std::string_view sv_id(const CompiledSvDeviceProfile& p) { + return {p.sv_id.data(), p.sv_id_length}; +} + +SvPublisherProfileCompileContext reference_context() { + SvPublisherProfileCompileContext c; + c.profile_family = SvProfileFamily::legacy_9_2le; + c.sample_counter_modulus = static_cast(4000U); + return c; +} + +void exact_three_stream_compilation_and_codec() { + const auto path = std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/scl/m4-three-9-2le-reference.scd"; + auto document = ar::iec61850::scl::SclParser{}.load(path); + CHECK(document.sampled_values_streams.size() == 3U); + // This document has same APPID on three different L2 destinations. + // The existing SCL conflict list must not arbitrarily block this roster. + const auto compile = M4ThreeSvRosterCompiler::compile(document, reference_context()); + if (!compile.ok()) { + for (const auto& e : compile.errors) std::cerr << e << '\n'; + } + CHECK(compile.ok()); + const auto& roster = *compile.roster; + CHECK(roster.common_sample_rate_hz == 4000U); + CHECK(roster.total_ethernet_frames_per_second == 12000U); + + for (std::size_t i = 0U; i < 3U; ++i) { + const auto& stream = roster.streams[i]; + const auto& profile = stream.device_profile; + CHECK(stream.ied_name == "MU01_4I_4V_" + std::to_string(i + 1U)); + CHECK(stream.control_block_reference.find("MSVCB01") != std::string::npos); + CHECK(sv_id(profile) == "MU01_SV" + std::to_string(i + 1U)); + CHECK(profile.destination_mac == (std::array{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, + static_cast(i + 1U)})); + CHECK(profile.app_id == 0x4000U); + CHECK(profile.vlan_present); + CHECK(profile.vlan_id == 0U); + CHECK(profile.vlan_priority == 4U); + CHECK(profile.frame_rate_hz == 4000U); + CHECK(profile.sample_counter_modulus == 4000U); + CHECK(profile.payload_size_bytes == 64U); + CHECK(profile.no_asdu == 1U); + CHECK(profile.leaf_count == 16U); + CHECK(profile.asdu_options.sample_synchronized); + + const auto bytes = SvDeviceProfileBinaryCodec::encoded_size(profile); + CHECK(bytes.has_value() && *bytes > 0U); + std::vector encoded(*bytes); + CHECK(SvDeviceProfileBinaryCodec::encode_into(profile, encoded).success()); + CompiledSvDeviceProfile decoded; + CHECK(SvDeviceProfileBinaryCodec::decode(encoded, decoded).success()); + CHECK(decoded == profile); + } + + // Reject a fourth stream, not merely truncate it. + auto invalid = document; + invalid.sampled_values_streams.push_back(invalid.sampled_values_streams.back()); + CHECK(!M4ThreeSvRosterCompiler::compile(invalid, reference_context()).ok()); + + // No implicit family or rate-sized counter guess. + CHECK(!M4ThreeSvRosterCompiler::compile(document, {}).ok()); + auto wrong_counter = reference_context(); + wrong_counter.sample_counter_modulus = static_cast(4800U); + CHECK(!M4ThreeSvRosterCompiler::compile(document, wrong_counter).ok()); + + // One bad stream must invalidate the entire atomic roster, not two-thirds. + invalid = document; + invalid.sampled_values_streams[1].sample_rate = 4800U; + CHECK(!M4ThreeSvRosterCompiler::compile(invalid, reference_context()).ok()); + + // Same APPID with different MAC is allowed; identical complete wire + // identity (MAC+APPID+VLAN+svID) is rejected. + invalid = document; + invalid.sampled_values_streams[2].address.destination_mac = + invalid.sampled_values_streams[0].address.destination_mac; + invalid.sampled_values_streams[2].sv_id = + invalid.sampled_values_streams[0].sv_id; + invalid.sampled_values_streams[2].smv_id = + invalid.sampled_values_streams[0].smv_id; + CHECK(!M4ThreeSvRosterCompiler::compile(invalid, reference_context()).ok()); + + std::cout << "PASS: 3 SCL MUs, 3 exact L2 SV profiles, binary V1 round-trip, " + "shared 4000 Hz grid, 12000 fps, atomic rejection\n"; +} +} // namespace + +int main() { + try { + exact_three_stream_compilation_and_codec(); + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +}