diff --git a/CMakeLists.txt b/CMakeLists.txt index b3aa42634..4e564948b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,14 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_m4_multi_profile_staging_tests COMMAND ariec61850_m4_multi_profile_staging_tests) + add_executable(ariec61850_m4_three_sv_grid_tests tests/test_m4_three_sv_grid.cpp) + target_link_libraries(ariec61850_m4_three_sv_grid_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_m4_three_sv_grid_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_m4_three_sv_grid_tests) + ariec61850_apply_sanitizers(ariec61850_m4_three_sv_grid_tests) + add_test(NAME ariec61850_m4_three_sv_grid_tests + COMMAND ariec61850_m4_three_sv_grid_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_grid.hpp b/include/ariec61850/sampled_values/m4_three_sv_grid.hpp new file mode 100644 index 000000000..4315d9289 --- /dev/null +++ b/include/ariec61850/sampled_values/m4_three_sv_grid.hpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/m4_three_sv_roster.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Deterministic shared-clock tick planning: this is NOT the PTP servo and +// NEVER independently fabricates lock. Firmware supplies the actual qualified +// lease and sample counter from its one GPTimer/PTP authority. +struct M4GridTick final { + std::uint64_t ptp_generation{}; + std::uint64_t tick_sequence{}; // absolute 4000Hz grid slot, never TX attempts + std::uint8_t smp_synch{}; // caller's common PTP decision: 0,1,2 + bool timing_qualified{}; +}; + +struct M4GridFrame final { + std::uint8_t slot{}; // 0..2 maps to a distinct SCD MU + std::uint16_t smp_cnt{}; // one common epoch for every enabled MU + std::uint8_t smp_synch{}; // same PTP decision, never per-MU override +}; + +struct M4GridBatch final { + std::array frames{}; + std::uint8_t count{}; + std::uint64_t tick_sequence{}; + std::uint64_t ptp_generation{}; + std::uint64_t missed_ticks{}; + bool timing_discontinuity{}; // stale PTP lease must not emit stale sync +}; + +class M4ThreeSvGridPlanner final { +public: + // A new common reference epoch is accepted ONLY by the existing external + // PTP-to-GPTimer owner. No stream-specific reset of the sample clock. + void reset(const std::uint64_t ptp_generation) noexcept { + generation_ = ptp_generation; + initialized_ = ptp_generation != 0U; + observed_tick_ = false; + last_tick_ = 0U; + } + + void set_enabled(const std::size_t slot, const bool enabled) noexcept { + if (slot < enabled_.size()) enabled_[slot] = enabled; + } + + [[nodiscard]] bool enabled(const std::size_t slot) const noexcept { + return slot < enabled_.size() && enabled_[slot]; + } + + [[nodiscard]] M4GridBatch plan(const M4GridTick tick) noexcept { + M4GridBatch batch{}; + batch.tick_sequence = tick.tick_sequence; + batch.ptp_generation = tick.ptp_generation; + + if (!initialized_ || tick.ptp_generation == 0U || + tick.ptp_generation != generation_ || !tick.timing_qualified || + tick.smp_synch > 2U) { + batch.timing_discontinuity = true; + // Revocation is group-wide: callers must explicitly re-arm on + // the newly qualified PTP epoch, never auto-promote synchrony. + initialized_ = false; + return batch; + } + + if (observed_tick_ && tick.tick_sequence <= last_tick_) { + batch.timing_discontinuity = true; + initialized_ = false; + return batch; + } + if (observed_tick_) { + batch.missed_ticks = tick.tick_sequence - last_tick_ - 1U; + } + observed_tick_ = true; + last_tick_ = tick.tick_sequence; + + const auto counter = static_cast( + tick.tick_sequence % m4_three_sv_rate_hz); + for (std::size_t slot = 0U; slot < enabled_.size(); ++slot) { + if (!enabled_[slot]) continue; + batch.frames[batch.count++] = M4GridFrame{ + static_cast(slot), counter, tick.smp_synch}; + } + return batch; + } + +private: + std::array enabled_{{true, true, true}}; + std::uint64_t generation_{}; + std::uint64_t last_tick_{}; + bool initialized_{}; + bool observed_tick_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_m4_three_sv_grid.cpp b/tests/test_m4_three_sv_grid.cpp new file mode 100644 index 000000000..8f097cfc7 --- /dev/null +++ b/tests/test_m4_three_sv_grid.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/m4_three_sv_grid.hpp" + +#include +#include +#include +#include +#include + +namespace { +#define CHECK(condition) do { if (!(condition)) { \ + throw std::runtime_error(std::string{"M4 shared grid: "} + #condition + \ + " line " + std::to_string(__LINE__)); \ +} } while (false) + +using namespace ar::iec61850::sampled_values; + +void verify_synchronous_three_stream_planning() { + M4ThreeSvGridPlanner scheduler; + CHECK(scheduler.enabled(0U) && scheduler.enabled(1U) && scheduler.enabled(2U)); + scheduler.reset(123U); + std::uint32_t generated = 0U; + for (std::uint64_t tick = 0U; tick < 8000U; ++tick) { + const auto batch = scheduler.plan({123U, tick, 2U, true}); + CHECK(!batch.timing_discontinuity); + CHECK(batch.count == 3U); + CHECK(batch.missed_ticks == 0U); + for (std::size_t slot = 0U; slot < 3U; ++slot) { + CHECK(batch.frames[slot].slot == slot); + CHECK(batch.frames[slot].smp_cnt == (tick % 4000U)); + CHECK(batch.frames[slot].smp_synch == 2U); + } + generated += batch.count; + } + CHECK(generated == 24000U); // exactly 12000/s across 2 simulated seconds +} + +void verify_per_stream_isolation_and_group_revocation() { + M4ThreeSvGridPlanner planner; + planner.reset(41U); + auto batch = planner.plan({41U, 3998U, 1U, true}); + CHECK(batch.count == 3U && batch.frames[0].smp_cnt == 3998U); + planner.set_enabled(1U, false); + batch = planner.plan({41U, 3999U, 1U, true}); + CHECK(batch.count == 2U); + CHECK(batch.frames[0].slot == 0U && batch.frames[1].slot == 2U); + CHECK(batch.frames[0].smp_cnt == 3999U && batch.frames[1].smp_cnt == 3999U); + + planner.set_enabled(1U, true); + batch = planner.plan({41U, 4000U, 1U, true}); + CHECK(batch.count == 3U); + CHECK(batch.frames[1].slot == 1U && batch.frames[1].smp_cnt == 0U); + CHECK(batch.frames[0].smp_cnt == batch.frames[2].smp_cnt); + + batch = planner.plan({41U, 4004U, 1U, true}); + CHECK(batch.count == 3U && batch.missed_ticks == 3U); + CHECK(batch.frames[0].smp_cnt == 4U); // skip late samples, no stale replay + + batch = planner.plan({42U, 4005U, 2U, true}); + CHECK(batch.count == 0U && batch.timing_discontinuity); + batch = planner.plan({42U, 4006U, 2U, true}); + CHECK(batch.count == 0U); // no unqualified automatic re-arm + planner.reset(42U); + batch = planner.plan({42U, 4007U, 2U, true}); + CHECK(batch.count == 3U && !batch.timing_discontinuity); + + batch = planner.plan({42U, 4008U, 2U, false}); + CHECK(batch.count == 0U && batch.timing_discontinuity); + planner.reset(42U); + batch = planner.plan({42U, 4009U, 0U, true}); + CHECK(batch.count == 3U && batch.frames[0].smp_synch == 0U); + batch = planner.plan({42U, 4009U, 0U, true}); + CHECK(batch.count == 0U && batch.timing_discontinuity); +} +} + +int main() { + try { + verify_synchronous_three_stream_planning(); + verify_per_stream_isolation_and_group_revocation(); + std::cout << "PASS M4 shared 4kHz sample grid: three independent slots, " + "one epoch/smpCnt/smpSynch, skipped slots and PTP revocation\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}