Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 17 additions & 5 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -557,7 +559,7 @@ plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept {
ptp_time.nanoseconds, static_cast<std::uint64_t>(before_us),
static_cast<std::uint64_t>(after_us)});
if (!arm.has_value()) return std::nullopt;
return SvPtpEpochArm{*arm, lease};
return SvPtpEpochArm{*arm, lease, static_cast<std::uint64_t>(after_us)};
#else
(void)eth_handle;
return std::nullopt;
Expand Down Expand Up @@ -611,18 +613,28 @@ 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<std::uint64_t>(pre_arm_us))
Comment on lines +618 to +622

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move the rebase timestamp past the final lease check

When the final lease snapshot takes time or this task is preempted, remaining is computed from pre_arm_us before read_ptp_epoch_clock_lease(), while the GPTimer countdown starts only after that read and the subsequent alarm/enable calls. That unaccounted interval becomes a persistent sample-zero phase offset, and a delay exceeding the 20 ms lead can place smpCnt=0 after the intended PTP boundary while epoch_armed remains true. Capture/rebase the monotonic timestamp after the lease check and as close as possible to gptimer_start().

AGENTS.md reference: AGENTS.md:L94-L94

Useful? React with 👍 / 👎.

: 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 =
epoch_armed && profile_has_second_aligned_counter &&
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));
Expand Down
31 changes: 31 additions & 0 deletions include/ariec61850/time_sync/sv_ptp_epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@ plan_sv_ptp_second_arm(const SvPtpClockObservation& o,
static_cast<std::uint32_t>(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<SvSecondBoundaryArm>
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<std::uint32_t>(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.
Expand Down
17 changes: 17 additions & 0 deletions tests/test_sv_ptp_epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading