diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 68087b5b6..39fd0d4ba 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -41,6 +41,44 @@ def main() -> None: and "kMaxCanonicalTxOffsetNs = 250000LL" in sv_gate and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests, "retain independently bounded PTP sample-phase and nonzero MAC publication latency") + firmware_source = (project_root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text(encoding="utf-8") + require("kSvTxEvidenceEpochStride = 2U" in sv_gate + and "expected_epoch_stride = kSvTxEvidenceEpochStride" in sv_gate + and "kEvidenceStride = kSvTxEvidenceEpochStride" in sv_gate + and "kEgressEvidenceEveryZeroEpochs =\n SvLocalSyncEvidenceGate::kEvidenceStride" in firmware_source + and "check_fast_source_auto_qualification_with_fresh_evidence()" in sv_tests, + "SOURCE AUTO must qualify from two coherent descriptor pairs at 2/4/6s, not 48s") + # Eliminate long initial phase slew caused by GPTimer driver enable + # AFTER the EMAC/PTP epoch snapshot. All this work is outside the ISR. + enable_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_enable(timer));") + sample_ptp = firmware_source.index("const auto epoch_arm = plan_ptp_epoch_arm(eth_handle);") + start_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_start(timer));") + require(enable_clock < sample_ptp < start_clock + and "SV/PTP AUTO phase=" in firmware_source + and "MACpairs=%lu" in firmware_source + and "measured_pairs() const noexcept" in sv_gate, + "PTP startup must compensate GPTimer enable delay and expose early acquisition measurements") + require("SvLocalRefAcquisition local_acquisition;" in firmware_source + and "local_acquisition.observe(" in firmware_source + and "local_acquisition.reset();" in firmware_source + and "clean=%lu/%lu" in firmware_source + and "kRequiredCleanEpochs = 3U" in sv_gate + and "check_no_intermitttent_local_at_start()" in sv_tests, + "LOCAL REF startup must require three clean real epochs and immediately revoke on faults") + phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") + phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") + require("g_preflight_phase_clock_active" in firmware_source + and "publisher_notifications_enabled" in firmware_source + and "launch_on_next_epoch" in firmware_source + and "preflight_only = false" in firmware_source + and "SV/PTP AUTO preflight sample clock armed (SV TX OFF)" in firmware_source + and "check_auto_preflight_does_not_invent_sync_or_start()" in phase_tests + and "SvAutoPreflightPolicy::can_steer(" in firmware_source + and "SvAutoPreflightPolicy::can_advertise_local(" in firmware_source + and "if (injecting)" in firmware_source + and "preflight_telemetry" in firmware_source + and "SvAutoPreflightPolicy" in phase_header, + "AUTO SOURCE preflight must discipline the sample clock before START without transmitting SV") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 955d7ca75..74498f7c7 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -27,6 +27,9 @@ SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 #define AR_SV_PTP_PHASE_SUPPORTED 1 #include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +// The fail-closed SOURCE entry filter remains available even when descriptor +// evidence is disabled by firmware config (AUTO then stays NOT SYNC). +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" #include "freertos/semphr.h" #else #define AR_SV_PTP_PHASE_SUPPORTED 0 @@ -71,6 +74,8 @@ using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::smp_synch_lab_decision; +using ar::esp32p4::smv::smp_synch_lab_mode; +using ar::esp32p4::smv::SvSyncPolicyMode; using ar::esp32p4::smv::smp_synch_set_source_local_qualified; using ar::esp32p4::smv::smp_synch_source_local_qualified; using ar::esp32p4::smv::take_start_request; @@ -94,6 +99,8 @@ using ar::iec61850::time_sync::valid_sv_ptp_clock_lease; #if AR_SV_PTP_PHASE_SUPPORTED using ar::iec61850::time_sync::SvBoundedSlewCursor; using ar::iec61850::time_sync::SvPtpPhaseAuthority; +using ar::iec61850::time_sync::SvAutoPreflightPolicy; +using ar::iec61850::time_sync::SvLocalRefAcquisition; using ar::iec61850::time_sync::SvPtpPhaseObservation; using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif @@ -139,7 +146,8 @@ static_assert(std::atomic::is_always_lock_free); #endif std::atomic g_sample_tick_total{0U}; #if AR_SV_TX_EGRESS_SUPPORTED -constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U; +constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = + SvLocalSyncEvidenceGate::kEvidenceStride; portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; SvTxEgressEvidence g_tx_egress_evidence{}; SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker @@ -149,6 +157,9 @@ SemaphoreHandle_t g_phase_timer_mutex = nullptr; gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR TaskHandle_t g_phase_observer_task = nullptr; std::atomic g_phase_session_serial{0U}; +// SOURCE AUTO preflight disciplines the sample clock while SV TX is OFF. +// Only the realtime publisher task creates/stops this dry-run GPTimer. +std::atomic g_preflight_phase_clock_active{false}; #endif struct PacketTemplate final { @@ -204,6 +215,10 @@ struct SampleClockContext final { std::uint32_t phase_index{}; bool previous_phase_healthy{}; bool phase_enabled{}; // set before timer START; only ISR reads it + // START transfers a warmed clock at the NEXT canonical second boundary. + // Never wake the high-priority publisher for the 4kHz dry-run ticks. + std::atomic publisher_notifications_enabled{true}; + std::atomic launch_on_next_epoch{false}; #endif }; @@ -448,6 +463,11 @@ bool sample_alarm_callback( if (context->phase_enabled) { if (index == 0U) { epoch_boundary = true; + if (context->launch_on_next_epoch.exchange( + false, std::memory_order_acq_rel)) { + context->publisher_notifications_enabled.store( + true, std::memory_order_release); + } context->last_epoch_alarm.store( static_cast(event->alarm_value), std::memory_order_relaxed); @@ -504,7 +524,14 @@ bool sample_alarm_callback( static_cast(gptimer_set_alarm_action(timer, &context->next_alarm)); BaseType_t high_priority_task_woken = pdFALSE; +#if AR_SV_PTP_PHASE_SUPPORTED + if (context->publisher_notifications_enabled.load( + std::memory_order_acquire)) { + vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); + } +#else vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); +#endif #if AR_SV_PTP_PHASE_SUPPORTED if (epoch_boundary && g_phase_observer_task != nullptr) { vTaskNotifyGiveFromISR(g_phase_observer_task, &high_priority_task_woken); @@ -575,7 +602,8 @@ gptimer_handle_t start_sample_clock( const std::uint32_t publisher_rate_hz, const esp_eth_handle_t eth_handle, bool& epoch_armed, - const bool profile_has_second_aligned_counter) { + const bool profile_has_second_aligned_counter, + const bool preflight_only = false) { epoch_armed = false; #if AR_SV_PTP_PHASE_SUPPORTED g_sample_clock_context.publisher_task = publisher_task_handle; @@ -614,8 +642,12 @@ gptimer_handle_t start_sample_clock( ESP_ERROR_CHECK(gptimer_register_event_callbacks( timer, &callbacks, &g_sample_clock_context)); - // The timer is configured before reading EMAC PTP time, minimizing - // cross-clock arm latency without touching the high-frequency callback. + // GPTimer enable may require driver/PM setup. Perform it BEFORE sampling + // the PTP clock; otherwise that unpredictable setup delay moves the + // first smpCnt=0 deadline by milliseconds and the deliberately bounded + // 400us/second phase servo takes tens of seconds to converge. + // The timer still does not count until gptimer_start() below. + ESP_ERROR_CHECK(gptimer_enable(timer)); const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); // Time spent configuring GPTimer after the EMAC PTP read must not // shift the first smpCnt=0 deadline. Recheck the same lease before arm. @@ -635,6 +667,10 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.phase_enabled = epoch_armed && profile_has_second_aligned_counter && g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; + g_sample_clock_context.publisher_notifications_enabled.store( + !preflight_only, std::memory_order_relaxed); + g_sample_clock_context.launch_on_next_epoch.store( + false, std::memory_order_relaxed); #endif g_sample_clock_context.next_alarm = {}; g_sample_clock_context.next_alarm.alarm_count = epoch_armed @@ -642,7 +678,6 @@ gptimer_handle_t start_sample_clock( : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); - ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); #if AR_SV_PTP_PHASE_SUPPORTED if (g_sample_clock_context.phase_enabled) { @@ -655,6 +690,9 @@ gptimer_handle_t start_sample_clock( } void stop_sample_clock(gptimer_handle_t& timer) noexcept { +#if AR_SV_PTP_PHASE_SUPPORTED + g_preflight_phase_clock_active.store(false, std::memory_order_release); +#endif // Release the wire claim before teardown; old MAC evidence never // survives STOP/START, even when the same SOURCE later restarts. smp_synch_set_source_local_qualified(false); @@ -732,12 +770,37 @@ void publisher_task(void* argument) { std::array probe_multicast{}; #endif + bool preflight_clock = false; + std::uint32_t preflight_rate_hz = 0U; while (true) { - const std::uint32_t pending_ticks = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - if (pending_ticks == 0U) continue; + // With SV OFF, inspect verified SOURCE/AUTO readiness at 5Hz. The + // dry clock runs autonomously on the existing ISR; zero SV Ethernet + // packets are transmitted until the operator's explicit START. + const std::uint32_t pending_ticks = ulTaskNotifyTake( + pdTRUE, live_tx_running() ? portMAX_DELAY : pdMS_TO_TICKS(200)); + if (pending_ticks == 0U && live_tx_running()) continue; if (take_start_request()) { +#if AR_SV_PTP_PHASE_SUPPORTED + const auto now_lease = read_ptp_epoch_clock_lease(); + const auto requested_profile = runtime_profile_snapshot(); + const bool reuse_preflight = sample_clock != nullptr && epoch_armed && + !g_sample_clock_context.publisher_notifications_enabled.load( + std::memory_order_acquire) && + requested_profile.sample_counter_modulus == + requested_profile.publisher_rate_hz && + SvAutoPreflightPolicy::can_reuse( + preflight_clock, g_sample_clock_context.phase_enabled, + same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, now_lease), + preflight_rate_hz == requested_profile.publisher_rate_hz, + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto); + if (!reuse_preflight) stop_sample_clock(sample_clock); +#else + constexpr bool reuse_preflight = false; stop_sample_clock(sample_clock); +#endif + preflight_clock = false; active_profile = runtime_profile_snapshot(); if (!runtime_profile_validate(active_profile)) { ESP_LOGE(kTag, "START rejected: active runtime profile is invalid"); @@ -789,22 +852,29 @@ void publisher_task(void* argument) { static_cast(active_profile.vlan_priority), static_cast(canonical.bytes.size())); - // Starting the 250 us clock is the final operation in this branch. + // Timer commit is the final operation in this branch. // Formatting the profile log after timer enable previously queued // dozens of ISR notifications before the publisher could consume // its first tick, so every START falsely began with missed slots. - sample_clock = start_sample_clock( - task_handle, active_profile.publisher_rate_hz, eth_handle, - epoch_armed, - // The 1-ASDU-per-frame 80-SPC profile has exactly one - // sample-counter epoch per publisher second. Other SCL - // profiles need an explicit standard-derived epoch policy. - active_profile.sample_counter_modulus == - active_profile.publisher_rate_hz); - if (!epoch_armed) { - // Legacy first alarm already consumes the first rational tick; - // a PTP-armed first alarm is an independent epoch delay. - static_cast(expected_schedule.next_ticks()); + if (reuse_preflight) { +#if AR_SV_PTP_PHASE_SUPPORTED + // Keep the already disciplined sample clock, phase authority, + // and SOURCE generation. First SV packet is released exactly + // at the next smpCnt=0 epoch, never mid-second. + g_preflight_phase_clock_active.store(false, std::memory_order_release); + g_sample_clock_context.launch_on_next_epoch.store( + true, std::memory_order_release); +#endif + } else { + sample_clock = start_sample_clock( + task_handle, active_profile.publisher_rate_hz, eth_handle, + epoch_armed, + // Only rate==modulus has one exact sample epoch per second. + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz); + if (!epoch_armed) { + static_cast(expected_schedule.next_ticks()); + } } // Do not log after the timer is enabled: even a single slow // console write can accumulate ISR notifications before the @@ -814,7 +884,41 @@ void publisher_task(void* argument) { } if (!live_tx_running()) { +#if AR_SV_PTP_PHASE_SUPPORTED + const auto profile = runtime_profile_snapshot(); + const auto lease = read_ptp_epoch_clock_lease(); + const bool auto_source_ready = SvAutoPreflightPolicy::can_prepare( + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto, + lease.role == PtpAuthorityRole::source && + valid_sv_ptp_clock_lease(lease) && + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U, + runtime_profile_validate(profile), profile.publisher_rate_hz, + profile.sample_counter_modulus); + if (preflight_clock && (!auto_source_ready || + preflight_rate_hz != profile.publisher_rate_hz || + !same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, lease))) { + stop_sample_clock(sample_clock); + preflight_clock = false; + } + if (sample_clock == nullptr && auto_source_ready) { + bool aligned = false; + sample_clock = start_sample_clock( + task_handle, profile.publisher_rate_hz, eth_handle, + aligned, true, true); + if (aligned && g_sample_clock_context.phase_enabled) { + epoch_armed = true; + preflight_clock = true; + preflight_rate_hz = profile.publisher_rate_hz; + g_preflight_phase_clock_active.store(true, std::memory_order_release); + ESP_LOGI(kTag, "SV/PTP AUTO preflight sample clock armed (SV TX OFF)"); + } else { + stop_sample_clock(sample_clock); + } + } +#else stop_sample_clock(sample_clock); +#endif schedule_anchored = false; continue; } @@ -907,9 +1011,11 @@ void publisher_task(void* argument) { patch_packet( canonical, canonical_sample_count, sample_synchronization, row, signal_state); #if AR_SV_TX_EGRESS_SUPPORTED - // ONCE / 16 seconds, replace normal SV TX for the real canonical - // frame with descriptor-stamped TX. ESP-IDF 5.5.4 polls the DMA - // descriptor for its timestamp: never run this on every frame. + // Every 2 seconds, replace normal SV TX for the real canonical + // frame with descriptor-stamped TX. This reaches two valid + // measured pairs after ~6 seconds when all independent clocks + // qualify. ESP-IDF polls the DMA descriptor: NEVER do this on + // every frame; a slow TX must fail closed via no_missed_slots. // This is diagnostic evidence, not validated IED synchronization. bool take_egress_timestamp = false; SvTxEgressObservation egress{}; @@ -1075,8 +1181,10 @@ void sv_ptp_phase_observer_task(void* argument) { const auto eth_handle = static_cast(argument); SvPtpPhaseAuthority controller; SvPtpPublisherContinuityGate continuity; + SvLocalRefAcquisition local_acquisition; std::uint32_t accepted_epochs = 0U; std::uint32_t controller_session = 0U; + bool previous_auto_local = false; while (true) { const std::uint32_t events = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); const std::uint32_t notified_session = @@ -1085,6 +1193,9 @@ void sv_ptp_phase_observer_task(void* argument) { if (controller_session != notified_session) { controller.reset(); continuity.reset(); + local_acquisition.reset(); + accepted_epochs = 0U; + previous_auto_local = false; controller_session = notified_session; } vTaskDelay(pdMS_TO_TICKS(250)); @@ -1097,14 +1208,18 @@ void sv_ptp_phase_observer_task(void* argument) { !continuity.unchanged(notified_session, current_faults, lease.generation)) { static_cast(controller.invalidate()); + local_acquisition.reset(); revoke_sv_phase_session(notified_session); continue; } - if (events != 1U || !live_tx_running() || + if (events != 1U || + (!live_tx_running() && + !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { static_cast(controller.invalidate()); + local_acquisition.reset(); revoke_sv_phase_session(notified_session); continue; } @@ -1154,6 +1269,7 @@ void sv_ptp_phase_observer_task(void* argument) { !same_sv_ptp_clock_lease(armed_lease, lease) || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { static_cast(controller.invalidate()); + local_acquisition.reset(); revoke_sv_phase_session(notified_session); continue; } @@ -1163,6 +1279,7 @@ void sv_ptp_phase_observer_task(void* argument) { // the GPTimer mutex. Source identity and fault/session generations // must match the exact phase observation before any AUTO promotion. bool source_local_qualified = false; + std::uint32_t mac_pairs = 0U; #if AR_SV_TX_EGRESS_SUPPORTED if (lease.role == PtpAuthorityRole::source && decision.accepted && decision.epoch_second_continuous) { @@ -1180,6 +1297,7 @@ void sv_ptp_phase_observer_task(void* argument) { worst_case_phase_us <= UINT32_MAX ? static_cast(worst_case_phase_us) : UINT32_MAX); + mac_pairs = g_local_sync_evidence.measured_pairs(); portEXIT_CRITICAL(&g_tx_egress_mux); } #endif @@ -1196,51 +1314,86 @@ void sv_ptp_phase_observer_task(void* argument) { g_monitored_sample_clock != nullptr && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == notified_session; - if (session_matches && - g_sample_clock_context.epoch_generation.load( + const bool injecting = live_tx_running(); + const bool preflight_active = g_preflight_phase_clock_active.load( + std::memory_order_acquire); + if (g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && - decision.accepted && live_tx_running() && - same_sv_ptp_clock_lease( - armed_lease, g_sample_clock_context.armed_ptp_clock) && - same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()) && - no_new_publisher_fault) { - // A single near-boundary observation is only a baseline. Do - // not arm ISR steering until adjacent smpCnt=0 deadlines map - // to adjacent *integer PTP seconds* in the same generation. + SvAutoPreflightPolicy::can_steer( + session_matches, decision.accepted, + same_sv_ptp_clock_lease( + armed_lease, g_sample_clock_context.armed_ptp_clock) && + same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()), + no_new_publisher_fault, injecting, preflight_active)) { + // The PRE-START clock must truly steer while SV TX is OFF. + // The old live_tx_running() gate invalidated its servo on every + // 1Hz observation; capture verified 120s of smpSynch=0. g_sample_clock_context.phase_authority_valid.store( decision.epoch_second_continuous, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( decision.epoch_second_continuous ? decision.slew_ticks_next_epoch : 0, std::memory_order_release); - smp_synch_set_source_local_qualified( - source_local_qualified && decision.epoch_second_continuous); + if (injecting) { + // A warm GPTimer is NEVER canonical MAC TX evidence. + // Require the actual descriptor pairs and three clean live + // epochs before advertising LOCAL REF to an IED. + const bool stable_local = local_acquisition.observe( + lease.role == PtpAuthorityRole::source && + SvAutoPreflightPolicy::can_advertise_local( + injecting, source_local_qualified) && + decision.epoch_second_continuous, + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation); + smp_synch_set_source_local_qualified(stable_local); + } else { + local_acquisition.reset(); + smp_synch_set_source_local_qualified(false); + } committed = true; } else if (session_matches) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + local_acquisition.reset(); smp_synch_set_source_local_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { static_cast(controller.invalidate()); + local_acquisition.reset(); continue; } ++accepted_epochs; - if ((accepted_epochs % 60U) == 0U) { + const bool auto_local = smp_synch_source_local_qualified(); + // Emit bounded ACQUISITION diagnostics at 2s intervals initially, + // then every 5s until qualified; retain sparse 60s tracking telemetry. + // Only this 1Hz control-plane task logs, never the 4kHz SV ISR/TX path. + const bool early_observation = accepted_epochs <= 10U && + (accepted_epochs % 2U) == 0U; + const bool still_acquiring = !auto_local && + (accepted_epochs % 5U) == 0U; + const bool preflight_telemetry = !live_tx_running() && + (accepted_epochs % 2U) == 0U; + if (lease.role == PtpAuthorityRole::source && + (early_observation || still_acquiring || + preflight_telemetry || auto_local != previous_auto_local || + (accepted_epochs % 60U) == 0U)) { ESP_LOGI(kTag, - "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " - "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u AUTOlocal=%u", + "SV/PTP AUTO phase=%ldus uncertainty=%luus " + "slew=%ldus/s tracking=%u MACpairs=%lu clean=%lu/%lu epoch=%llu LOCAL=%u", static_cast(decision.phase_error_us), static_cast(decision.observation_uncertainty_us), static_cast(decision.slew_ticks_next_epoch), decision.software_tracking ? 1U : 0U, + static_cast(mac_pairs), + static_cast(local_acquisition.clean_epochs()), + static_cast(SvLocalRefAcquisition::kRequiredCleanEpochs), static_cast(decision.sv_epoch_ptp_second), - decision.epoch_second_continuous ? 1U : 0U, - smp_synch_source_local_qualified() ? 1U : 0U); + auto_local ? 1U : 0U); } + previous_auto_local = auto_local; } } #endif diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index c0104615f..b04fa0fa8 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -270,6 +270,39 @@ class SvPtpPublisherContinuityGate final { std::uint32_t source_generation_{}; }; +// Pure preflight reuse policy. This does NOT grant smpSynch or allow any +// Ethernet SV transmission. Production uses the same checks before reusing +// an idle SOURCE/AUTO GPTimer when the operator explicitly presses START. +struct SvAutoPreflightPolicy final { + [[nodiscard]] static constexpr bool can_prepare( + const bool auto_mode, const bool usable_source, + const bool profile_valid, const std::uint32_t rate, + const std::uint32_t sample_counter_modulus) noexcept { + return auto_mode && usable_source && profile_valid && rate > 0U && + rate == sample_counter_modulus; + } + [[nodiscard]] static constexpr bool can_steer( + const bool session_valid, const bool phase_accepted, + const bool lease_valid, const bool faults_unchanged, + const bool injecting, const bool preflight) noexcept { + return session_valid && phase_accepted && lease_valid && + faults_unchanged && (injecting || preflight); + } + + [[nodiscard]] static constexpr bool can_advertise_local( + const bool injecting, const bool descriptor_qualified) noexcept { + return injecting && descriptor_qualified; + } + + [[nodiscard]] static constexpr bool can_reuse( + const bool preflight_running, const bool phase_enabled, + const bool source_lease_unchanged, const bool rate_unchanged, + const bool auto_mode) noexcept { + return preflight_running && phase_enabled && + source_lease_unchanged && rate_unchanged && auto_mode; + } +}; + // ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the // existing rational scheduler by at most ONE 1-MHz timer tick. The complete // correction is distributed across one explicitly armed second (4000/4800 diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index 56a908fd1..b231cd6d2 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -7,6 +7,12 @@ namespace ar::iec61850::time_sync { +// One actual canonical descriptor timestamp every two smpCnt=0 epochs. +// Baseline at ~2s, two coherent pairs at ~4s and ~6s permit AUTO to +// qualify within the 10s operator target IF independent software phase, +// PTP source and publisher continuity have also qualified. Never force 1. +inline constexpr std::uint32_t kSvTxEvidenceEpochStride = 2U; + // Sparse, read-only MAC descriptor TX evidence. No result authorizes // smpSynch=1/2, grandmaster quality or IED synchronization. struct SvTxEgressObservation final { @@ -54,13 +60,13 @@ struct SvTxEgressSummary final { std::int32_t last_phase_ns{}; }; -// Explicit stride corresponds to every 16th sample-counter-zero epoch in -// ESP32-P4 M2C. Readings are NOT inferred from packet rate or PCAP timestamps. +// Evidence cadence follows the shared firmware stride. Readings are NOT +// inferred from packet rate or PCAP timestamps. // No heap, locks, I/O or clocks; suitable for strict portable unit testing. class SvTxEgressEvidence final { public: explicit constexpr SvTxEgressEvidence( - const std::uint32_t expected_epoch_stride = 16U) noexcept + const std::uint32_t expected_epoch_stride = kSvTxEvidenceEpochStride) noexcept : expected_stride_(expected_epoch_stride) {} constexpr void reset() noexcept { @@ -195,8 +201,8 @@ class SvLocalSyncEvidenceGate final { static constexpr std::int64_t kMinCanonicalTxOffsetNs = -4000LL; static constexpr std::int64_t kMaxCanonicalTxOffsetNs = 250000LL; static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; - static constexpr std::uint32_t kEvidenceStride = 16U; - static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U; + static constexpr std::uint32_t kEvidenceStride = kSvTxEvidenceEpochStride; + static constexpr std::uint32_t kMaxEvidenceAgeEpochs = kEvidenceStride + 1U; static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; constexpr void reset() noexcept { @@ -211,7 +217,7 @@ class SvLocalSyncEvidenceGate final { // The phase here is the packet's TX offset from the nearest whole PTP // second, i.e. a bound on *publication latency*, NOT sampling phase. // A separate GPTimer/EMAC observer qualifies the sampling instant. - // The lower-level tracker verifies consecutive 16-second PTP epochs + // The lower-level tracker verifies consecutive evidenced PTP epochs // and the same source/session. constexpr void observe(const SvTxEgressObservation& o, const SvTxEvidenceResult result, @@ -239,6 +245,13 @@ class SvLocalSyncEvidenceGate final { if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_; } + // Diagnostic read under the same ownership/critical section as observe(). + // This is a count of consecutive measured, coherent descriptor pairs, + // not a hardware-phase certificate or proof of IED subscription. + [[nodiscard]] constexpr std::uint32_t measured_pairs() const noexcept { + return matched_pairs_; + } + [[nodiscard]] constexpr bool qualified( const std::uint32_t session, const std::uint32_t fault_generation, @@ -264,4 +277,39 @@ class SvLocalSyncEvidenceGate final { std::uint32_t last_epoch_{}; }; +// Fail-closed entry filter: requires consecutive, independently qualified +// real SV epochs before asserting LOCAL, but never delays loss of LOCAL. +class SvLocalRefAcquisition final { +public: + static constexpr std::uint32_t kRequiredCleanEpochs = 3U; + constexpr void reset() noexcept { + count_ = 0U; session_ = 0U; faults_ = 0U; + source_ = 0U; epoch_ = 0U; + } + [[nodiscard]] constexpr bool observe( + const bool qualified, const std::uint32_t session, + const std::uint32_t faults, const std::uint32_t source, + const std::uint32_t epoch) noexcept { + if (!qualified || session == 0U || source == 0U || epoch == 0U) { + reset(); + return false; + } + if (count_ != 0U && + (session_ != session || faults_ != faults || source_ != source || + epoch - epoch_ != 1U)) reset(); + session_ = session; faults_ = faults; source_ = source; epoch_ = epoch; + if (count_ < kRequiredCleanEpochs) ++count_; + return count_ == kRequiredCleanEpochs; + } + [[nodiscard]] constexpr std::uint32_t clean_epochs() const noexcept { + return count_; + } +private: + std::uint32_t count_{}; + std::uint32_t session_{}; + std::uint32_t faults_{}; + std::uint32_t source_{}; + std::uint32_t epoch_{}; +}; + } // namespace ar::iec61850::time_sync diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index 5b9becb30..211cc68ee 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -3,6 +3,8 @@ #include "ariec61850/sampled_values/live_signal_state.hpp" #include +#include +#include #include #include #include @@ -138,6 +140,55 @@ void fixed_point_engine_preserves_frequency_and_phase() { CHECK(phase_120 != phase_minus_120); } +// Independent waveform regression for the operator's non-balanced 4I+4V +// table. The firmware MUST NOT reset current phases to the voltage defaults. +// A full 80-sample/50Hz fundamental projection checks each channel's RMS +// and signed phase in the actual fixed-point generator, not a GUI mock. +void custom_four_current_four_voltage_table_reaches_sine_samples() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 50000U; + const std::array rms_counts{ + 1000, 800, 1200, 0, 5774, 5774, 5774, 0}; + const std::array phases_mdeg{ + -30000, -145000, 87000, 0, 0, -120000, 120000, 0}; + for (std::size_t ch = 0; ch < state.channels.size(); ++ch) { + state.channels[ch].rms_counts = rms_counts[ch]; + state.channels[ch].phase_millidegrees = phases_mdeg[ch]; + state.channels[ch].enabled = true; + } + + constexpr double pi = 3.14159265358979323846; + constexpr int points = 80; + std::array sin_projection{}; + std::array cos_projection{}; + for (int n = 0; n < points; ++n) { + const auto values = engine.next(state, 4000U); + const double angle = (2.0 * pi * n) / points; + for (std::size_t ch = 0; ch < values.size(); ++ch) { + sin_projection[ch] += values[ch] * std::sin(angle); + cos_projection[ch] += values[ch] * std::cos(angle); + } + } + for (std::size_t ch = 0; ch < rms_counts.size(); ++ch) { + const double estimated_rms = std::hypot(sin_projection[ch], cos_projection[ch]) + * std::sqrt(2.0) / points; + if (rms_counts[ch] == 0) { + CHECK(estimated_rms < 1.0); + continue; + } + const double estimated_phase_deg = std::atan2( + cos_projection[ch], sin_projection[ch]) * 180.0 / pi; + const double phase_error_deg = std::remainder( + estimated_phase_deg - phases_mdeg[ch] / 1000.0, 360.0); + CHECK(std::abs(phase_error_deg) < 1.0); + CHECK(std::abs(estimated_rms - rms_counts[ch]) < + std::max(4.0, rms_counts[ch] * 0.012)); + } +} + void independent_channels_change_without_stream_restart() { using namespace ar::iec61850::sampled_values; @@ -174,6 +225,7 @@ int main() { dc_engine_outputs_constant_engineering_values(); ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart(); + custom_four_current_four_voltage_table_reaches_sine_samples(); std::cout << "[PASS] live Sampled Values signal state\n"; return 0; } catch (const std::exception& error) { diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 99805e75d..db8b7e158 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -34,6 +34,48 @@ SvPtpPhaseObservation snapshot( epoch, kEpochZero + epoch, kPtpfractionNs}; } +void check_auto_preflight_does_not_invent_sync_or_start() { + static_assert(SvAutoPreflightPolicy::can_prepare( + true, true, true, 4000U, 4000U)); + CHECK(!SvAutoPreflightPolicy::can_prepare( + false, true, true, 4000U, 4000U)); // operator FORCE_0 + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, false, true, 4000U, 4000U)); // PTP source OFF + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, false, 4000U, 4000U)); // unverified profile + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, true, 4000U, 4800U)); // no 1-second wrap + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, true, 0U, 0U)); + // Field capture: 120 seconds of AUTO=0 after a preflight phase + // observer was improperly gated on actual SV injection. + CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, false, true)); + CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, true, false)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, true, false, false)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, false, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, false, false, true)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, false, true, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(true, false)); + CHECK(SvAutoPreflightPolicy::can_advertise_local(true, true)); + CHECK(SvAutoPreflightPolicy::can_reuse(true, true, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(false, true, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, false, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, false, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, true, false)); + + // A warm phase servo can track but must never be interpreted as an + // independently measured canonical SV publication. The packet evidence + // gate is reset at START, so even prequalified phase is NOT smpSynch=1. + SvPtpPhaseAuthority phase; + for (std::uint32_t epoch = 1U; epoch <= 5U; ++epoch) { + const auto d = phase.observe(snapshot(epoch, 0U, 0U)); + CHECK(d.accepted); + CHECK(d.software_tracking == (epoch >= 4U)); + } +} + void check_phase_sign_and_bounded_corrections() { SvPtpPhaseAuthority controller; const auto warm = controller.observe(snapshot(1U, 220)); @@ -354,6 +396,7 @@ void check_continuous_ptp_second_binding() { int main() { try { + check_auto_preflight_does_not_invent_sync_or_start(); check_phase_sign_and_bounded_corrections(); check_uncertain_brackets_never_claim_software_tracking(); check_per_second_slew_distribution(); diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 5fc4234ab..a2a6a564a 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -2,6 +2,7 @@ #include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" #include +#include #include #include #include @@ -33,49 +34,119 @@ SvTxEgressObservation make_good( } void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { SvLocalSyncEvidenceGate gate; - auto o = make_good(32U, 1700000016U, 2000U); - REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + auto o = make_good(4U, 1700000016U, 2000U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); - REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); // Hardware packet TX normally follows the top-of-second sampling // instant by processing/queue delay. 110 us is valid publication delay, // and MUST NOT be mistaken for 110 us sampling phase error. gate.observe(o, SvTxEvidenceResult::measured, 110000); - REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); - o.sample_epoch_generation = 48U; + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); + o.sample_epoch_generation = 6U; gate.observe(o, SvTxEvidenceResult::measured, 175000); - REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U)); - REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U)); - REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U)); - REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, true, 5U)); - REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, false, 0U)); - REQUIRE(!gate.qualified(7U, 1U, 3U, 48U, true, 0U)); - REQUIRE(!gate.qualified(7U, 0U, 4U, 48U, true, 0U)); - REQUIRE(!gate.qualified(8U, 0U, 3U, 48U, true, 0U)); - REQUIRE(!gate.qualified(7U, 0U, 3U, 47U, true, 0U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 6U, true, 4U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 9U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, false, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 6U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 6U, true, 0U)); + REQUIRE(!gate.qualified(8U, 0U, 3U, 6U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 5U, true, 0U)); // A new source, bad SV timestamp, skipped frame or TX failure // immediately revokes the old promotion and requires fresh pairs. - o.sample_epoch_generation = 64U; + o.sample_epoch_generation = 8U; gate.observe(o, SvTxEvidenceResult::measured, 300000); - REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 0U)); // A TX timestamp unexpectedly before the sample epoch is invalid, // even if a symmetric +/− phase comparison would have accepted it. gate.observe(o, SvTxEvidenceResult::measured, -9000); - REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 0U)); gate.observe(o, SvTxEvidenceResult::measured, 110000); - o.sample_epoch_generation = 80U; + o.sample_epoch_generation = 10U; gate.observe(o, SvTxEvidenceResult::measured, 180000); - REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 10U, true, 0U)); gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); - REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U)); - o.sample_epoch_generation = 96U; + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 0U)); + o.sample_epoch_generation = 12U; gate.observe(o, SvTxEvidenceResult::measured, 1000); - o.sample_epoch_generation = 112U; + o.sample_epoch_generation = 14U; o.publisher_fault_generation = 1U; gate.observe(o, SvTxEvidenceResult::measured, 1000); - REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 14U, true, 0U)); gate.reset(); - REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 14U, true, 0U)); +} + +// Full target-cadence path: a real descriptor timestamp is sampled only at +// the 2s, 4s and 6s canonical sample epochs; software/PTP phase remains +// independent, so MAC presence alone can never authorize AUTO local. +void check_fast_source_auto_qualification_with_fresh_evidence() { + static_assert(kSvTxEvidenceEpochStride == 2U); + static_assert(SvLocalSyncEvidenceGate::kEvidenceStride == + kSvTxEvidenceEpochStride); + static_assert(SvLocalSyncEvidenceGate::kMaxEvidenceAgeEpochs == 3U); + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate gate; + constexpr std::uint32_t base_seconds = 1700000000U; + for (const std::uint32_t elapsed : {2U, 4U, 6U}) { + const auto o = make_good(elapsed, base_seconds + elapsed, 90000U); + const auto result = tx.record(o); + REQUIRE(result == (elapsed == 2U + ? SvTxEvidenceResult::baseline_established + : SvTxEvidenceResult::measured)); + gate.observe(o, result, tx.summary().last_phase_ns); + REQUIRE(gate.measured_pairs() == (elapsed / 2U) - 1U); + REQUIRE(gate.qualified(7U, 0U, 3U, elapsed, true, 4U) == + (elapsed == 6U)); + // No fake sync claim from the MAC descriptor alone. + REQUIRE(!gate.qualified(7U, 0U, 3U, elapsed, false, 0U)); + } + REQUIRE(tx.summary().measured == 2U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 4U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 6U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 6U, true, 4U)); + // A publisher fault immediately revokes qualification, regardless of + // good-looking timestamps and the original six-second acquisition. + auto fault = make_good(8U, base_seconds + 8U, 90000U); + fault.publisher_fault_generation = 1U; + const auto changed = tx.record(fault); + REQUIRE(changed == SvTxEvidenceResult::publisher_discontinuity); + gate.observe(fault, changed, tx.summary().last_phase_ns); + REQUIRE(gate.measured_pairs() == 0U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 4U)); +} + +void check_no_intermitttent_local_at_start() { + SvLocalRefAcquisition gate; + static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 3U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 5U)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 6U)); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 7U)); + REQUIRE(gate.clean_epochs() == 0U); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 8U)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 9U)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 10U)); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 11U)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 12U)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 14U)); // gap + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 15U)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 16U)); + REQUIRE(!gate.observe(true, 7U, 1U, 3U, 17U)); // TX fault + REQUIRE(!gate.observe(true, 7U, 1U, 4U, 18U)); // source restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 19U)); // publisher restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 20U)); + REQUIRE(gate.observe(true, 8U, 1U, 4U, 21U)); + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 21U)); // duplicate + REQUIRE(!gate.observe(true, 0U, 1U, 4U, 22U)); + REQUIRE(!gate.observe(true, 8U, 1U, 0U, 23U)); +} +void check_clean_local_by_eight_seconds() { + SvLocalRefAcquisition gate; + for (std::uint32_t t = 1U; t <= 8U; ++t) + REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 8U)); } void check_baseline_and_contiguous_ptp() { @@ -100,7 +171,7 @@ void check_baseline_and_contiguous_ptp() { REQUIRE(e.summary().measured == 3U); } void check_clock_steps_recovery_and_session() { - SvTxEgressEvidence e; + SvTxEgressEvidence e{16U}; REQUIRE(e.record(make_good(16U, 1700000000U)) == SvTxEvidenceResult::baseline_established); // +1 sec PTP clock step despite perfect-looking within-second phase. @@ -140,7 +211,7 @@ void check_clock_steps_recovery_and_session() { REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); } void check_invalid_evidence_requires_rebaseline() { - SvTxEgressEvidence e; + SvTxEgressEvidence e{16U}; auto o = make_good(16U, 1700000000U); REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); o = make_good(32U, 1700000016U); @@ -176,7 +247,7 @@ void check_invalid_evidence_requires_rebaseline() { REQUIRE(e.summary().measured == 0U); } void check_counter_wrap_and_signed_negative_phase() { - SvTxEgressEvidence e; + SvTxEgressEvidence e{16U}; auto o = make_good(0xfffffff8U, 1700000099U, 999999900U); REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); o = make_good(8U, 1700000115U, 999999900U); @@ -194,6 +265,9 @@ void check_counter_wrap_and_signed_negative_phase() { int main() { try { check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); + check_fast_source_auto_qualification_with_fresh_evidence(); + check_no_intermitttent_local_at_start(); + check_clean_local_by_eight_seconds(); check_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline();