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
4 changes: 3 additions & 1 deletion .github/workflows/esp32p4-ptp-lab.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint32_t>::is_always_lock_free);' in source
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ptp-core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 20 additions & 0 deletions docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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;
}

Expand Down
9 changes: 8 additions & 1 deletion include/ariec61850/time_sync/sv_ptp_phase_authority.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::int64_t>(uncertainty);
if (worst_case_phase_us <=
policy_.software_tracking_threshold_us) {
if (tracking_samples_ < policy_.consecutive_tracking_samples) {
++tracking_samples_;
Expand Down
4 changes: 2 additions & 2 deletions tests/test_ptp_power_profile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
37 changes: 37 additions & 0 deletions tests/test_sv_ptp_phase_authority.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading