From 150b2ecb36bf91b84dc034ad3f94512d0ccbcdb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 15:28:06 +0700 Subject: [PATCH 01/21] fix(smv): qualify PTP SOURCE AUTO within ~6s using bounded 2s MAC evidence --- .../tests/check_operator_workspace.py | 7 ++ .../esp32p4_smv_injector/main/app_main.cpp | 11 ++- .../time_sync/sv_tx_egress_evidence.hpp | 18 ++-- tests/test_sv_tx_egress_evidence.cpp | 94 +++++++++++++------ 4 files changed, 93 insertions(+), 37 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 68087b5b6..01d29ea2b 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -41,6 +41,13 @@ 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") 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..a5eb8791f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -139,7 +139,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 @@ -907,9 +908,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{}; diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index 56a908fd1..2f878f4e1 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, diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 5fc4234ab..f24da6b2d 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,87 @@ 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.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.qualified(7U, 0U, 3U, 8U, true, 4U)); } void check_baseline_and_contiguous_ptp() { @@ -100,7 +139,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 +179,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 +215,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 +233,7 @@ 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_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline(); From fba4d7c55170d132e808cfeb723176a28e3bd731 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 15:28:43 +0700 Subject: [PATCH 02/21] test(studio): match actual newline in evidence cadence source guard --- apps/arstack_studio/tests/check_operator_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 01d29ea2b..77bc784a4 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -45,7 +45,7 @@ def main() -> None: 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 "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") main_qml = source("Main.qml") From 4a795b1b5e7e4d560db342b7b907efac30fc55c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 15:32:33 +0700 Subject: [PATCH 03/21] test(smv): verify unbalanced 4I+4V table RMS and signed angles in real sample engine --- tests/test_live_signal.cpp | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) 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) { From 6a6f748d80a132b3c67f0f171ae07762c8133ff4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:16:36 +0700 Subject: [PATCH 04/21] fix(smv): enable GPTimer before PTP epoch sample to avoid slow initial slew --- .../esp32p4_smv_injector/main/app_main.cpp | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index a5eb8791f..187e766f2 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -615,8 +615,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. @@ -643,7 +647,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) { @@ -1080,6 +1083,7 @@ void sv_ptp_phase_observer_task(void* argument) { SvPtpPublisherContinuityGate continuity; 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 = @@ -1088,6 +1092,8 @@ void sv_ptp_phase_observer_task(void* argument) { if (controller_session != notified_session) { controller.reset(); continuity.reset(); + accepted_epochs = 0U; + previous_auto_local = false; controller_session = notified_session; } vTaskDelay(pdMS_TO_TICKS(250)); @@ -1166,6 +1172,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) { @@ -1183,6 +1190,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 @@ -1232,18 +1240,30 @@ void sv_ptp_phase_observer_task(void* argument) { 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; + if (lease.role == PtpAuthorityRole::source && + (early_observation || still_acquiring || + 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 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(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 From 9c1e00b0c9ee23258f0187ca61b2d141b9a251d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:16:39 +0700 Subject: [PATCH 05/21] feat(ptp): expose coherent MAC pair count for acquisition diagnostics --- include/ariec61850/time_sync/sv_tx_egress_evidence.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index 2f878f4e1..e6f767e80 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -245,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, From 4ebad5b5bbe1b871506ef3b98bd515aaa2b1d2d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:16:41 +0700 Subject: [PATCH 06/21] test(smv): assert coherent pair progression and fault revocation --- tests/test_sv_tx_egress_evidence.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index f24da6b2d..ff4b13552 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -97,6 +97,7 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { ? 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. @@ -114,6 +115,7 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { 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)); } From 665f8d801435f7ba4d9afee95c4ba20cc39a5a61 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:16:44 +0700 Subject: [PATCH 07/21] test(studio): guard close-to-start PTP arm timing and early diagnostics --- apps/arstack_studio/tests/check_operator_workspace.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 77bc784a4..29daf031b 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -48,6 +48,16 @@ def main() -> None: 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") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") From ed92934ccdb481f8748089882f438f5d60e3fb3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:54:20 +0700 Subject: [PATCH 08/21] fix(smv): require stable 3-epoch PTP/MAC qualification before LOCAL promotion --- .../time_sync/sv_tx_egress_evidence.hpp | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index e6f767e80..b231cd6d2 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -277,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 From ed8213db9e0ecf13cc855baeb99130a1bcf11b22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:54:23 +0700 Subject: [PATCH 09/21] fix(smv): prevent transient SOURCE AUTO local chatter at startup --- .../esp32p4_smv_injector/main/app_main.cpp | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 187e766f2..33e678482 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1081,6 +1081,7 @@ 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; @@ -1092,6 +1093,7 @@ 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; @@ -1106,6 +1108,7 @@ 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; } @@ -1114,6 +1117,7 @@ void sv_ptp_phase_observer_task(void* argument) { (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; } @@ -1163,6 +1167,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; } @@ -1224,19 +1229,27 @@ void sv_ptp_phase_observer_task(void* argument) { 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); + // Require three full 1Hz software/PTP/MAC-qualified epochs; + // invalid or stale evidence still revokes LOCAL immediately. + const bool stable_local = local_acquisition.observe( + lease.role == PtpAuthorityRole::source && + 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); 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; @@ -1254,12 +1267,14 @@ void sv_ptp_phase_observer_task(void* argument) { (accepted_epochs % 60U) == 0U)) { ESP_LOGI(kTag, "SV/PTP AUTO phase=%ldus uncertainty=%luus " - "slew=%ldus/s tracking=%u MACpairs=%lu epoch=%llu LOCAL=%u", + "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), auto_local ? 1U : 0U); } From aee6623e0f7b67d6fa0fc60e053efc0842b75c4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:54:26 +0700 Subject: [PATCH 10/21] test(smv): verify intermittent phase never prematurely claims LOCAL --- tests/test_sv_tx_egress_evidence.cpp | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index ff4b13552..a2a6a564a 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -119,6 +119,36 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { 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() { SvTxEgressEvidence e{16U}; auto o = make_good(16U, 1700000000U, 125000U); @@ -236,6 +266,8 @@ 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(); From 5549e1a52c028a5c60198d14098c26d5c1535400 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:54:28 +0700 Subject: [PATCH 11/21] test(studio): guard initial LOCAL qualification stability --- apps/arstack_studio/tests/check_operator_workspace.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 29daf031b..03837e87a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -58,6 +58,13 @@ def main() -> None: 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") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") From d85c96ce3fc77e09a6faa3e3bc9a759be8e892a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 17:55:04 +0700 Subject: [PATCH 12/21] fix(embedded): expose portable LOCAL entry filter under every phase-enabled firmware configuration --- embedded/esp32p4_smv_injector/main/app_main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 33e678482..bd3b30c6e 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 @@ -94,6 +97,7 @@ 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::SvLocalRefAcquisition; using ar::iec61850::time_sync::SvPtpPhaseObservation; using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif From ba66a79c8e81625d4dee1c991dbfb7fd2e6ea375 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 18:26:06 +0700 Subject: [PATCH 13/21] feat(smv): prediscipline PTP AUTO sample clock before START without sending SV --- .../esp32p4_smv_injector/main/app_main.cpp | 129 +++++++++++++++--- 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index bd3b30c6e..65a41e978 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -74,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; @@ -154,6 +156,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 { @@ -209,6 +214,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 }; @@ -453,6 +462,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); @@ -509,7 +523,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); @@ -580,7 +601,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; @@ -644,6 +666,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 @@ -663,6 +689,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); @@ -740,12 +769,35 @@ 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 = preflight_clock && sample_clock != nullptr && + epoch_armed && g_sample_clock_context.phase_enabled && + g_sample_clock_context.publisher_notifications_enabled.load( + std::memory_order_acquire) == false && + preflight_rate_hz == requested_profile.publisher_rate_hz && + requested_profile.sample_counter_modulus == requested_profile.publisher_rate_hz && + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto && + same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, now_lease); + 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"); @@ -797,22 +849,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 @@ -822,7 +881,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 = + 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; } @@ -1117,7 +1210,9 @@ void sv_ptp_phase_observer_task(void* argument) { 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()); From 35768525483a03521535959e4903c360d4b42248 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 18:27:54 +0700 Subject: [PATCH 14/21] test(ptp): introduce explicit fail-closed warm clock reuse policy --- .../time_sync/sv_ptp_phase_authority.hpp | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index c0104615f..4d20b2ecd 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -270,6 +270,26 @@ 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_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 From 3d7385ba8c0f03ecbb2e185e357703205eb231fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 18:27:57 +0700 Subject: [PATCH 15/21] test(ptp): protect SOURCE AUTO dry preflight and guarded START transfer --- tests/test_sv_ptp_phase_authority.cpp | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 99805e75d..f71ed6889 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -34,6 +34,37 @@ 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)); + 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 +385,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(); From fd28fc4e1776fa819ead6ed0f4e8db2ced3b04c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 18:27:59 +0700 Subject: [PATCH 16/21] test(studio): require genuine pre-start AUTO clock acquisition with zero SV TX --- apps/arstack_studio/tests/check_operator_workspace.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 03837e87a..910e0368f 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -65,6 +65,16 @@ def main() -> None: 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" 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") From dbaeebe7c8fab0dbe95410c7b71730aa864af4a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 18:28:23 +0700 Subject: [PATCH 17/21] refactor(smv): enforce shared SOURCE AUTO preflight and reuse policy at runtime --- .../esp32p4_smv_injector/main/app_main.cpp | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 65a41e978..6e7e8df52 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -99,6 +99,7 @@ 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; @@ -783,15 +784,17 @@ void publisher_task(void* argument) { #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 = preflight_clock && sample_clock != nullptr && - epoch_armed && g_sample_clock_context.phase_enabled && - g_sample_clock_context.publisher_notifications_enabled.load( - std::memory_order_acquire) == false && - preflight_rate_hz == requested_profile.publisher_rate_hz && - requested_profile.sample_counter_modulus == requested_profile.publisher_rate_hz && - smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto && - same_sv_ptp_clock_lease( - g_sample_clock_context.armed_ptp_clock, now_lease); + 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; @@ -884,13 +887,13 @@ void publisher_task(void* argument) { #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 = - smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto && + 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; + 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( From 26579601ba87f2e87722047f665e31c08c555771 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 21:19:35 +0700 Subject: [PATCH 18/21] fix(smv): actually steer SOURCE PTP phase in silent AUTO preflight --- .../esp32p4_smv_injector/main/app_main.cpp | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 6e7e8df52..74498f7c7 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1314,31 +1314,42 @@ 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); - // Require three full 1Hz software/PTP/MAC-qualified epochs; - // invalid or stale evidence still revokes LOCAL immediately. - const bool stable_local = local_acquisition.observe( - lease.role == PtpAuthorityRole::source && - 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); + 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( @@ -1363,9 +1374,11 @@ void sv_ptp_phase_observer_task(void* argument) { (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 || - auto_local != previous_auto_local || + preflight_telemetry || auto_local != previous_auto_local || (accepted_epochs % 60U) == 0U)) { ESP_LOGI(kTag, "SV/PTP AUTO phase=%ldus uncertainty=%luus " From f74802df23657091fd5eed25333434b5915d17c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 21:20:07 +0700 Subject: [PATCH 19/21] fix(ptp): portable fail-closed preflight steering policy --- .../ariec61850/time_sync/sv_ptp_phase_authority.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 4d20b2ecd..b04fa0fa8 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -281,6 +281,19 @@ struct SvAutoPreflightPolicy final { 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, From 0ba02299edd8f5564d2d5aaf3279062c562b7aa7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 21:20:29 +0700 Subject: [PATCH 20/21] test(ptp): enforce actual dry-run steering and no idle LOCAL claims --- tests/test_sv_ptp_phase_authority.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index f71ed6889..db8b7e158 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -47,6 +47,17 @@ void check_auto_preflight_does_not_invent_sync_or_start() { 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)); From 7dc488c2c49bde0348dcdd649de654b2d6ba9017 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 21:20:42 +0700 Subject: [PATCH 21/21] test(studio): guard active silent preflight phase steering and no premature LOCAL --- apps/arstack_studio/tests/check_operator_workspace.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 910e0368f..39fd0d4ba 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -73,6 +73,10 @@ def main() -> None: 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")