Skip to content
35 changes: 35 additions & 0 deletions docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,38 @@ 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.

## M3C.5 — Vendor-neutral AUTO local-sync qualification (2026-10-17)

No new operator prompts; source PTP On → SMV Start still operates directly.
The IEC 61850-9-2 local sync declaration (`smpSynch=1`) is not the same
as merely sending PTP frames. IEC/IEEE 61850-9-3 defines the applicable
power-utility PTP profile. The SIPROTEC 5 process-bus manual confirms
`Accepted SmpSynch=1` is an intentional local-source interoperability mode
when merging unit and relay share a time reference; ARStack contains no
Siemens-specific wire quirk.

A **SOURCE-only, explicit hardware-evidence build** can now promote AUTO to
local `1` only when ALL gates pass: stable PTP SOURCE generation, coherent
second-bound GPTimer sample-zero observations, software tracking with the
measured absolute phase plus read uncertainty <=4us, two adjacent coherent
16-second comparisons of genuine canonical SV MAC TX descriptor timestamps
both within +/-4us, no publish faults/missed slots/descriptor stalls, and
current hardware evidence age <=17 sample epochs. These 4us values are a
conservative project admission policy, not a universal IEC profile claim.
When disabled or insufficient, AUTO stays `0`; global `2` is never implied.

The gate revokes on STOP, role change, missed sample, failed transmission,
wrong generation, discontinuous PTP epoch, stale evidence or unqualified
software phase. No heap, blocking IO, or additional work in the GPTimer ISR.
The sparse actual-frame descriptor poll remains a possible publisher timing
disturbance; evaluate actual board and relay traces before release.

SOURCE and RECEIVER remain distinct: this milestone qualifies SOURCE local
evidence only; receiver `LOCKED` without equivalent measured SV egress
does not promote AUTO. Successful firmware CI does not establish physical
phase accuracy or guarantee zero relay sync alarms.

External specifications:
- https://standards.ieee.org/ieee/61850-9-3/6167/
- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true
62 changes: 59 additions & 3 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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_set_source_local_qualified;
using ar::esp32p4::smv::smp_synch_source_local_qualified;
using ar::esp32p4::smv::take_start_request;
using ar::iec61850::ethernet::MacAddress;
using ar::iec61850::ethernet::VlanTag;
Expand All @@ -97,6 +99,7 @@ using ar::iec61850::time_sync::SvPtpPublisherContinuityGate;
#endif
#if AR_SV_TX_EGRESS_SUPPORTED
using ar::iec61850::time_sync::SvTxEgressEvidence;
using ar::iec61850::time_sync::SvLocalSyncEvidenceGate;
using ar::iec61850::time_sync::SvTxEgressObservation;
using ar::iec61850::time_sync::SvTxEgressSummary;
#endif
Expand Down Expand Up @@ -139,6 +142,7 @@ std::atomic<std::uint32_t> g_sample_tick_total{0U};
constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U;
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
#endif
#if AR_SV_PTP_PHASE_SUPPORTED
SemaphoreHandle_t g_phase_timer_mutex = nullptr;
Expand Down Expand Up @@ -651,6 +655,9 @@ gptimer_handle_t start_sample_clock(
}

void stop_sample_clock(gptimer_handle_t& timer) noexcept {
// 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);
if (timer == nullptr) return;
#if AR_SV_PTP_PHASE_SUPPORTED
// Serialize teardown with the non-ISR phase observer, so a GPTimer
Expand Down Expand Up @@ -762,6 +769,7 @@ void publisher_task(void* argument) {
#if AR_SV_TX_EGRESS_SUPPORTED
zero_epoch_frames_seen = 0U;
portENTER_CRITICAL(&g_tx_egress_mux);
g_local_sync_evidence.reset();
g_tx_egress_evidence.reset();
portEXIT_CRITICAL(&g_tx_egress_mux);
#endif
Expand Down Expand Up @@ -848,6 +856,7 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
#endif
missed_slots = static_cast<std::uint64_t>(pending_ticks - 1U);
canonical_sample_count = static_cast<std::uint16_t>(
Expand All @@ -874,11 +883,24 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
#endif
#if CONFIG_AR_SMV_BROADCAST_MIRROR
++local.mirror_fail;
#endif
} else {
#if AR_SV_PTP_PHASE_SUPPORTED
// After qualification, consult the lightweight SOURCE freshness
// authority on each frame; a stopped/faulted PTP source must not
// continue advertising previously measured local synchronization.
if (smp_synch_source_local_qualified() &&
(!g_sample_clock_context.phase_authority_valid.load(
std::memory_order_acquire) ||
ar_ptp_lab_source_generation() !=
g_sample_clock_context.armed_ptp_clock.generation)) {
smp_synch_set_source_local_qualified(false);
}
#endif
const auto sync_decision = smp_synch_lab_decision();
const auto sample_synchronization =
static_cast<std::uint8_t>(sync_decision.value);
Expand Down Expand Up @@ -955,8 +977,12 @@ void publisher_task(void* argument) {
g_sample_clock_context.publisher_fault_generation.load(
std::memory_order_acquire) == fault_generation_before;
portENTER_CRITICAL(&g_tx_egress_mux);
static_cast<void>(g_tx_egress_evidence.record(egress));
const auto result = g_tx_egress_evidence.record(egress);
const auto phase_ns = g_tx_egress_evidence.summary().last_phase_ns;
g_local_sync_evidence.observe(egress, result, phase_ns);
portEXIT_CRITICAL(&g_tx_egress_mux);
if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured)
smp_synch_set_source_local_qualified(false);
}
#else
const esp_err_t canonical_result =
Expand All @@ -975,6 +1001,7 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
#endif
}

Expand Down Expand Up @@ -1036,6 +1063,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept {
false, std::memory_order_release);
g_sample_clock_context.requested_slew_ticks.store(
0, std::memory_order_relaxed);
smp_synch_set_source_local_qualified(false);
}
xSemaphoreGive(g_phase_timer_mutex);
}
Expand Down Expand Up @@ -1131,6 +1159,30 @@ void sv_ptp_phase_observer_task(void* argument) {
}

const auto decision = controller.observe(observation);
// Snapshot the independently stamped canonical SV evidence outside
// the GPTimer mutex. Source identity and fault/session generations
// must match the exact phase observation before any AUTO promotion.
bool source_local_qualified = false;
#if AR_SV_TX_EGRESS_SUPPORTED
if (lease.role == PtpAuthorityRole::source &&
decision.accepted && decision.epoch_second_continuous) {
const std::int64_t abs_phase_us = decision.phase_error_us < 0
? -static_cast<std::int64_t>(decision.phase_error_us)
: static_cast<std::int64_t>(decision.phase_error_us);
const std::uint64_t worst_case_phase_us =
static_cast<std::uint64_t>(abs_phase_us) +
decision.observation_uncertainty_us;
portENTER_CRITICAL(&g_tx_egress_mux);
source_local_qualified = g_local_sync_evidence.qualified(
notified_session, current_faults, lease.generation,
observation.last_sv_epoch_generation,
decision.software_tracking,
worst_case_phase_us <= UINT32_MAX
? static_cast<std::uint32_t>(worst_case_phase_us)
: UINT32_MAX);
portEXIT_CRITICAL(&g_tx_egress_mux);
}
#endif
// Handoff and GPTimer deletion share a control-plane mutex:
// neither a stale sample nor a recycled timer can receive a command.
bool committed = false;
Expand Down Expand Up @@ -1161,12 +1213,15 @@ 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);
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);
smp_synch_set_source_local_qualified(false);
}
xSemaphoreGive(g_phase_timer_mutex);
if (!committed) {
Expand All @@ -1177,13 +1232,14 @@ void sv_ptp_phase_observer_task(void* argument) {
if ((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; smpSynch AUTO remains 0",
"slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u AUTOlocal=%u",
static_cast<long>(decision.phase_error_us),
static_cast<unsigned long>(decision.observation_uncertainty_us),
static_cast<long>(decision.slew_ticks_next_epoch),
decision.software_tracking ? 1U : 0U,
static_cast<unsigned long long>(decision.sv_epoch_ptp_second),
decision.epoch_second_continuous ? 1U : 0U);
decision.epoch_second_continuous ? 1U : 0U,
smp_synch_source_local_qualified() ? 1U : 0U);
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "ptp_lab_task.hpp"
#include "ptp_receiver_task.hpp"
#include "smp_synch_lab.hpp"

#include "ariec61850/time_sync/ptp_discipline.hpp"
#include "ariec61850/time_sync/ptp_canonical_authority.hpp"
Expand Down Expand Up @@ -122,6 +123,9 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept {

// Ownership is derived from live workers, never from a stale selected role.
void stop_active_ptp_roles() noexcept {
// Operator STOP or role handoff revokes the transmitted AUTO local-sync
// claim immediately, independent of the slower phase observer cadence.
smp_synch_set_source_local_qualified(false);
if (ar_ptp_source_runtime_active()) {
ar_ptp_source_stop();
}
Expand Down
27 changes: 22 additions & 5 deletions embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ std::atomic<std::uint8_t> g_mode{
std::atomic<int> g_measured_value{-1};
// Fail-closed until an independent SV timeline/phase acceptance gate exists.
std::atomic_bool g_sv_timeline_phase_qualified{false};
std::atomic_bool g_source_local_qualified{false};

[[nodiscard]] std::optional<SmpSynchValue> measured_value() noexcept {
const int raw = g_measured_value.load(std::memory_order_acquire);
Expand All @@ -33,6 +34,17 @@ std::atomic_bool g_sv_timeline_phase_qualified{false};
}
}

[[nodiscard]] std::optional<SmpSynchValue> auto_evidence() noexcept {
// SOURCE has no receiver discipline object. Its local-sync claim
// originates exclusively from the sample phase + canonical TX gate.
// Global sync is reserved for measured external time traceability.
if (g_source_local_qualified.load(std::memory_order_acquire))
return SmpSynchValue::local_synchronized;
return ar::iec61850::time_sync::qualified_sv_sync_value(
measured_value(), g_sv_timeline_phase_qualified.load(
std::memory_order_acquire));
}

[[nodiscard]] SvSyncPolicyMode current_mode() noexcept {
return static_cast<SvSyncPolicyMode>(
g_mode.load(std::memory_order_acquire));
Expand All @@ -49,14 +61,11 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept {
}

SmpSynchDecision smp_synch_lab_decision() noexcept {
return resolve_sv_sync_policy(current_mode(),
ar::iec61850::time_sync::qualified_sv_sync_value(measured_value(),
g_sv_timeline_phase_qualified.load(std::memory_order_acquire)));
return resolve_sv_sync_policy(current_mode(), auto_evidence());
}

SmpSynchLabStatus smp_synch_lab_status() noexcept {
const auto measured = ar::iec61850::time_sync::qualified_sv_sync_value(
measured_value(), g_sv_timeline_phase_qualified.load(std::memory_order_acquire));
const auto measured = auto_evidence();
return {
resolve_sv_sync_policy(current_mode(), measured),
measured.has_value(),
Expand All @@ -73,4 +82,12 @@ void smp_synch_lab_set_measured(const std::optional<SmpSynchValue> value) noexce
std::memory_order_release);
}

void smp_synch_set_source_local_qualified(const bool qualified) noexcept {
g_source_local_qualified.store(qualified, std::memory_order_release);
}

bool smp_synch_source_local_qualified() noexcept {
return g_source_local_qualified.load(std::memory_order_acquire);
}

} // namespace ar::esp32p4::smv
6 changes: 6 additions & 0 deletions embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept;
*/
void smp_synch_lab_set_measured(std::optional<SmpSynchValue> value) noexcept;

/** SOURCE AUTO only: assert local sync after independent scheduled-phase AND
* canonical MAC TX evidence have been qualified. Must revoke on STOP, source
* change, missed slot, TX failure or stale phase; never claims global sync. */
void smp_synch_set_source_local_qualified(bool qualified) noexcept;
[[nodiscard]] bool smp_synch_source_local_qualified() noexcept;

} // namespace ar::esp32p4::smv
73 changes: 73 additions & 0 deletions include/ariec61850/time_sync/sv_tx_egress_evidence.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,77 @@ class SvTxEgressEvidence final {
SvTxEgressSummary summary_{};
};

// Sparse MAC egress is a corroborating measurement, NOT sufficient by itself
// to declare synchronized samples. The independent software phase observer
// must ALSO verify smpCnt-zero PTP epoch continuity with a bounded clock-read
// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression.
class SvLocalSyncEvidenceGate final {
public:
static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL;
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 kRequiredMeasuredPairs = 2U;

constexpr void reset() noexcept {
matched_pairs_ = 0U;
session_ = 0U;
fault_generation_ = 0U;
source_generation_ = 0U;
last_epoch_ = 0U;
}

// Call only for the actual canonical SV descriptor timestamp.
// The lower-level tracker already verifies that consecutive timestamps
// belong to adjacent 16-second PTP epochs and the same source/session.
constexpr void observe(const SvTxEgressObservation& o,
const SvTxEvidenceResult result,
const std::int32_t phase_ns) noexcept {
const std::int64_t phase = static_cast<std::int64_t>(phase_ns);
if (result != SvTxEvidenceResult::measured ||
!o.canonical_tx_success || !o.no_missed_slots ||
!o.same_sample_epoch ||
phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) {
reset();
return;
}
if (matched_pairs_ != 0U &&
(session_ != o.publisher_session_generation ||
fault_generation_ != o.publisher_fault_generation ||
source_generation_ != o.ptp_source_generation ||
o.sample_epoch_generation - last_epoch_ != kEvidenceStride)) {
reset();
}
session_ = o.publisher_session_generation;
fault_generation_ = o.publisher_fault_generation;
source_generation_ = o.ptp_source_generation;
last_epoch_ = o.sample_epoch_generation;
if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_;
}

[[nodiscard]] constexpr bool qualified(
const std::uint32_t session,
const std::uint32_t fault_generation,
const std::uint32_t source_generation,
const std::uint32_t current_epoch,
const bool software_tracking,
const std::uint32_t software_phase_bound_us) const noexcept {
return matched_pairs_ >= kRequiredMeasuredPairs &&
software_tracking &&
software_phase_bound_us <= kMaxSoftwarePhaseBoundUs &&
session != 0U && source_generation != 0U &&
session == session_ &&
fault_generation == fault_generation_ &&
source_generation == source_generation_ &&
current_epoch >= last_epoch_ &&
current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs;
}
private:
std::uint32_t matched_pairs_{};
std::uint32_t session_{};
std::uint32_t fault_generation_{};
std::uint32_t source_generation_{};
std::uint32_t last_epoch_{};
};

} // namespace ar::iec61850::time_sync
Loading
Loading