diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 61b817436..1f04ab1db 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -182,7 +182,9 @@ jobs: 'g_announce_sent.load', 'g_sync_sent.load', 'g_follow_up_sent.load', - 'return g_ptp_ready.load', + '[[nodiscard]] bool source_has_fresh_evidence() noexcept', + 'PtpSourceEvidenceFreshness::fresh(', + 'return source_has_fresh_evidence();', ] combined = eth + '\n' + ptp missing = [token for token in required if token not in combined] diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b8a5e3744..c1ccd29db 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -101,9 +101,10 @@ jobs: start_begin = source.index('if (take_start_request())') start_end = source.index('if (!live_tx_running())', start_begin) start_block = source[start_begin:start_end] - clock_start = start_block.rindex( - 'sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz);' - ) + # Match the call site, not an obsolete fixed argument list. The + # SOURCE/RECEIVER experimental arm passes ETH/PTP epoch metadata. + # Never relax the no-console-after-arming invariant. + clock_start = start_block.rindex('sample_clock = start_sample_clock(') assert start_block.rindex('ESP_LOGI') < clock_start assert 'ESP_LOG' not in start_block[clock_start:] assert 'static_assert(std::atomic::is_always_lock_free);' in source diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 3820d14e6..b9e513c21 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -128,6 +128,13 @@ jobs: Build-Target 'smp_synch_policy_tests' Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' + # Keep explicit MSVC targets in sync with the canonical CTest + # registrations; CTest otherwise fails with missing M3 binaries. + Build-Target 'ptp_control_cancellation_tests' + Build-Target 'ptp_emac_role_ownership_tests' + Build-Target 'ptp_canonical_authority_tests' + Build-Target 'ptp_receiver_clock_authority_tests' + Build-Target 'sv_ptp_clock_lease_tests' Build-Target 'sv_ptp_epoch_tests' Build-Target 'sv_ptp_phase_authority_tests' Build-Target 'sv_tx_egress_evidence_tests' diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index 2be984391..e3ac4d8f8 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -15,3 +15,23 @@ The existing ESP32-P4 SOURCE control-plane observer now publishes ISR phase-stee ## Scope and next gating This is M3C **foundation**, NOT completion of the M3A canonical dual-mode runtime authority. It currently guards the opt-in SOURCE servo; RECEIVER phase correlation, clock-authority selection/switching, absolute PPS/smpCnt physical accuracy, MAC egress, non-default SV profile epoch semantics, full M3 recovery and legitimate AUTO smpSynch remain separate engineering gates. The firmware experiment stays default OFF and native ARStack Studio is unchanged. A successful build/test establishes software verification only; hardware/IED acceptance remains unverified. + +## M3C.3 — Bounded confidence on software phase tracking (2026-10-17) + +The previous software-tracking decision checked only the nominal GPTimer/PTP +phase residual against the configured threshold. It could report +`software_tracking=true` after consecutive 400-µs clock-read brackets even +though the corresponding measurement uncertainty was at least 201 µs, +larger than the default 60-µs tracking threshold. + +The qualification counter now advances only when +`abs(measured_phase_error_us) + observation_uncertainty_us` is within the +configured software-tracking threshold. One low-confidence observation revokes +tracking. Integer-second continuity, the separate phase-observation acceptance +limit, bounded PI steering, ISR work and firmware feature gates are unchanged. +This is a software confidence bound, not a hardware egress measurement or +authorization to assert `smpSynch=1/2`. + +Portable regression includes six accurate-phase/uncertain-clock observations, +four fresh narrowly bracketed reacquisition observations, and confidence +demotion after one degraded bracket. Hardware bench acceptance is still absent. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 947089bad..1ec72b4fa 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -785,9 +785,11 @@ void publisher_task(void* argument) { // 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()); - } else { - ESP_LOGI(kTag, "PTP epoch START armed; SV sync remains unqualified (smpSynch AUTO=0)"); } + // Do not log after the timer is enabled: even a single slow + // console write can accumulate ISR notifications before the + // publisher consumes its first slot (false START misses). + // This does not change fail-closed smpSynch AUTO=0. continue; } diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index b67e46841..c0104615f 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -182,7 +182,14 @@ class SvPtpPhaseAuthority final { const bool first_observation = !have_previous_; have_previous_ = true; - if (sv_phase_detail::magnitude(phase) <= + // A small nominal phase residual is NOT sufficiently qualified + // when the GPTimer/PTP read bracket is wider than the tracking + // tolerance. Include the full bracket uncertainty before advancing + // consecutive software-tracking samples (never wire smpSynch). + const std::int64_t worst_case_phase_us = + sv_phase_detail::magnitude(phase) + + static_cast(uncertainty); + if (worst_case_phase_us <= policy_.software_tracking_threshold_us) { if (tracking_samples_ < policy_.consecutive_tracking_samples) { ++tracking_samples_; diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index 8c3cc825f..a951251f0 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -281,8 +281,8 @@ void check_tai_seed_and_truthful_time_properties() { void check_vlan_encoding_remains_configurable() { auto options = make_source(); - options.vlan_id = 100U; - options.vlan_priority = 4U; + options.vlan_id = std::uint16_t{100U}; + options.vlan_priority = std::uint8_t{4U}; PtpPublisherRuntime source{options}; REQUIRE(source.start()); const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 2c77a912a..99805e75d 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -141,6 +141,42 @@ void check_software_servo_tracks_80ppm_drift_without_counter_reset() { } } + +void check_uncertain_brackets_never_claim_software_tracking() { + SvPtpPhaseAuthority tracker; + // Valid control-plane reads may be too uncertain to support a 60 us + // tracking claim. A 400 us PTP read bracket has >=201 us uncertainty; + // the nominal phase is deliberately zero to catch false positives. + for (std::uint32_t epoch = 1U; epoch <= 6U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 400U)); + CHECK(decision.accepted); + CHECK(decision.epoch_second_continuous == (epoch != 1U)); + CHECK(decision.phase_error_us == 0); + CHECK(decision.observation_uncertainty_us >= 201U); + CHECK(decision.state == SvPtpPhaseState::acquiring); + CHECK(!decision.software_tracking); + } + + // Qualification requires four fresh, narrow observations; old + // uncertain observations cannot contribute toward this threshold. + for (std::uint32_t epoch = 7U; epoch <= 9U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 8U)); + CHECK(decision.accepted); + CHECK(!decision.software_tracking); + } + const auto qualified = tracker.observe(snapshot(10U, 20, 8U)); + CHECK(qualified.accepted); + CHECK(qualified.software_tracking); + CHECK(qualified.state == SvPtpPhaseState::tracking); + + // One uncertain sample revokes tracking, even with perfect measured + // phase. The PI/slew remains bounded separately from qualification. + const auto degraded = tracker.observe(snapshot(11U, 20, 400U)); + CHECK(degraded.accepted); + CHECK(!degraded.software_tracking); + CHECK(degraded.state == SvPtpPhaseState::acquiring); +} + void check_publisher_continuity_gate_reacquisition() { SvPtpPublisherContinuityGate gate; SvPtpPhaseAuthority phase; @@ -319,6 +355,7 @@ void check_continuous_ptp_second_binding() { int main() { try { check_phase_sign_and_bounded_corrections(); + check_uncertain_brackets_never_claim_software_tracking(); check_per_second_slew_distribution(); check_software_servo_tracks_80ppm_drift_without_counter_reset(); check_clock_step_stale_brackets_and_timer_wrap();