diff --git a/docs/M4_P0C2B_THREE_SV_TX_PREVIEW.md b/docs/M4_P0C2B_THREE_SV_TX_PREVIEW.md new file mode 100644 index 000000000..c02682790 --- /dev/null +++ b/docs/M4_P0C2B_THREE_SV_TX_PREVIEW.md @@ -0,0 +1,39 @@ +# M4-P0C2B — Three-SV Ethernet TX preview + +**Status:** experimental opt-in firmware code under PR; not an official M4 release. `M3.H.3` official stable stays unchanged. + +## Achieved by this change + +One ESP32-P4 uses its **existing GPTimer publisher task, EMAC transmit path, PTP clock lease and sample-count logic** to generate **three canonical SV frames per shared 250 µs tick** instead of cloning entire FreeRTOS publishers. The source is the exact three-record SCD-derived `ARM3 V2` roster from P0C1: + +- MU1 is the canonical publisher's usual `PacketTemplate` and `SvFixedPointSineEngine`. +- MU2 and MU3 each have an **independently prebuilt packet buffer, separate DDS engine and separate snapshotted signal state**. Packet templates are built once at START, outside the 4kHz hot path. +- **Memory/DSP optimization:** replaced every 16 KiB per-engine `4096 × int32` sine table with one **1,025-entry Q30 quarter-wave ROM table (~4 KiB shared)**. Each DDS engine now owns only a phase accumulator (4 bytes). Three publisher engines free approximately **48 KiB of per-instance RAM** and avoid three runtime `sin()` table generations; a 4096-point host parity test allows at most 1 Q30 LSB difference. +- Each stream's encoded destination multicast MAC, `svID`, VLAN and APPID are taken unchanged from the independently validated V2 profile (including the reference SCD's same APPID `0x4000`). +- Each tick's three frames use the **same** `smpCnt` and one existing `smpSynch` decision; the M4 shared-grid planner checks common slot count/epoch and revokes group TX on phase/session inconsistency. There is one physical TX queue, so packets leave sequentially. +- Firmware measures every three-frame EMAC submission batch without formatting logs in the hot path, recording **maximum submit duration** and **batches over the 250 µs deadline** in the existing periodic timing report. These are instrumentation outputs; no fabricated throughput numbers. +- `pending_ticks>1` advances the phase engines by missed slots and skips stale frames; each canonical send participates in the existing bounded Ethernet failure / fail-closed logic. +- Diagnostic mirrors/probes are disabled in three-SV mode, instead of multiplying debug traffic by three. + +## Engineering activation sequence + +This is a **lab preview**, not yet a GUI-exposed feature: + +1. On an isolated bench, flash the **paired M4 feature-branch firmware**. +2. With SV STOPPED, send `MULTISV BEGIN / CHUNK / VALIDATE / COMMIT` with the V2 envelope built from the three-IED SCD. Confirm `MULTISV SHOW` reports the exact 3 MACs/svIDs. +3. Send `MULTISV SELECT`. No SV packets are sent until START is accepted. `MULTISV DESELECT` returns the next START to the original single-publisher mode; neither command is allowed while SV is running. +4. Follow the **existing** `CFG BEGIN / CFG COMMIT / CFG SHOW / START` transaction/boot-token handshake and verified Ethernet LINK UP. Selection is independent of that verified start authority. The old STOP command stops the one common publisher task. +5. With a test capture NIC, examine EtherType `0x88BA` for `MU01_SV1`, `MU01_SV2`, `MU01_SV3` and MAC suffixes `01/02/03`. Do not infer 12k measured frames/s from CI compile success: collect the actual PHY packet capture and TX lateness counters. +6. Use `MULTISV DESELECT` while STOPPED to return to exactly the old single-SV publisher semantics. + +The host SCL compiler chooses the supported `9-2LE` timing family and resolves each 4I4V DataSet; ESP firmware **does not parse SCD/XML**. + +## Limits, and P0D/P0E next + +- **Setpoints:** MU2/MU3 currently own *independent snapshots* of the same initial 4I4V bank at START. They are **not yet independently editable** by Studio while running. Per-MU atomic setpoint transactions and stream-specific STOP/START are P0D, before release. +- **PTP:** One hardware clock drives all three sample times, and the three frames receive one shared existing firmware `smpSynch` decision; a correct `smpCnt` does not in itself prove synchronization to an external GM. Internal bench SOURCE mode remains the practical default. +- **Live TX performance:** 12,000 SV frames/s is the *intended* 3×4000 nominal traffic requirement, **not a measured result** until the physical ESP32-P4 capture. 250 µs deadline, TX descriptor headroom and PTP coexistence need a normal Wireshark/Npcap bench experiment, not expensive dedicated hardware. +- **GUI:** single-window MU1/MU2/MU3 pagination and SCL-import/deploy UI remain to be wired in P0E. +- **Compatibility:** no change to the official M3.H.3 release files or V1 `PROFILE` binary format. M4 selectable TX is a separate firmware capability `MULTISV-TX-LAB-PREVIEW`, not the final `SV-MULTI3-V1`. + +**Next:** protect per-MU signal banks with generation-specific atomic commits (P0D), then bind Studio Table/Waveform/Phasor via a three-row roster/scenario model (P0E). Release only a matched Studio+firmware M4 package after capture-driven optimization and repeatable successful real SV traffic. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b83c74638..232875745 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -16,6 +16,9 @@ #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "multi_profile_control.hpp" +#include "compiled_profile_adapter.hpp" +#include "ariec61850/sampled_values/m4_three_sv_grid.hpp" #include "sdkconfig.h" #if CONFIG_AR_PTP_LAB_TX #include "ptp_lab_task.hpp" @@ -172,6 +175,9 @@ struct TimingStats final { std::uint64_t mirror_ok{}; std::uint64_t mirror_fail{}; std::uint64_t missed_slots{}; + std::uint32_t multi_batch_max_us{}; + std::uint64_t multi_batch_overruns{}; + std::uint64_t multi_batches{}; std::int64_t lateness_sum_us{}; std::int32_t lateness_min_us{std::numeric_limits::max()}; std::int32_t lateness_max_us{std::numeric_limits::min()}; @@ -508,6 +514,10 @@ void merge_stats(TimingStats& local) noexcept { g_interval_stats.mirror_ok += local.mirror_ok; g_interval_stats.mirror_fail += local.mirror_fail; g_interval_stats.missed_slots += local.missed_slots; + g_interval_stats.multi_batch_max_us = std::max( + g_interval_stats.multi_batch_max_us, local.multi_batch_max_us); + g_interval_stats.multi_batch_overruns += local.multi_batch_overruns; + g_interval_stats.multi_batches += local.multi_batches; g_interval_stats.lateness_sum_us += local.lateness_sum_us; g_interval_stats.lateness_min_us = std::min(g_interval_stats.lateness_min_us, local.lateness_min_us); g_interval_stats.lateness_max_us = std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); @@ -525,6 +535,10 @@ void accumulate_stats(TimingStats& target, const TimingStats& source) noexcept { target.mirror_ok += source.mirror_ok; target.mirror_fail += source.mirror_fail; target.missed_slots += source.missed_slots; + target.multi_batch_max_us = std::max( + target.multi_batch_max_us, source.multi_batch_max_us); + target.multi_batch_overruns += source.multi_batch_overruns; + target.multi_batches += source.multi_batches; target.lateness_sum_us += source.lateness_sum_us; target.lateness_min_us = std::min(target.lateness_min_us, source.lateness_min_us); target.lateness_max_us = std::max(target.lateness_max_us, source.lateness_max_us); @@ -1115,6 +1129,16 @@ void publisher_task(void* argument) { PacketTemplate canonical; PacketTemplate mirror; + // One publisher task, one timer and one Ethernet MAC. Extra MUs have + // dedicated packet storage and DDS phase; no extra RTOS tasks/timers. + static std::array multi_extra_packets{}; + static std::array multi_profiles{}; + static std::array multi_extra_engines{}; + static std::array multi_extra_signals{}; + ar::iec61850::sampled_values::M4ThreeSvGridPlanner multi_grid{}; + bool multi_active = false; + std::uint64_t multi_tick_index = 0U; + std::uint64_t multi_ptp_session = 1U; RuntimePublisherProfile active_profile = initial_profile; static SvFixedPointSineEngine signal_engine; gptimer_handle_t sample_clock = nullptr; @@ -1150,9 +1174,35 @@ void publisher_task(void* argument) { if (pending_ticks == 0U && live_tx_running()) continue; if (take_start_request()) { + // BEGIN/VALIDATE/COMMIT and explicit SELECT came through one + // console task while STOPPED. A selected roster is immutable + // until STOP; never decode XML or allocate in a 250us tick. + multi_active = false; + if (const auto* roster = + ar::esp32p4::smv::multi_profile_tx_roster_if_selected()) { + bool valid = true; + for (std::size_t slot = 0U; slot < multi_profiles.size(); ++slot) { + if (ar::esp32p4::smv::map_compiled_runtime_profile( + roster[slot], multi_profiles[slot]) != + ar::esp32p4::smv::BinaryRuntimeProfileStatus::ok || + !runtime_profile_validate(multi_profiles[slot]) || + multi_profiles[slot].publisher_rate_hz != 4000U || + multi_profiles[slot].sample_counter_modulus != 4000U) { + valid = false; + break; + } + } + if (!valid) { + ESP_LOGE(kTag, "MULTISV START rejected: invalid or unsupported MU profile"); + live_control_force_stop(); + continue; + } + multi_active = true; + } #if AR_SV_PTP_PHASE_SUPPORTED const auto now_lease = read_ptp_epoch_clock_lease(); - const auto requested_profile = runtime_profile_snapshot(); + const auto requested_profile = multi_active + ? multi_profiles[0] : runtime_profile_snapshot(); // A fast STOP→START may arrive before the publisher has run its // 200-ms idle/park branch. There is still exactly one valid timer; // do not destroy that continuously aligned phase grid just @@ -1172,7 +1222,8 @@ void publisher_task(void* argument) { sample_clock != nullptr, epoch_armed, g_sample_clock_context.phase_enabled, source_unchanged, profile_unchanged, auto_mode); - const bool reuse_preflight = sample_clock != nullptr && epoch_armed && + const bool reuse_preflight = !multi_active && + sample_clock != nullptr && epoch_armed && requested_profile.sample_counter_modulus == requested_profile.publisher_rate_hz && SvAutoPreflightPolicy::can_reuse( @@ -1220,17 +1271,44 @@ void publisher_task(void* argument) { stop_sample_clock(sample_clock); #endif preflight_clock = false; - active_profile = runtime_profile_snapshot(); + active_profile = multi_active + ? multi_profiles[0] : runtime_profile_snapshot(); if (!runtime_profile_validate(active_profile)) { ESP_LOGE(kTag, "START rejected: active runtime profile is invalid"); continue; } if (!build_packet_template(source_mac, active_profile, false, false, canonical)) { ESP_LOGE(kTag, "START rejected: canonical template build failed"); + live_control_force_stop(); continue; } + if (multi_active) { + bool built = true; + for (std::size_t slot = 1U; slot < multi_profiles.size(); ++slot) { + if (!build_packet_template(source_mac, multi_profiles[slot], + false, false, multi_extra_packets[slot - 1U])) { + built = false; + break; + } + } + if (!built) { + ESP_LOGE(kTag, "MULTISV START rejected: three canonical templates required"); + live_control_force_stop(); + continue; + } + const auto base_signal = live_signal_snapshot(); + for (std::size_t slot = 0U; slot < multi_extra_signals.size(); ++slot) { + // Physical per-MU live edits arrive with P0D; until then + // separate immutable initial copies prevent shared state + // mutation and each MU has an independent phase engine. + multi_extra_signals[slot] = base_signal; + multi_extra_engines[slot].reset_phase(); + } + multi_tick_index = 0U; + } #if CONFIG_AR_SMV_BROADCAST_MIRROR - if (!build_packet_template(source_mac, active_profile, true, true, mirror)) { + if (!multi_active && + !build_packet_template(source_mac, active_profile, true, true, mirror)) { ESP_LOGE(kTag, "START rejected: diagnostic mirror template build failed"); continue; } @@ -1284,6 +1362,8 @@ void publisher_task(void* argument) { current_phase_healthy ? 1U : 0U, phase_observer_fresh ? 1U : 0U); #endif + if (multi_active) + ESP_LOGI(kTag, "MULTISV TX selected: 3 x 4000fps, common GPTimer/PTP sample grid"); ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -1326,6 +1406,19 @@ void publisher_task(void* argument) { static_cast(expected_schedule.next_ticks()); } } + // The timer/ISR is the sole sampling clock for all selected + // MUs. Its PTP session generation is shared, not one per stream. + if (multi_active) { +#if AR_SV_PTP_PHASE_SUPPORTED + multi_ptp_session = std::max( + 1U, g_sample_clock_context.session_generation.load( + std::memory_order_acquire)); +#else + multi_ptp_session = 1U; +#endif + multi_grid.reset(multi_ptp_session); + + } // Do not log after the timer is enabled: even a single slow // console write can accumulate ISR notifications before the // publisher consumes its first slot (false START misses). @@ -1477,6 +1570,13 @@ void publisher_task(void* argument) { signal_state, active_profile.publisher_rate_hz, static_cast(missed_slots)); + if (multi_active) { + for (std::size_t slot = 0U; slot < multi_extra_engines.size(); ++slot) + multi_extra_engines[slot].advance( + multi_extra_signals[slot], 4000U, + static_cast(missed_slots)); + multi_tick_index += missed_slots; + } } const std::int64_t lateness_us = actual_wake_us - expected_wake_us; @@ -1547,8 +1647,34 @@ void publisher_task(void* argument) { const auto sync_decision = smp_synch_lab_decision(); const auto sample_synchronization = static_cast(sync_decision.value); + if (multi_active) { +#if AR_SV_PTP_PHASE_SUPPORTED + const auto actual_session = std::max( + 1U, g_sample_clock_context.session_generation.load( + std::memory_order_acquire)); +#else + const auto actual_session = multi_ptp_session; +#endif + const auto shared = multi_grid.plan({ + actual_session, multi_tick_index, sample_synchronization, true}); + // A change to the one sample clock/lease revokes the whole + // three-stream batch, rather than sending three stale epochs. + if (shared.timing_discontinuity || shared.count != 3U || + shared.frames[0].smp_cnt != canonical_sample_count) { + ESP_LOGE(kTag, "MULTISV shared PTP/sample grid revoked"); + live_control_force_stop(); + stop_sample_clock(sample_clock); + schedule_anchored = false; + continue; + } + } 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. + const std::int64_t multi_batch_start_us = + multi_active ? esp_timer_get_time() : 0LL; #if AR_SV_TX_EGRESS_SUPPORTED // Every 2 seconds, replace normal SV TX for the real canonical // frame with descriptor-stamped TX. This reaches two valid @@ -1709,23 +1835,76 @@ void publisher_task(void* argument) { #endif } + // 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 + // uses ONE EMAC and is necessarily serialized. No malloc, locks, + // XML traversal or logging on a successful 250us batch. + if (multi_active && live_tx_running()) { + 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); + patch_packet(multi_extra_packets[slot], canonical_sample_count, + sample_synchronization, extra_row, + multi_extra_signals[slot]); + const auto tx = esp_eth_transmit( + eth_handle, multi_extra_packets[slot].bytes.data(), + multi_extra_packets[slot].bytes.size()); + if (tx == ESP_OK) { + ++local.canonical_ok; + if (consecutive_canonical_tx_failures != 0U) + g_sv_tx_faults.recovered(); + consecutive_canonical_tx_failures = 0U; + last_canonical_tx_error = ESP_OK; + } else { + ++local.canonical_fail; + last_canonical_tx_error = tx; + ++consecutive_canonical_tx_failures; + const bool first = g_sv_tx_faults.failure( + static_cast(tx), + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U); +#if AR_SV_PTP_PHASE_SUPPORTED + if (first) { + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::canonical_tx); + } +#else + (void)first; +#endif + } + } + } + #if CONFIG_AR_SMV_BROADCAST_MIRROR + if (!multi_active) { patch_packet( mirror, mirror_sample_count, sample_synchronization, row, signal_state); const esp_err_t mirror_result = esp_eth_transmit(eth_handle, mirror.bytes.data(), mirror.bytes.size()); if (mirror_result == ESP_OK) ++local.mirror_ok; else ++local.mirror_fail; + } #endif #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES - if (canonical_sample_count == 0U) { + if (!multi_active && canonical_sample_count == 0U) { static_cast(esp_eth_transmit( eth_handle, probe_broadcast.data(), probe_broadcast.size())); static_cast(esp_eth_transmit( eth_handle, probe_multicast.data(), probe_multicast.size())); } #endif + if (multi_active) { + const std::int64_t elapsed = esp_timer_get_time() - multi_batch_start_us; + const auto bounded = static_cast( + std::clamp(elapsed, 0LL, 0xFFFFFFFFLL)); + local.multi_batch_max_us = std::max(local.multi_batch_max_us, bounded); + ++local.multi_batches; + if (bounded > 250U) ++local.multi_batch_overruns; + } } update_local_timing( @@ -1755,6 +1934,7 @@ void publisher_task(void* argument) { } canonical_sample_count = static_cast( (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); + if (multi_active) ++multi_tick_index; mirror_sample_count = static_cast(mirror_sample_count + 1U); if (local.samples >= kStatsMergeEvery) merge_stats(local); } @@ -2317,6 +2497,16 @@ void telemetry_task(void*) { static_cast(snapshot.lateness_max_us), static_cast(snapshot.last_sample_count), static_cast(snapshot.last_signal_generation)); + if (snapshot.multi_batches != 0U) { + ESP_LOGI(kTag, + "MULTISV TX budget: batches=%llu max_3frame_submit=%luus " + "over_250us=%llu canonical_ok=%llu fail=%llu", + static_cast(snapshot.multi_batches), + static_cast(snapshot.multi_batch_max_us), + static_cast(snapshot.multi_batch_overruns), + static_cast(snapshot.canonical_ok), + static_cast(snapshot.canonical_fail)); + } } } diff --git a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp index 7b0c71238..d175c49a3 100644 --- a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp +++ b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp @@ -19,14 +19,10 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { // Device capability is narrower than host SCL validity. Do not reinterpret // unsupported wire fields or invent an alternative active profile model. -[[nodiscard]] inline BinaryRuntimeProfileStatus decode_binary_runtime_profile( - const std::span bytes, +[[nodiscard]] inline BinaryRuntimeProfileStatus map_compiled_runtime_profile( + const ar::iec61850::sampled_values::CompiledSvDeviceProfile& decoded, RuntimePublisherProfile& output) noexcept { using namespace ar::iec61850::sampled_values; - CompiledSvDeviceProfile decoded{}; - if (!SvDeviceProfileBinaryCodec::decode(bytes, decoded).success()) { - return BinaryRuntimeProfileStatus::invalid_envelope; - } if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 && decoded.profile_family != SvProfileFamily::legacy_9_2le) || (decoded.sampling_basis != SvSampleMode::samples_per_second && @@ -88,4 +84,17 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { return BinaryRuntimeProfileStatus::ok; } +// The existing single-profile V1 decoder delegates to the same capability +// gate as M4; never create a separate, weaker "three-stream" mapper. +[[nodiscard]] inline BinaryRuntimeProfileStatus decode_binary_runtime_profile( + const std::span bytes, + RuntimePublisherProfile& output) noexcept { + using namespace ar::iec61850::sampled_values; + CompiledSvDeviceProfile decoded{}; + if (!SvDeviceProfileBinaryCodec::decode(bytes, decoded).success()) { + return BinaryRuntimeProfileStatus::invalid_envelope; + } + return map_compiled_runtime_profile(decoded, output); +} + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 1fdda4d42..2af19a543 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -256,7 +256,8 @@ void print_help() noexcept { ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); ESP_LOGI(kTag, " MULTISV STATUS|SHOW|BEGIN |CHUNK |VALIDATE |COMMIT |ABORT "); - ESP_LOGI(kTag, " MULTISV V2 is control-plane ONLY until M4-P0C2; it does not start three SV publishers."); + ESP_LOGI(kTag, " MULTISV SELECT|DESELECT (STOPPED; verified START uses chosen roster)"); + ESP_LOGI(kTag, " MULTISV TX preview: explicit SELECT then existing validated CFG+START; 3x4kHz TX, shared sample clock; no independent per-MU live setpoints yet."); } void print_identity() noexcept { @@ -269,7 +270,7 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = - "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2"; + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2,MULTISV-TX-LAB-PREVIEW"; #else const char* capabilities = "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2"; diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp index f92a5016e..be228aa35 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp @@ -6,6 +6,7 @@ #include "esp_log.h" #include +#include #include #include #include @@ -23,6 +24,9 @@ constexpr char kDelimiters[] = " \t\r\n"; // One UART control-task owner; real-time publisher does NOT touch this bank // until P0C2 adds a separately synchronized activation protocol. MultiSvConfigStore g_multi{}; +// 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}; void upper(char* text) noexcept { if (text == nullptr) return; @@ -70,7 +74,7 @@ void upper(char* text) noexcept { void print_status() noexcept { ESP_LOGI(kTag, - "MULTISV STATUS staging=%u validated=%u transaction=%lu received=%lu expected=%lu last=%lu configured=%u generation=%llu hash=%016llX bytes=%lu output=INACTIVE_UNTIL_M4_P0C2", + "MULTISV STATUS staging=%u validated=%u transaction=%lu received=%lu expected=%lu last=%lu configured=%u generation=%llu hash=%016llX bytes=%lu output=OPT_IN_THREE_SV_TX_PREVIEW", g_multi.uploading() ? 1U : 0U, g_multi.validated() ? 1U : 0U, static_cast(g_multi.transaction()), @@ -81,6 +85,9 @@ void print_status() noexcept { static_cast(g_multi.generation()), static_cast(g_multi.committed_hash()), static_cast(g_multi.committed_bytes())); + ESP_LOGI(kTag, "MULTISV TXMODE selected=%u configured=%u (START uses selected roster; STOP ends TX)", + g_multi_tx_selected.load(std::memory_order_acquire) ? 1U : 0U, + g_multi.configured() ? 1U : 0U); } void print_roster() noexcept { @@ -113,6 +120,15 @@ void print_roster() noexcept { void multi_profile_abort_staging() noexcept { g_multi.abort(); } +const ar::iec61850::sampled_values::CompiledSvDeviceProfile* +multi_profile_tx_roster_if_selected() noexcept { + if (!g_multi_tx_selected.load(std::memory_order_acquire) || + !g_multi.configured()) return nullptr; + // COMMIT is only accepted while STOPPED, and START is parsed by this + // same serial command task; no writer can race a running publisher. + return g_multi.committed_profiles().data(); +} + void handle_multi_profile_command(char* arguments) noexcept { char* save = nullptr; char* command = strtok_r(arguments, kDelimiters, &save); @@ -132,6 +148,25 @@ void handle_multi_profile_command(char* arguments) noexcept { return; } + // Opt-in: generic legacy START remains single-stream unless SELECT + // explicitly arms a committed 3-SV roster. An incomplete transfer cannot + // affect the active mode, and SELECT never starts transmission itself. + if (!std::strcmp(command, "SELECT") || !std::strcmp(command, "DESELECT")) { + if (!no_extra(&save) || live_tx_running() || g_multi.uploading()) { + ESP_LOGE(kTag, "MULTISV SELECT rejected: STOP_SV_AND_UPLOAD_FIRST"); + return; + } + if (!std::strcmp(command, "SELECT") && !g_multi.configured()) { + ESP_LOGE(kTag, "MULTISV SELECT rejected: no committed roster"); + return; + } + const bool selected = !std::strcmp(command, "SELECT"); + 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"); + return; + } + std::uint32_t txn{}; if (!parse_uint(strtok_r(nullptr, kDelimiters, &save), txn) || txn == 0U) { ESP_LOGE(kTag, "MULTISV rejected: missing or invalid transaction ID"); @@ -197,6 +232,9 @@ void handle_multi_profile_command(char* arguments) noexcept { ESP_LOGE(kTag, "MULTISV COMMIT rejected: VALIDATE required or transaction invalid"); return; } + // 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", static_cast(txn), diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp index b2e287a1c..9a2438afc 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp @@ -1,6 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "ariec61850/sampled_values/compiled_device_profile.hpp" + +// 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 +// runs. The publisher only reads this immutable table on START; no hot-path +// XML parsing or shared mutable signal packet buffers. namespace ar::esp32p4::smv { void handle_multi_profile_command(char* arguments) noexcept; void multi_profile_abort_staging() 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. +// Capacity is guaranteed by M4ThreeSvBinaryCodec (exactly three records). +[[nodiscard]] const ar::iec61850::sampled_values::CompiledSvDeviceProfile* +multi_profile_tx_roster_if_selected() noexcept; } diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 5c5a141d0..e0b549901 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -2,6 +2,8 @@ #pragma once +#include "ariec61850/sampled_values/sine_quarter_lut.hpp" + #include #include #include @@ -159,9 +161,9 @@ class SvFixedPointSineEngine final { public: using SampleRow = std::array; - SvFixedPointSineEngine() noexcept { - build_lut(); - } + // One shared, precomputed flash LUT. Per MU only phase_accumulator_ lives + // in RAM; constructing three publishers no longer rebuilds 3 x 4096 sin(). + SvFixedPointSineEngine() noexcept = default; void reset_phase(const std::uint32_t phase = 0U) noexcept { phase_accumulator_ = phase; @@ -209,11 +211,11 @@ class SvFixedPointSineEngine final { const auto offset = phase_from_millidegrees(channel.phase_millidegrees); const std::uint32_t phase = base_phase + offset; const auto lut_index = static_cast(phase >> (32U - 12U)); - std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + std::int64_t sine_q30 = detail::sine_q30_from_index(lut_index); if (index < 4U && state.current_shape.enabled) { const auto harmonic_phase = phase * state.current_shape.harmonic_order; const auto harmonic_index = static_cast(harmonic_phase >> (32U - 12U)); - const std::int64_t harmonic_q30 = sine_lut_[harmonic_index & (kLiveSineLutSize - 1U)]; + const std::int64_t harmonic_q30 = detail::sine_q30_from_index(harmonic_index); constexpr std::int64_t q30 = std::int64_t{1} << 30U; sine_q30 += (harmonic_q30 * state.current_shape.harmonic_permille) / std::int64_t{1000}; sine_q30 += (q30 * state.current_shape.dc_offset_permille) / std::int64_t{1000}; @@ -258,19 +260,8 @@ class SvFixedPointSineEngine final { } private: - std::array sine_lut_{}; std::uint32_t phase_accumulator_{}; - void build_lut() noexcept { - constexpr double pi = 3.141592653589793238462643383279502884; - constexpr double q30 = static_cast(std::uint64_t{1} << 30U); - for (std::size_t index = 0U; index < sine_lut_.size(); ++index) { - const double angle = 2.0 * pi * static_cast(index) / - static_cast(sine_lut_.size()); - sine_lut_[index] = static_cast(std::llround(std::sin(angle) * q30)); - } - } - [[nodiscard]] static std::int64_t rms_to_peak_counts(const std::int32_t rms) noexcept { constexpr std::int64_t sqrt2_q30 = 1518500250LL; return (static_cast(rms) * sqrt2_q30) >> 30U; @@ -299,6 +290,9 @@ class SvFixedPointSineEngine final { } }; +static_assert(sizeof(SvFixedPointSineEngine) <= 16U, + "An MU DDS engine must not allocate its own sine LUT"); + [[nodiscard]] inline std::optional live_channel_index( const std::string_view name) noexcept { constexpr std::array names{ diff --git a/include/ariec61850/sampled_values/sine_quarter_lut.hpp b/include/ariec61850/sampled_values/sine_quarter_lut.hpp new file mode 100644 index 000000000..68935ffb9 --- /dev/null +++ b/include/ariec61850/sampled_values/sine_quarter_lut.hpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +namespace ar::iec61850::sampled_values::detail { + +// Q30 quarter-wave table: 1025 samples cover [0, pi/2]. All three M4 MU +// engines share the same read-only flash data; no per-instance 16KiB LUT, +// no startup std::sin()/llround() loop and no heap/locks on the sample path. +// Grid 4096 = 4 * 1024, preserving the original 12-bit phase indexing. +inline constexpr std::array kSvSineQuarterQ30{{ + 0, 1647099, 3294193, 4941281, 6588356, 8235416, 9882456, 11529474, 13176464, 14823423, 16470347, 18117233, + 19764076, 21410872, 23057618, 24704310, 26350943, 27997515, 29644021, 31290457, 32936819, 34583104, 36229307, 37875426, + 39521455, 41167391, 42813230, 44458968, 46104602, 47750128, 49395541, 51040837, 52686014, 54331067, 55975992, 57620785, + 59265442, 60909960, 62554335, 64198563, 65842639, 67486561, 69130324, 70773924, 72417357, 74060620, 75703709, 77346620, + 78989349, 80631892, 82274245, 83916404, 85558366, 87200127, 88841683, 90483029, 92124163, 93765079, 95405776, 97046247, + 98686491, 100326502, 101966277, 103605812, 105245103, 106884147, 108522939, 110161476, 111799753, 113437768, 115075515, 116712992, + 118350194, 119987118, 121623759, 123260114, 124896179, 126531950, 128167423, 129802595, 131437462, 133072019, 134706263, 136340190, + 137973796, 139607077, 141240030, 142872651, 144504935, 146136880, 147768480, 149399733, 151030634, 152661180, 154291367, 155921191, + 157550647, 159179733, 160808445, 162436778, 164064728, 165692293, 167319468, 168946249, 170572633, 172198615, 173824192, 175449360, + 177074115, 178698453, 180322371, 181945865, 183568930, 185191564, 186813762, 188435520, 190056834, 191677702, 193298119, 194918080, + 196537583, 198156624, 199775198, 201393302, 203010932, 204628085, 206244756, 207860942, 209476638, 211091842, 212706549, 214320755, + 215934457, 217547651, 219160334, 220772500, 222384147, 223995270, 225605867, 227215933, 228825464, 230434456, 232042906, 233650811, + 235258165, 236864966, 238471210, 240076892, 241682010, 243286558, 244890535, 246493935, 248096755, 249698991, 251300640, 252901697, + 254502159, 256102022, 257701283, 259299937, 260897982, 262495412, 264092224, 265688415, 267283981, 268878918, 270473223, 272066891, + 273659918, 275252302, 276844038, 278435122, 280025552, 281615322, 283204430, 284792871, 286380643, 287967740, 289554160, 291139898, + 292724951, 294309316, 295892988, 297475964, 299058239, 300639811, 302220676, 303800829, 305380268, 306958988, 308536985, 310114257, + 311690799, 313266607, 314841679, 316416009, 317989595, 319562433, 321134518, 322705848, 324276419, 325846226, 327415267, 328983538, + 330551034, 332117752, 333683689, 335248841, 336813204, 338376774, 339939549, 341501523, 343062693, 344623057, 346182609, 347741347, + 349299266, 350856364, 352412636, 353968079, 355522689, 357076462, 358629395, 360181484, 361732726, 363283116, 364832652, 366381329, + 367929144, 369476093, 371022173, 372567379, 374111709, 375655159, 377197725, 378739403, 380280190, 381820082, 383359076, 384897167, + 386434353, 387970630, 389505993, 391040440, 392573967, 394106570, 395638246, 397168991, 398698801, 400227673, 401755603, 403282588, + 404808624, 406333708, 407857835, 409381002, 410903207, 412424444, 413944711, 415464004, 416982319, 418499653, 420016002, 421531363, + 423045732, 424559105, 426071480, 427582852, 429093217, 430602573, 432110916, 433618242, 435124548, 436629829, 438134084, 439637307, + 441139496, 442640647, 444140756, 445639820, 447137835, 448634799, 450130706, 451625555, 453119340, 454612060, 456103710, 457594286, + 459083786, 460572205, 462059541, 463545789, 465030947, 466515010, 467997976, 469479840, 470960600, 472440251, 473918791, 475396216, + 476872522, 478347705, 479821764, 481294693, 482766489, 484237150, 485706671, 487175049, 488642281, 490108363, 491573292, 493037064, + 494499676, 495961124, 497421405, 498880516, 500338453, 501795212, 503250791, 504705185, 506158392, 507610408, 509061229, 510510853, + 511959275, 513406493, 514852502, 516297300, 517740883, 519183248, 520624391, 522064309, 523502998, 524940456, 526376678, 527811662, + 529245404, 530677900, 532109148, 533539144, 534967884, 536395365, 537821584, 539246538, 540670223, 542092635, 543513772, 544933630, + 546352205, 547769495, 549185496, 550600205, 552013618, 553425732, 554836544, 556246051, 557654248, 559061133, 560466703, 561870954, + 563273883, 564675486, 566075761, 567474703, 568872310, 570268579, 571663506, 573057087, 574449320, 575840202, 577229728, 578617896, + 580004702, 581390144, 582774218, 584156920, 585538248, 586918198, 588296766, 589673951, 591049748, 592424154, 593797166, 595168781, + 596538995, 597907806, 599275210, 600641203, 602005783, 603368947, 604730691, 606091012, 607449906, 608807372, 610163404, 611518001, + 612871159, 614222875, 615573145, 616921967, 618269338, 619615253, 620959711, 622302707, 623644239, 624984303, 626322897, 627660017, + 628995660, 630329823, 631662503, 632993696, 634323400, 635651611, 636978327, 638303543, 639627258, 640949467, 642270169, 643589359, + 644907034, 646223192, 647537830, 648850943, 650162530, 651472587, 652781111, 654088099, 655393548, 656697454, 657999816, 659300629, + 660599890, 661897597, 663193747, 664488336, 665781362, 667072820, 668362709, 669651026, 670937767, 672222928, 673506508, 674788504, + 676068911, 677347728, 678624950, 679900576, 681174602, 682447025, 683717842, 684987051, 686254647, 687520629, 688784993, 690047736, + 691308855, 692568348, 693826211, 695082441, 696337036, 697589992, 698841307, 700090977, 701339000, 702585372, 703830092, 705073155, + 706314559, 707554301, 708792378, 710028787, 711263525, 712496590, 713727978, 714957687, 716185713, 717412054, 718636707, 719859669, + 721080937, 722300508, 723518380, 724734549, 725949013, 727161768, 728372813, 729582143, 730789757, 731995651, 733199822, 734402269, + 735602987, 736801974, 737999228, 739194745, 740388522, 741580558, 742770848, 743959390, 745146182, 746331221, 747514503, 748696026, + 749875788, 751053785, 752230015, 753404474, 754577161, 755748072, 756917205, 758084557, 759250125, 760413906, 761575898, 762736098, + 763894504, 765051111, 766205919, 767358923, 768510122, 769659512, 770807092, 771952857, 773096806, 774238936, 775379244, 776517728, + 777654384, 778789210, 779922204, 781053363, 782182683, 783310163, 784435800, 785559591, 786681534, 787801625, 788919863, 790036244, + 791150767, 792263427, 793374223, 794483153, 795590213, 796695401, 797798714, 798900150, 799999706, 801097379, 802193167, 803287068, + 804379079, 805469196, 806557419, 807643743, 808728167, 809810688, 810891304, 811970011, 813046808, 814121692, 815194659, 816265709, + 817334838, 818402043, 819467323, 820530675, 821592095, 822651583, 823709135, 824764748, 825818421, 826870150, 827919934, 828967769, + 830013654, 831057586, 832099562, 833139580, 834177638, 835213733, 836247863, 837280024, 838310216, 839338435, 840364679, 841388945, + 842411232, 843431536, 844449856, 845466188, 846480531, 847492882, 848503239, 849511600, 850517961, 851522321, 852524677, 853525028, + 854523370, 855519701, 856514019, 857506321, 858496606, 859484870, 860471112, 861455330, 862437520, 863417681, 864395810, 865371905, + 866345964, 867317984, 868287963, 869255900, 870221790, 871185633, 872147426, 873107167, 874064853, 875020483, 875974054, 876925563, + 877875009, 878822389, 879767701, 880710943, 881652112, 882591207, 883528225, 884463164, 885396022, 886326796, 887255485, 888182086, + 889106597, 890029016, 890949341, 891867569, 892783698, 893697727, 894609652, 895519473, 896427186, 897332790, 898236282, 899137661, + 900036924, 900934069, 901829095, 902721998, 903612776, 904501429, 905387953, 906272347, 907154608, 908034735, 908912725, 909788576, + 910662286, 911533853, 912403276, 913270551, 914135678, 914998653, 915859476, 916718143, 917574653, 918429004, 919281194, 920131221, + 920979082, 921824777, 922668302, 923509656, 924348837, 925185843, 926020672, 926853322, 927683790, 928512076, 929338177, 930162092, + 930983817, 931803352, 932620694, 933435842, 934248793, 935059546, 935868098, 936674448, 937478595, 938280535, 939080267, 939877790, + 940673101, 941466198, 942257081, 943045745, 943832191, 944616416, 945398418, 946178196, 946955747, 947731070, 948504163, 949275023, + 950043650, 950810042, 951574196, 952336111, 953095785, 953853216, 954608403, 955361344, 956112036, 956860479, 957606670, 958350608, + 959092290, 959831716, 960568883, 961303790, 962036435, 962766816, 963494932, 964220780, 964944360, 965665669, 966384706, 967101468, + 967815955, 968528165, 969238095, 969945745, 970651112, 971354196, 972054994, 972753504, 973449725, 974143656, 974835295, 975524639, + 976211688, 976896441, 977578894, 978259047, 978936898, 979612445, 980285688, 980956623, 981625251, 982291568, 982955574, 983617267, + 984276646, 984933708, 985588453, 986240879, 986890984, 987538766, 988184225, 988827359, 989468165, 990106644, 990742793, 991376610, + 992008094, 992637245, 993264059, 993888536, 994510675, 995130473, 995747930, 996363043, 996975812, 997586236, 998194311, 998800038, + 999403415, 1000004439, 1000603111, 1001199428, 1001793390, 1002384994, 1002974239, 1003561124, 1004145648, 1004727809, 1005307605, 1005885036, + 1006460100, 1007032796, 1007603122, 1008171077, 1008736660, 1009299870, 1009860704, 1010419162, 1010975242, 1011528943, 1012080264, 1012629204, + 1013175761, 1013719934, 1014261721, 1014801122, 1015338134, 1015872758, 1016404991, 1016934832, 1017462281, 1017987335, 1018509994, 1019030256, + 1019548121, 1020063586, 1020576651, 1021087314, 1021595575, 1022101432, 1022604883, 1023105929, 1023604567, 1024100796, 1024594615, 1025086024, + 1025575020, 1026061603, 1026545772, 1027027525, 1027506862, 1027983780, 1028458280, 1028930359, 1029400018, 1029867254, 1030332067, 1030794455, + 1031254418, 1031711954, 1032167062, 1032619742, 1033069992, 1033517810, 1033963197, 1034406151, 1034846671, 1035284755, 1035720404, 1036153615, + 1036584389, 1037012723, 1037438617, 1037862069, 1038283080, 1038701647, 1039117770, 1039531448, 1039942680, 1040351465, 1040757802, 1041161689, + 1041563127, 1041962114, 1042358649, 1042752731, 1043144360, 1043533534, 1043920252, 1044304514, 1044686319, 1045065665, 1045442553, 1045816980, + 1046188946, 1046558451, 1046925492, 1047290071, 1047652185, 1048011834, 1048369016, 1048723732, 1049075980, 1049425759, 1049773069, 1050117909, + 1050460278, 1050800175, 1051137599, 1051472550, 1051805027, 1052135029, 1052462555, 1052787604, 1053110176, 1053430270, 1053747885, 1054063021, + 1054375676, 1054685850, 1054993543, 1055298753, 1055601479, 1055901722, 1056199480, 1056494753, 1056787540, 1057077840, 1057365653, 1057650977, + 1057933813, 1058214159, 1058492016, 1058767381, 1059040255, 1059310638, 1059578527, 1059843923, 1060106826, 1060367233, 1060625146, 1060880563, + 1061133483, 1061383907, 1061631833, 1061877261, 1062120190, 1062360620, 1062598550, 1062833980, 1063066909, 1063297336, 1063525261, 1063750684, + 1063973603, 1064194019, 1064411931, 1064627338, 1064840240, 1065050636, 1065258526, 1065463909, 1065666786, 1065867154, 1066065015, 1066260367, + 1066453210, 1066643544, 1066831367, 1067016680, 1067199483, 1067379774, 1067557554, 1067732821, 1067905576, 1068075818, 1068243547, 1068408763, + 1068571464, 1068731650, 1068889322, 1069044479, 1069197120, 1069347245, 1069494854, 1069639946, 1069782521, 1069922579, 1070060120, 1070195142, + 1070327646, 1070457632, 1070585099, 1070710046, 1070832474, 1070952382, 1071069770, 1071184638, 1071296985, 1071406812, 1071514117, 1071618901, + 1071721163, 1071820903, 1071918122, 1072012818, 1072104991, 1072194642, 1072281769, 1072366374, 1072448455, 1072528012, 1072605046, 1072679556, + 1072751542, 1072821003, 1072887940, 1072952352, 1073014240, 1073073603, 1073130440, 1073184753, 1073236540, 1073285802, 1073332538, 1073376748, + 1073418433, 1073457592, 1073494225, 1073528332, 1073559913, 1073588967, 1073615496, 1073639498, 1073660973, 1073679922, 1073696345, 1073710241, + 1073721611, 1073730454, 1073736771, 1073740561, 1073741824 +}}; + +[[nodiscard]] inline constexpr std::int32_t sine_q30_from_index( + const std::size_t phase_index) noexcept { + const std::size_t index = phase_index & 4095U; + const std::size_t quarter_offset = index & 1023U; + const std::size_t lookup_index = + (index & 1024U) != 0U ? 1024U - quarter_offset : quarter_offset; + const std::int32_t magnitude = kSvSineQuarterQ30[lookup_index]; + return (index & 2048U) != 0U ? -magnitude : magnitude; +} + +static_assert(kSvSineQuarterQ30.size() == 1025U); +static_assert(sine_q30_from_index(0U) == 0); +static_assert(sine_q30_from_index(1024U) == 1073741824); +static_assert(sine_q30_from_index(2048U) == 0); +static_assert(sine_q30_from_index(3072U) == -1073741824); +static_assert(sine_q30_from_index(4096U) == 0); +static_assert(sine_q30_from_index(512U) == sine_q30_from_index(1536U)); +static_assert(sine_q30_from_index(512U) == -sine_q30_from_index(2560U)); + +} // namespace ar::iec61850::sampled_values::detail diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index cdca29f96..b57721458 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -266,6 +266,26 @@ void independent_channels_change_without_stream_restart() { CHECK(row[6] != 0); } +void shared_quarter_wave_rom_lut_has_legacy_sine_parity() { + using namespace ar::iec61850::sampled_values; + static_assert(sizeof(SvFixedPointSineEngine) <= 16U); + constexpr double pi = 3.141592653589793238462643383279502884; + constexpr double q30 = static_cast(std::uint64_t{1} << 30U); + for (std::size_t index = 0U; index < kLiveSineLutSize; ++index) { + const double angle = 2.0 * pi * static_cast(index) / + static_cast(kLiveSineLutSize); + // Original M3 engine populated a private 4096-entry array using + // llround(sin(angle)*Q30). The shared 1025-value ROM quarter-wave + // should differ by at most one Q30 integer at libm rounding edges. + const auto legacy = static_cast(std::llround(std::sin(angle) * q30)); + const auto current = detail::sine_q30_from_index(index); + const std::int64_t delta = static_cast(current) - legacy; + CHECK(delta >= -1LL && delta <= 1LL); + } + CHECK(detail::sine_q30_from_index(4096U) == + detail::sine_q30_from_index(0U)); +} + } // namespace int main() { @@ -273,6 +293,7 @@ int main() { bank_commits_complete_generations(); concurrent_publisher_and_diagnostics_snapshots_are_coherent(); fixed_point_engine_preserves_frequency_and_phase(); + shared_quarter_wave_rom_lut_has_legacy_sine_parity(); dc_engine_outputs_constant_engineering_values(); ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart();