diff --git a/docs/M4_P0D3_STREAM_SCENARIO_TRANSACTIONS.md b/docs/M4_P0D3_STREAM_SCENARIO_TRANSACTIONS.md new file mode 100644 index 000000000..b12e696af --- /dev/null +++ b/docs/M4_P0D3_STREAM_SCENARIO_TRANSACTIONS.md @@ -0,0 +1,46 @@ +# M4 P0D3 — Atomic per-MU scenarios and shared-grid stream gates + +**Status:** feature PR / requires ESP-IDF + C++ CI; not physical hardware validation. + +Firmware reuses the existing `SvScenarioTransaction` in a bounded array of three slots. No XML, heap, mutex, extra timer, extra publisher task or per-frame log is introduced. The old single-SV CFG/START protocol and M3.H.3 release remain untouched. + +## Protocol + +Requires `MULTISV` committed roster + explicit `MULTISV SELECT`. Slot is `1`, `2`, or `3`. + +```text +SVSTREAM 2 CFG BEGIN 12345 +SVSTREAM 2 CFG FREQ 50000 +SVSTREAM 2 CFG SET IA 100000 0 0 1 +SVSTREAM 2 CFG SET IB 100000 -120000 0 1 +SVSTREAM 2 CFG SET IC 100000 120000 0 1 +SVSTREAM 2 CFG SET IN 0 0 0 1 +SVSTREAM 2 CFG SET UA 5774 0 0 1 +SVSTREAM 2 CFG SET UB 5774 -120000 0 1 +SVSTREAM 2 CFG SET UC 5774 120000 0 1 +SVSTREAM 2 CFG SET UN 0 0 0 1 +SVSTREAM 2 CFG SHAPE 0 0 0 2 1000 +SVSTREAM 2 CFG COMMIT 12345 <16-character-host-computed-sv_scenario_hash-hex> +SVSTREAM 2 CFG READBACK 12345 +SVSTREAM 2 SHOW +``` + +`FREQ` argument is mHz; `SET` arguments are wire RMS counts, phase millidegrees, uint32 quality, and Boolean channel enabled. `SHAPE` arguments are enabled, DC permille, harmonic permille, order and clip permille. SHA/CRC of the SCD envelope is a **different** check from `sv_scenario_hash`; compute the latter with the existing canonical host codec in `live_signal_transaction.hpp`, not floating-point JSON serialization. + +All 18 fields (frequency + shape + 8 SET + 8 enable states) are mandatory. A SET stages value and enabled status together, satisfying two completeness bits. One missing channel, invalid field, changed bank generation, wrong token, stale timeout (>8s) or hash mismatch rejects the whole transaction, preserving old output. `READBACK` checks applied bank generation/hash. A running scenario does not provide a fresh global START authority. + +```text +SVSTREAM 2 STOP +SVSTREAM 2 SHOW +SVSTREAM 2 START +``` + +These operations toggle only the stream's Ethernet TX bit, **not** its signal-bank contents or the shared grid. Each 250us tick reads one 3-bit enable snapshot; all three DDS engines advance regardless of TX gating. MU2 rejoining takes the **current group smpCnt**, not 0. The global START/STOP handshake and physical link/PTP fail-closed policy remain authoritative; a per-MU START while the group is stopped merely arms that MU for the next verified group START. A newly committed SCD roster reinitializes all three TX-enable bits to ON. + +## Explicit limitations / next + +- Atomic **per-MU scenario**, not one atomic transaction covering all three MUs. +- TX state is RAM-resident and persists through Studio disconnect while the ESP32 remains powered, not across ESP32 reboot. +- Output follows one wire-time grid but no guarantee is claimed about zero Ethernet inter-packet skew. +- Complete serial Studio deploy, 3-MU page editing and reconnect adoption are P0E. +- Firmware CI is **not** proof of real 3×4000fps, 250us deadline, on-wire MAC or PTP phase; P0F must capture physical Ethernet. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 785880db7..76ab75168 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1673,8 +1673,15 @@ void publisher_task(void* argument) { continue; } } - patch_packet( - canonical, canonical_sample_count, sample_synchronization, row, signal_state); + // One mask snapshot per batch. A disabled MU still advances its + // DDS phase on the shared 250us grid; it simply skips Ethernet. + const std::uint8_t multi_enabled_mask = multi_active + ? ar::esp32p4::smv::multi_profile_enabled_mask() : 0x01U; + const bool send_primary = (multi_enabled_mask & 0x01U) != 0U; + if (send_primary) { + patch_packet( + canonical, canonical_sample_count, sample_synchronization, row, signal_state); + } // One timestamp BEFORE all three canonical EMAC submissions. // Budget telemetry is folded into the existing 400-tick stats // flush, never logged or malloc'ed on the real-time path. @@ -1692,7 +1699,7 @@ void publisher_task(void* argument) { SvPtpClockLease evidence_lease{}; std::uint32_t evidence_session = 0U; std::uint32_t fault_generation_before = 0U; - if (epoch_armed && g_sample_clock_context.phase_enabled && + if (send_primary && epoch_armed && g_sample_clock_context.phase_enabled && canonical_sample_count == 0U && missed_slots == 0U && active_profile.sample_counter_modulus == active_profile.publisher_rate_hz) { @@ -1730,7 +1737,8 @@ void publisher_task(void* argument) { eth_mac_time_t egress_hw_time{}; const std::int64_t submit_before_us = take_egress_timestamp ? esp_timer_get_time() : 0; - const esp_err_t canonical_result = take_egress_timestamp + const esp_err_t canonical_result = !send_primary ? ESP_OK : + take_egress_timestamp ? esp_eth_transmit_ctrl_vargs( eth_handle, &egress_hw_time, 2U, canonical.bytes.data(), static_cast(canonical.bytes.size())) @@ -1811,9 +1819,11 @@ void publisher_task(void* argument) { } } #else - const esp_err_t canonical_result = - esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); + const esp_err_t canonical_result = send_primary + ? esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()) + : ESP_OK; #endif + if (send_primary) { if (canonical_result == ESP_OK) { ++local.canonical_ok; if (consecutive_canonical_tx_failures != 0U) @@ -1840,6 +1850,8 @@ void publisher_task(void* argument) { #endif } + } // primary-MU enable gate + // M4: exactly two additional canonical SV frames from the same // hardware-timed tick. Each has independent prebuilt packet bytes // and DDS state. The three frames share smpCnt/smpSynch; egress @@ -1849,6 +1861,9 @@ void publisher_task(void* argument) { for (std::size_t slot = 0U; slot < multi_extra_packets.size(); ++slot) { const auto extra_row = multi_extra_engines[slot].next( multi_extra_signals[slot], 4000U); + // Advance phase even when muted, so re-enabled frames + // rejoin this same shared PTP sample epoch. + if ((multi_enabled_mask & (1U << (slot + 1U))) == 0U) continue; patch_packet(multi_extra_packets[slot], canonical_sample_count, sample_synchronization, extra_row, multi_extra_signals[slot]); @@ -1902,7 +1917,7 @@ void publisher_task(void* argument) { eth_handle, probe_multicast.data(), probe_multicast.size())); } #endif - if (multi_active) { + if (multi_active && multi_enabled_mask != 0U) { const std::int64_t elapsed = esp_timer_get_time() - multi_batch_start_us; const auto bounded = static_cast( std::clamp(elapsed, 0LL, 0xFFFFFFFFLL)); diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp index 48f21c7e4..b79ab6e85 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp @@ -3,8 +3,10 @@ #include "multi_profile_staging.hpp" #include "live_control.hpp" #include "ariec61850/sampled_values/m4_three_sv_signals.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "esp_log.h" +#include "esp_timer.h" #include #include @@ -27,6 +29,11 @@ constexpr char kDelimiters[] = " \t\r\n"; MultiSvConfigStore g_multi{}; // Exactly three independent atomic waveform banks; only console task writes. ar::iec61850::sampled_values::M4ThreeSvSignalBanks g_mu_signals{}; +// Each slot independently stages a complete scenario. One UART writer; no +// transactional parsing or allocation in the 4-kHz data-plane. +std::array g_mu_transactions{}; +// Toggled only by UART control; publisher takes one atomic mask per grid tick. +ar::iec61850::sampled_values::M4ThreeSvStreamGate g_mu_stream_gate{}; // Selection changes only in STOPPED control-plane commands; the active // publisher reads the immutable committed roster once at START. std::atomic g_multi_tx_selected{false}; @@ -161,6 +168,162 @@ multi_profile_signal_snapshot(const std::size_t slot) noexcept { return value; } + +[[nodiscard]] bool parse_u64_token(const char* text, std::uint64_t& output, + const int base) noexcept { + if (!text || !*text || *text == '-') return false; + errno = 0; + char* end = nullptr; + const unsigned long long value = std::strtoull(text, &end, base); + if (errno || end == text || *end) return false; + output = static_cast(value); + return true; +} + +[[nodiscard]] std::uint32_t serial_now_ms() noexcept { + return static_cast( + static_cast(esp_timer_get_time()) / 1000ULL); +} + +void reset_mu_transactions() noexcept { + for (auto& tx : g_mu_transactions) tx.reset(); +} + +// The existing V1 SvScenarioTransaction is deliberately reused per MU: +// complete 8-channel staging, generation guard, 8s timeout, canonical +// wire-state hash and explicit readback. Only this UART task writes a bank. +void handle_svstream_cfg(const std::size_t slot, char* save) noexcept { + using Tx = ar::iec61850::sampled_values::SvScenarioTransaction; + auto& tx = g_mu_transactions[slot]; + auto* bank = g_mu_signals.control_bank_for_serial_writer(slot); + char* verb = strtok_r(nullptr, kDelimiters, &save); + if (!verb || bank == nullptr) { + tx.reject(); + ESP_LOGE(kTag, "SVSTREAM CFG requires BEGIN|FREQ|SET|SHAPE|COMMIT|READBACK|ABORT"); + return; + } + upper(verb); + const auto now = serial_now_ms(); + const auto log_slot = static_cast(slot + 1U); + if (std::strcmp(verb, "ABORT") == 0) { + if (!no_extra(&save)) { tx.reject(); return; } + tx.reset(); + ESP_LOGI(kTag, "SVSTREAM CFG ABORT slot=%u", log_slot); + return; + } + if (std::strcmp(verb, "BEGIN") == 0) { + std::uint64_t token{}; + if (!parse_u64_token(strtok_r(nullptr, kDelimiters, &save), token, 10) || + !no_extra(&save) || + !tx.begin(token, bank->snapshot(), now, live_tx_running())) { + tx.reset(); + ESP_LOGE(kTag, "SVSTREAM CFG BEGIN rejected slot=%u", log_slot); + return; + } + ESP_LOGI(kTag, "SVSTREAM CFG BEGIN slot=%u token=%llu", + log_slot, static_cast(token)); + return; + } + if (std::strcmp(verb, "READBACK") == 0) { + std::uint64_t token{}; + if (!parse_u64_token(strtok_r(nullptr, kDelimiters, &save), token, 10) || + !no_extra(&save) || !tx.confirm_readback(*bank, token, now)) { + ESP_LOGE(kTag, "SVSTREAM CFG READBACK rejected slot=%u", log_slot); + return; + } + ESP_LOGI(kTag, + "SVSTREAM CFG READBACK slot=%u token=%llu hash=%016llX generation=%llu", + log_slot, static_cast(token), + static_cast(tx.checksum()), + static_cast(tx.applied_generation())); + return; + } + if (std::strcmp(verb, "COMMIT") == 0) { + std::uint64_t token{}, expected_hash{}; + if (!parse_u64_token(strtok_r(nullptr, kDelimiters, &save), token, 10) || + !parse_u64_token(strtok_r(nullptr, kDelimiters, &save), expected_hash, 16) || + !no_extra(&save) || tx.began_running() != live_tx_running()) { + tx.reject(); + ESP_LOGE(kTag, "SVSTREAM CFG COMMIT rejected slot=%u malformed or RUN state changed", + log_slot); + return; + } + const auto result = tx.commit(*bank, token, expected_hash, now); + if (result != Tx::Result::accepted) { + ESP_LOGE(kTag, "SVSTREAM CFG REJECT slot=%u code=%u", log_slot, + static_cast(result)); + return; + } + ESP_LOGI(kTag, + "SVSTREAM CFG COMMITTED slot=%u token=%llu hash=%016llX generation=%llu", + log_slot, static_cast(token), + static_cast(tx.checksum()), + static_cast(tx.applied_generation())); + return; + } + + if (!tx.staging() || tx.began_running() != live_tx_running()) { + tx.reject(); + ESP_LOGE(kTag, "SVSTREAM CFG rejected slot=%u: BEGIN required or RUN state changed", + log_slot); + return; + } + auto& draft = tx.draft(); + if (std::strcmp(verb, "FREQ") == 0) { + std::uint32_t frequency{}; + if (!parse_uint(strtok_r(nullptr, kDelimiters, &save), frequency) || + !no_extra(&save)) { tx.reject(); return; } + draft.frequency_millihz = frequency; + tx.mark_frequency(); + } else if (std::strcmp(verb, "SET") == 0) { + const int index = stream_channel(strtok_r(nullptr, kDelimiters, &save)); + std::int32_t rms{}, phase{}; + std::uint32_t quality{}, enabled{}; + if (index < 0 || + !parse_signed(strtok_r(nullptr, kDelimiters, &save), rms) || + !parse_signed(strtok_r(nullptr, kDelimiters, &save), phase) || + !parse_uint(strtok_r(nullptr, kDelimiters, &save), quality) || + !parse_uint(strtok_r(nullptr, kDelimiters, &save), enabled) || + enabled > 1U || !no_extra(&save)) { tx.reject(); return; } + auto& ch = draft.channels[static_cast(index)]; + ch.rms_counts = rms; + ch.phase_millidegrees = phase; + ch.quality = quality; + ch.enabled = enabled != 0U; + tx.mark_set(static_cast(index)); + tx.mark_enabled(static_cast(index)); + } else if (std::strcmp(verb, "SHAPE") == 0) { + std::uint32_t enabled{}, harmonic{}, order{}, clip{}; + std::int32_t dc{}; + if (!parse_uint(strtok_r(nullptr, kDelimiters, &save), enabled) || + enabled > 1U || + !parse_signed(strtok_r(nullptr, kDelimiters, &save), dc) || + !parse_uint(strtok_r(nullptr, kDelimiters, &save), harmonic) || + !parse_uint(strtok_r(nullptr, kDelimiters, &save), order) || + !parse_uint(strtok_r(nullptr, kDelimiters, &save), clip) || + order > 255U || !no_extra(&save)) { tx.reject(); return; } + draft.current_shape = {enabled != 0U, dc, harmonic, + static_cast(order), clip}; + tx.mark_shape(); + } else { + tx.reject(); + ESP_LOGE(kTag, "SVSTREAM CFG invalid command slot=%u", log_slot); + return; + } + // Even before COMMIT, reject an invalid individual field without exposing + // this draft to the publisher; full validation repeats on COMMIT. + if (!ar::iec61850::sampled_values::SvLiveSignalBank::validate(draft)) { + tx.reject(); + ESP_LOGE(kTag, "SVSTREAM CFG invalid staged value slot=%u", log_slot); + return; + } + ESP_LOGI(kTag, "SVSTREAM CFG STAGED slot=%u verb=%s", log_slot, verb); +} + +std::uint8_t multi_profile_enabled_mask() noexcept { + return g_mu_stream_gate.snapshot_mask(); +} + void handle_svstream_command(char* arguments) noexcept { char* save = nullptr; const char* slot_text = strtok_r(arguments, kDelimiters, &save); @@ -175,13 +338,30 @@ void handle_svstream_command(char* arguments) noexcept { char* verb = strtok_r(nullptr, kDelimiters, &save); if (!verb) { ESP_LOGE(kTag, "SVSTREAM usage: <1..3> SHOW|SET|FREQ"); return; } upper(verb); + if (std::strcmp(verb, "CFG") == 0) { + handle_svstream_cfg(slot, save); + return; + } + if (std::strcmp(verb, "START") == 0 || std::strcmp(verb, "STOP") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "SVSTREAM START|STOP takes no extra tokens"); + return; + } + const bool enabled = std::strcmp(verb, "START") == 0; + static_cast(g_mu_stream_gate.set_enabled(slot, enabled)); + ESP_LOGI(kTag, "SVSTREAM slot=%lu enabled=%u mask=0x%02X effect=NEXT_COMMON_TICK", + static_cast(slot_number), enabled ? 1U : 0U, + static_cast(multi_profile_enabled_mask())); + return; + } auto state = multi_profile_signal_snapshot(slot); if (std::strcmp(verb, "SHOW") == 0) { if (!no_extra(&save)) { ESP_LOGE(kTag, "SVSTREAM SHOW takes no extras"); return; } - ESP_LOGI(kTag, "SVSTREAM slot=%lu generation=%llu frequency_millihz=%lu", + ESP_LOGI(kTag, "SVSTREAM slot=%lu generation=%llu frequency_millihz=%lu tx_enabled=%u", static_cast(slot_number), static_cast(state.generation), - static_cast(state.frequency_millihz)); + static_cast(state.frequency_millihz), + (multi_profile_enabled_mask() & (1U << slot)) ? 1U : 0U); static constexpr const char* names[] = {"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; for (std::size_t i = 0; i < state.channels.size(); ++i) { const auto& ch = state.channels[i]; @@ -259,6 +439,7 @@ void handle_multi_profile_command(char* arguments) noexcept { return; } const bool selected = !std::strcmp(command, "SELECT"); + reset_mu_transactions(); g_multi_tx_selected.store(selected, std::memory_order_release); ESP_LOGI(kTag, "MULTISV TXMODE selected=%u; next verified START uses %s", selected ? 1U : 0U, selected ? "THREE_SV" : "LEGACY_ONE_SV"); @@ -330,11 +511,15 @@ void handle_multi_profile_command(char* arguments) noexcept { ESP_LOGE(kTag, "MULTISV COMMIT rejected: VALIDATE required or transaction invalid"); return; } + // Roster identity changed: pending MU tickets and TX bits belong + // to the old set; all newly deployed MUs default to enabled. + reset_mu_transactions(); + g_mu_stream_gate.reset_all(); // A new committed profile set is never auto-activated by replacing // the old roster. Require an explicit SELECT after each COMMIT. g_multi_tx_selected.store(false, std::memory_order_release); ESP_LOGI(kTag, - "MULTISV COMMITTED transaction=%lu generation=%llu hash=%016llX bytes=%lu output=INACTIVE_UNTIL_M4_P0C2", + "MULTISV COMMITTED transaction=%lu generation=%llu hash=%016llX bytes=%lu output=CONFIG_COMMITTED_TX_REQUIRES_SELECT_AND_VERIFIED_START", static_cast(txn), static_cast(g_multi.generation()), static_cast(g_multi.committed_hash()), diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp index d8fc06e84..921ab49d5 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp @@ -3,6 +3,7 @@ #include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" #include +#include // The console/control task is the ONLY writer to the committed roster. // A TX START serially follows SELECT, and COMMIT is forbidden while live SV @@ -14,6 +15,9 @@ void multi_profile_abort_staging() noexcept; void handle_svstream_command(char* arguments) noexcept; [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState multi_profile_signal_snapshot(std::size_t slot) noexcept; +// One acquire snapshot at the start of each 250us batch. Bits 0/1/2 gate +// canonical MU1/MU2/MU3 Ethernet submissions without resetting the grid. +[[nodiscard]] std::uint8_t multi_profile_enabled_mask() noexcept; // Null means legacy V1 START. Non-null is the selected, validated, committed // 3-profile V2 roster. Never retain across STOP and a future control commit. diff --git a/include/ariec61850/sampled_values/m4_three_sv_signals.hpp b/include/ariec61850/sampled_values/m4_three_sv_signals.hpp index a8dbd016a..33804a2f4 100644 --- a/include/ariec61850/sampled_values/m4_three_sv_signals.hpp +++ b/include/ariec61850/sampled_values/m4_three_sv_signals.hpp @@ -42,10 +42,47 @@ class M4ThreeSvSignalBanks final { return banks_[slot].snapshot().generation; } + // Control-plane only: the one serialized UART writer may use the existing + // SvScenarioTransaction against a specific bank. Never call this on the + // publisher task, which only uses snapshot(slot, output). + [[nodiscard]] SvLiveSignalBank* control_bank_for_serial_writer( + const std::size_t slot) noexcept { + return slot < banks_.size() ? &banks_[slot] : nullptr; + } + private: std::array banks_{}; }; + +// One atomic three-bit control snapshot for a single shared 4-kHz tick. +// A late-rejoining MU uses the current group smpCnt; no separate clock, +// free-running counter or phase reset when its TX bit is toggled. +class M4ThreeSvStreamGate final { +public: + static constexpr std::uint8_t all_enabled = 0x07U; + + [[nodiscard]] std::uint8_t snapshot_mask() const noexcept { + return enabled_.load(std::memory_order_acquire); + } + + [[nodiscard]] bool set_enabled(const std::size_t slot, const bool on) noexcept { + if (slot >= M4ThreeSvSignalBanks::capacity) return false; + const auto bit = static_cast(1U << slot); + if (on) enabled_.fetch_or(bit, std::memory_order_release); + else enabled_.fetch_and(static_cast(~bit), + std::memory_order_release); + return true; + } + + void reset_all() noexcept { + enabled_.store(all_enabled, std::memory_order_release); + } + +private: + std::atomic enabled_{all_enabled}; +}; + static_assert(M4ThreeSvSignalBanks::capacity == 3U); } // namespace ar::iec61850::sampled_values diff --git a/tests/test_m4_three_sv_signals.cpp b/tests/test_m4_three_sv_signals.cpp index 1621a5368..6ba062c1d 100644 --- a/tests/test_m4_three_sv_signals.cpp +++ b/tests/test_m4_three_sv_signals.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/sampled_values/m4_three_sv_signals.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include #include @@ -88,11 +89,148 @@ void verify_three_atomic_isolated_setpoint_banks() { std::cout << "PASS M4 P0D1: three independent 4I4V setpoints, " "quality, phase, generation and GUI page switching\n"; } + +void verify_three_isolated_complete_scenario_transactions() { + M4ThreeSvSignalBanks banks; + std::array tx{}; + constexpr std::uint32_t now = 100U; + SvLiveSignalState before[3]{}; + for (std::size_t slot = 0U; slot < 3U; ++slot) { + CHECK(banks.snapshot(slot, before[slot])); + CHECK(banks.control_bank_for_serial_writer(slot) != nullptr); + CHECK(tx[slot].begin(100U + slot, before[slot], now, true)); + auto& state = tx[slot].draft(); + state.frequency_millihz = 0U; + tx[slot].mark_frequency(); + state.current_shape = {false, 0, 0, 2, 1000}; + tx[slot].mark_shape(); + for (std::size_t channel = 0U; channel < 8U; ++channel) { + state.channels[channel].rms_counts = + static_cast(1000U + 500U * slot + channel); + state.channels[channel].phase_millidegrees = + static_cast(3000U * slot); + state.channels[channel].quality = static_cast(slot); + state.channels[channel].enabled = channel != 7U; + tx[slot].mark_set(channel); + tx[slot].mark_enabled(channel); + } + } + // Staging even three simultaneous transactions must not change any bank. + for (std::size_t slot = 0U; slot < 3U; ++slot) { + SvLiveSignalState current{}; + CHECK(banks.snapshot(slot, current)); + CHECK(current == before[slot]); + } + auto* mu2 = banks.control_bank_for_serial_writer(1U); + CHECK(mu2 != nullptr); + const auto mu2_hash = sv_scenario_hash(tx[1].draft()); + CHECK(tx[1].commit(*mu2, 101U, mu2_hash, now + 1U) == + SvScenarioTransaction::Result::accepted); + CHECK(tx[1].confirm_readback(*mu2, 101U, now + 2U)); + CHECK(tx[1].readback_matches(*mu2, 101U, now + 2U)); + CHECK(banks.generation(1U) == 1U); + CHECK(banks.generation(0U) == 0U && banks.generation(2U) == 0U); + SvLiveSignalState applied{}; + CHECK(banks.snapshot(1U, applied)); + CHECK(applied.channels[0].rms_counts == 1500); + CHECK(applied.channels[0].quality == 1U); + CHECK(!tx[1].can_start(*mu2, 101U, mu2_hash, now + 2U)); + + // A bad hash cannot partially update MU1 (nor touch the other MUs). + auto* mu1 = banks.control_bank_for_serial_writer(0U); + CHECK(mu1 != nullptr); + CHECK(tx[0].commit(*mu1, 100U, sv_scenario_hash(tx[0].draft()) ^ 1U, now + 1U) == + SvScenarioTransaction::Result::checksum); + CHECK(banks.generation(0U) == 0U && banks.generation(1U) == 1U); + + // A stale MU3 draft must not overwrite a subsequent independent edit. + auto* mu3 = banks.control_bank_for_serial_writer(2U); + CHECK(mu3 != nullptr); + auto edited = mu3->snapshot(); + edited.channels[1].quality = 0xABCDU; + CHECK(mu3->publish(edited)); + CHECK(tx[2].commit(*mu3, 102U, sv_scenario_hash(tx[2].draft()), now + 1U) == + SvScenarioTransaction::Result::stale_generation); + CHECK(banks.generation(2U) == 1U); + CHECK(banks.generation(1U) == 1U); + + // A fresh full MU1 transaction is committed while the common grid can + // keep publishing MU2 and MU3 without resetting either bank. + CHECK(tx[0].begin(200U, mu1->snapshot(), now + 3U, true)); + auto& fresh = tx[0].draft(); + fresh.frequency_millihz = 60000U; + tx[0].mark_frequency(); + tx[0].mark_shape(); + for (std::size_t ch = 0U; ch < 8U; ++ch) { + fresh.channels[ch].quality = static_cast(ch + 10U); + tx[0].mark_set(ch); + tx[0].mark_enabled(ch); + } + const auto fresh_hash = sv_scenario_hash(fresh); + CHECK(tx[0].commit(*mu1, 200U, fresh_hash, now + 4U) == + SvScenarioTransaction::Result::accepted); + CHECK(tx[0].confirm_readback(*mu1, 200U, now + 5U)); + CHECK(banks.generation(0U) == 1U); + CHECK(banks.generation(1U) == 1U && banks.generation(2U) == 1U); + CHECK(banks.control_bank_for_serial_writer(3U) == nullptr); + std::cout << "PASS M4 P0D3: isolated transaction, checksum, stale generation, readback\n"; +} + + +void verify_independent_stream_tx_gate() { + M4ThreeSvStreamGate gate{}; + CHECK(gate.snapshot_mask() == 0x07U); + CHECK(gate.set_enabled(1U, false)); + CHECK(gate.snapshot_mask() == 0x05U); + CHECK(gate.set_enabled(2U, false)); + CHECK(gate.snapshot_mask() == 0x01U); + CHECK(gate.set_enabled(1U, true)); + CHECK(gate.snapshot_mask() == 0x03U); + CHECK(!gate.set_enabled(3U, true)); + CHECK(gate.snapshot_mask() == 0x03U); + // Begin the timeline fixture with all three MUs enabled. The preceding + // mask-toggling assertions intentionally left MU3 disabled. + gate.reset_all(); + CHECK(gate.snapshot_mask() == 0x07U); + + // Independent electrical phase accumulation continues while packets + // for one MU are suppressed; shared smpCnt is NEVER restarted. + std::array engines{}; + M4ThreeSvSignalBanks banks{}; + std::array sent{}; + std::array last_sample{}; + for (std::uint32_t tick = 0U; tick < 4050U; ++tick) { + if (tick == 10U) CHECK(gate.set_enabled(1U, false)); + if (tick == 4020U) CHECK(gate.set_enabled(1U, true)); + const auto mask = gate.snapshot_mask(); + const auto shared_cnt = static_cast(tick % 4000U); + for (std::size_t slot = 0U; slot < 3U; ++slot) { + SvLiveSignalState state{}; + CHECK(banks.snapshot(slot, state)); + static_cast(engines[slot].next(state, 4000U)); + if ((mask & (1U << slot)) == 0U) continue; + last_sample[slot] = shared_cnt; + ++sent[slot]; + } + } + CHECK(sent[0] == 4050U && sent[2] == 4050U); + CHECK(sent[1] == 40U); // ticks 0-9 + ticks 4020-4049 + CHECK(last_sample[0] == 49U && last_sample[1] == 49U && + last_sample[2] == 49U); + CHECK(engines[0].phase_accumulator() == engines[1].phase_accumulator()); + CHECK(engines[1].phase_accumulator() == engines[2].phase_accumulator()); + gate.reset_all(); + CHECK(gate.snapshot_mask() == 0x07U); + std::cout << "PASS M4 P0D3: per-MU TX gates rejoin shared sampling grid\n"; +} + } // namespace int main() { try { verify_three_atomic_isolated_setpoint_banks(); + verify_three_isolated_complete_scenario_transactions(); + verify_independent_stream_tx_gate(); return 0; } catch (const std::exception& error) { std::cerr << error.what() << '\n';