diff --git a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md index a355968dd..8abc2d309 100644 --- a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md +++ b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md @@ -49,3 +49,26 @@ M1 improves interoperability defaults and synchronization safety; it does **not* - Uninitialized RTC: run as a plainly non-timescale-validated laboratory source; **do not** market as a fully conformant IEC/IEEE 61850-9-3 grandmaster. Still missing: independent reference proving actual UTC time and leap-second offset, continuous phase/frequency coupling between EMAC PTP and SV GPTimer, mandatory clock-state/BMCA behaviors, and physical accuracy/IED acceptance. + +## M3C.4 — First alarm latency hardening (17 October 2026) + +The experimental epoch-arm code originally computed time to the next EMAC +PTP second **before** configuring GPTimer, then armed the later timer using +the unchanged interval. This converts driver/interrupt setup time into a +systematic SV sample-zero offset. The M3C.4 planner now retains the monotonic +end of the PTP read, subtracts measured pre-arm setup age, and rechecks the +same role/source-scoped clock lease. If too close to the intended second, +the first alarm defers by one whole second. Stale or backwards observations +do not arm a PTP-aligned timer: the publisher falls back to unqualified free +running. ISR cadence, stream counter and AUTO smpSynch fail-closed policy +are unchanged. + +Regression covers a 200-us startup delay, near-epoch rollover, stale clock +evidence, reversed monotonic time and invalid alarm fields. The remaining +gap between this last pre-arm monotonic sample and actual GPTimer clock start +still needs measurement. Host Npcap receive timestamps do NOT establish MAC +egress timing; firmware and CI success do NOT authorize smpSynch=1/2 or a +public PTP-locked release. Test on the isolated ESP32-P4 bench with an +independent GM/PPS before claiming physical lock. + +Bench checkpoint: PR #229 guarded SOURCE/RECEIVER artifacts are experimental images only and require board P0 revision validation, independently measured SV TX phase, and a separate stable release review. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 1ec72b4fa..4cd4618dc 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -84,6 +84,7 @@ using ar::iec61850::sampled_values::SvLiveSignalState; using ar::iec61850::time_sync::SvSecondBoundaryArm; using ar::iec61850::time_sync::SvPtpClockObservation; using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +using ar::iec61850::time_sync::rebase_sv_ptp_second_arm; using ar::iec61850::time_sync::SvPtpClockLease; using ar::iec61850::time_sync::PtpAuthorityRole; using ar::iec61850::time_sync::same_sv_ptp_clock_lease; @@ -530,6 +531,7 @@ bool sample_alarm_callback( struct SvPtpEpochArm final { SvSecondBoundaryArm timer{}; SvPtpClockLease lease{}; + std::uint64_t clock_read_after_us{}; }; // Align the initial sample with EMAC PTP and retain EXACT authority identity. @@ -557,7 +559,7 @@ plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { ptp_time.nanoseconds, static_cast(before_us), static_cast(after_us)}); if (!arm.has_value()) return std::nullopt; - return SvPtpEpochArm{*arm, lease}; + return SvPtpEpochArm{*arm, lease, static_cast(after_us)}; #else (void)eth_handle; return std::nullopt; @@ -611,9 +613,19 @@ gptimer_handle_t start_sample_clock( // The timer is configured before reading EMAC PTP time, minimizing // cross-clock arm latency without touching the high-frequency callback. const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); - epoch_armed = epoch_arm.has_value(); + // Time spent configuring GPTimer after the EMAC PTP read must not + // shift the first smpCnt=0 deadline. Recheck the same lease before arm. + const std::int64_t pre_arm_us = esp_timer_get_time(); + const auto corrected_arm = epoch_arm.has_value() && pre_arm_us >= 0 + ? rebase_sv_ptp_second_arm( + epoch_arm->timer, epoch_arm->clock_read_after_us, + static_cast(pre_arm_us)) + : std::nullopt; + epoch_armed = corrected_arm.has_value() && + same_sv_ptp_clock_lease( + epoch_arm->lease, read_ptp_epoch_clock_lease()); #if AR_SV_PTP_PHASE_SUPPORTED - if (epoch_arm.has_value()) { + if (epoch_armed) { g_sample_clock_context.armed_ptp_clock = epoch_arm->lease; } g_sample_clock_context.phase_enabled = @@ -621,8 +633,8 @@ gptimer_handle_t start_sample_clock( g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; #endif g_sample_clock_context.next_alarm = {}; - g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() - ? epoch_arm->timer.first_alarm_us + g_sample_clock_context.next_alarm.alarm_count = epoch_armed + ? corrected_arm->first_alarm_us : 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)); diff --git a/include/ariec61850/time_sync/sv_ptp_epoch.hpp b/include/ariec61850/time_sync/sv_ptp_epoch.hpp index 1befea09d..98978d6bd 100644 --- a/include/ariec61850/time_sync/sv_ptp_epoch.hpp +++ b/include/ariec61850/time_sync/sv_ptp_epoch.hpp @@ -58,6 +58,37 @@ plan_sv_ptp_second_arm(const SvPtpClockObservation& o, static_cast(uncertainty_us)}; } +// Re-anchor first GPTimer alarm to the actual timer START window. +// The PTP IOCTL precedes timer setup: using its original delay unchanged +// introduces setup latency as a systematic phase offset. Reject stale reads. +[[nodiscard]] constexpr std::optional +rebase_sv_ptp_second_arm( + const SvSecondBoundaryArm& planned, + const std::uint64_t clock_read_after_us, + const std::uint64_t timer_start_preparation_us, + const std::uint32_t minimum_start_lead_us = 20000U, + const std::uint32_t maximum_rebase_age_us = 100000U) noexcept { + constexpr std::uint64_t epoch_us = 1000000ULL; + if (timer_start_preparation_us < clock_read_after_us || + minimum_start_lead_us == 0U || + minimum_start_lead_us > epoch_us || + planned.first_alarm_us == 0U || + planned.first_alarm_us > 2ULL * epoch_us) return std::nullopt; + const std::uint64_t age = timer_start_preparation_us - + clock_read_after_us; + if (age > maximum_rebase_age_us) return std::nullopt; + std::uint64_t until_epoch = planned.first_alarm_us; + // Skip to the next whole PTP second rather than start too late. + if (until_epoch <= age + minimum_start_lead_us) until_epoch += epoch_us; + if (until_epoch <= age) return std::nullopt; + const std::uint64_t remaining = until_epoch - age; + if (remaining < minimum_start_lead_us || + remaining > 2ULL * epoch_us) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(remaining), + planned.clock_read_uncertainty_us}; +} + // The clock receiver can prove PTP lock, but it cannot independently prove // the SV publisher's sample phase. AUTO must remain 0 until BOTH domains // have been qualified. Laboratory overrides remain deliberately separate. diff --git a/tests/test_sv_ptp_epoch.cpp b/tests/test_sv_ptp_epoch.cpp index b41246da6..57117e4ca 100644 --- a/tests/test_sv_ptp_epoch.cpp +++ b/tests/test_sv_ptp_epoch.cpp @@ -20,6 +20,23 @@ int main() { const auto near_second = plan_sv_ptp_second_arm({999995000U, 0U, 2U}); if (!require(near_second.has_value() && near_second->first_alarm_us == 1000004U, "near-boundary must roll forward safely")) return 1; + // Earlier PTP reads must not translate into late timer alarms. + const auto rebased = rebase_sv_ptp_second_arm(*at_quarter, 108U, 308U); + if (!require(rebased.has_value() && + rebased->first_alarm_us == 749796U && + rebased->clock_read_uncertainty_us == + at_quarter->clock_read_uncertainty_us, + "GPTimer START setup latency was not compensated")) return 1; + const auto late = rebase_sv_ptp_second_arm( + {200000U, 5U}, 1000U, 191000U, 20000U, 200000U); + if (!require(late.has_value() && late->first_alarm_us == 1010000U, + "late first alarm must defer one full PTP second")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 100109U), + "stale PTP clock read accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 107U), + "backwards monotonic arm time accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm({0U, 0U}, 108U, 110U), + "invalid zero alarm accepted")) return 1; const auto start = plan_sv_ptp_second_arm({0U, 100U, 100U}); if (!require(start.has_value() && start->first_alarm_us == 1000000U, "exact second must target next second")) return 1;