Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
150b2ec
fix(smv): qualify PTP SOURCE AUTO within ~6s using bounded 2s MAC evi…
masarray Oct 18, 2026
fba4d7c
test(studio): match actual newline in evidence cadence source guard
masarray Oct 18, 2026
4a795b1
test(smv): verify unbalanced 4I+4V table RMS and signed angles in rea…
masarray Oct 18, 2026
6a6f748
fix(smv): enable GPTimer before PTP epoch sample to avoid slow initia…
masarray Oct 18, 2026
9c1e00b
feat(ptp): expose coherent MAC pair count for acquisition diagnostics
masarray Oct 18, 2026
4ebad5b
test(smv): assert coherent pair progression and fault revocation
masarray Oct 18, 2026
665f8d8
test(studio): guard close-to-start PTP arm timing and early diagnostics
masarray Oct 18, 2026
ed92934
fix(smv): require stable 3-epoch PTP/MAC qualification before LOCAL p…
masarray Oct 18, 2026
ed8213d
fix(smv): prevent transient SOURCE AUTO local chatter at startup
masarray Oct 18, 2026
aee6623
test(smv): verify intermittent phase never prematurely claims LOCAL
masarray Oct 18, 2026
5549e1a
test(studio): guard initial LOCAL qualification stability
masarray Oct 18, 2026
d85c96c
fix(embedded): expose portable LOCAL entry filter under every phase-e…
masarray Oct 18, 2026
ba66a79
feat(smv): prediscipline PTP AUTO sample clock before START without s…
masarray Oct 18, 2026
3576852
test(ptp): introduce explicit fail-closed warm clock reuse policy
masarray Oct 18, 2026
3d7385b
test(ptp): protect SOURCE AUTO dry preflight and guarded START transfer
masarray Oct 18, 2026
fd28fc4
test(studio): require genuine pre-start AUTO clock acquisition with z…
masarray Oct 18, 2026
dbaeebe
refactor(smv): enforce shared SOURCE AUTO preflight and reuse policy …
masarray Oct 18, 2026
2657960
fix(smv): actually steer SOURCE PTP phase in silent AUTO preflight
masarray Oct 18, 2026
f74802d
fix(ptp): portable fail-closed preflight steering policy
masarray Oct 18, 2026
0ba0229
test(ptp): enforce actual dry-run steering and no idle LOCAL claims
masarray Oct 18, 2026
7dc488c
test(studio): guard active silent preflight phase steering and no pre…
masarray Oct 18, 2026
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
38 changes: 38 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,44 @@ def main() -> None:
and "kMaxCanonicalTxOffsetNs = 250000LL" in sv_gate
and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests,
"retain independently bounded PTP sample-phase and nonzero MAC publication latency")
firmware_source = (project_root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text(encoding="utf-8")
require("kSvTxEvidenceEpochStride = 2U" in sv_gate
and "expected_epoch_stride = kSvTxEvidenceEpochStride" in sv_gate
and "kEvidenceStride = kSvTxEvidenceEpochStride" in sv_gate
and "kEgressEvidenceEveryZeroEpochs =\n SvLocalSyncEvidenceGate::kEvidenceStride" in firmware_source
and "check_fast_source_auto_qualification_with_fresh_evidence()" in sv_tests,
"SOURCE AUTO must qualify from two coherent descriptor pairs at 2/4/6s, not 48s")
# Eliminate long initial phase slew caused by GPTimer driver enable
# AFTER the EMAC/PTP epoch snapshot. All this work is outside the ISR.
enable_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_enable(timer));")
sample_ptp = firmware_source.index("const auto epoch_arm = plan_ptp_epoch_arm(eth_handle);")
start_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_start(timer));")
require(enable_clock < sample_ptp < start_clock
and "SV/PTP AUTO phase=" in firmware_source
and "MACpairs=%lu" in firmware_source
and "measured_pairs() const noexcept" in sv_gate,
"PTP startup must compensate GPTimer enable delay and expose early acquisition measurements")
require("SvLocalRefAcquisition local_acquisition;" in firmware_source
and "local_acquisition.observe(" in firmware_source
and "local_acquisition.reset();" in firmware_source
and "clean=%lu/%lu" in firmware_source
and "kRequiredCleanEpochs = 3U" in sv_gate
and "check_no_intermitttent_local_at_start()" in sv_tests,
"LOCAL REF startup must require three clean real epochs and immediately revoke on faults")
phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8")
phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8")
require("g_preflight_phase_clock_active" in firmware_source
and "publisher_notifications_enabled" in firmware_source
and "launch_on_next_epoch" in firmware_source
and "preflight_only = false" in firmware_source
and "SV/PTP AUTO preflight sample clock armed (SV TX OFF)" in firmware_source
and "check_auto_preflight_does_not_invent_sync_or_start()" in phase_tests
and "SvAutoPreflightPolicy::can_steer(" in firmware_source
and "SvAutoPreflightPolicy::can_advertise_local(" in firmware_source
and "if (injecting)" in firmware_source
and "preflight_telemetry" in firmware_source
and "SvAutoPreflightPolicy" in phase_header,
"AUTO SOURCE preflight must discipline the sample clock before START without transmitting SV")
main_qml = source("Main.qml")
preview = source("SignalPreview.qml")
tray = source("StudioTimingTray.qml")
Expand Down
235 changes: 194 additions & 41 deletions embedded/esp32p4_smv_injector/main/app_main.cpp

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions include/ariec61850/time_sync/sv_ptp_phase_authority.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,39 @@ class SvPtpPublisherContinuityGate final {
std::uint32_t source_generation_{};
};

// Pure preflight reuse policy. This does NOT grant smpSynch or allow any
// Ethernet SV transmission. Production uses the same checks before reusing
// an idle SOURCE/AUTO GPTimer when the operator explicitly presses START.
struct SvAutoPreflightPolicy final {
[[nodiscard]] static constexpr bool can_prepare(
const bool auto_mode, const bool usable_source,
const bool profile_valid, const std::uint32_t rate,
const std::uint32_t sample_counter_modulus) noexcept {
return auto_mode && usable_source && profile_valid && rate > 0U &&
rate == sample_counter_modulus;
}
[[nodiscard]] static constexpr bool can_steer(
const bool session_valid, const bool phase_accepted,
const bool lease_valid, const bool faults_unchanged,
const bool injecting, const bool preflight) noexcept {
return session_valid && phase_accepted && lease_valid &&
faults_unchanged && (injecting || preflight);
}

[[nodiscard]] static constexpr bool can_advertise_local(
const bool injecting, const bool descriptor_qualified) noexcept {
return injecting && descriptor_qualified;
}

[[nodiscard]] static constexpr bool can_reuse(
const bool preflight_running, const bool phase_enabled,
const bool source_lease_unchanged, const bool rate_unchanged,
const bool auto_mode) noexcept {
return preflight_running && phase_enabled &&
source_lease_unchanged && rate_unchanged && auto_mode;
}
};

// ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the
// existing rational scheduler by at most ONE 1-MHz timer tick. The complete
// correction is distributed across one explicitly armed second (4000/4800
Expand Down
60 changes: 54 additions & 6 deletions include/ariec61850/time_sync/sv_tx_egress_evidence.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

namespace ar::iec61850::time_sync {

// One actual canonical descriptor timestamp every two smpCnt=0 epochs.
// Baseline at ~2s, two coherent pairs at ~4s and ~6s permit AUTO to
// qualify within the 10s operator target IF independent software phase,
// PTP source and publisher continuity have also qualified. Never force 1.
inline constexpr std::uint32_t kSvTxEvidenceEpochStride = 2U;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update the advertised descriptor polling cadence

With AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL enabled, this constant increases the blocking descriptor-timestamp operation from once per 16 epochs to once per 2 epochs, but embedded/esp32p4_smv_injector/main/Kconfig.projbuild and docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md still state that it runs once per 16 epochs. Because those descriptions are where users assess the feature's real-time scheduling risk, they now understate the polling frequency by 8×; update the Kconfig help and timing documentation with this change.

AGENTS.md reference: AGENTS.md:L230-L236

Useful? React with 👍 / 👎.


// Sparse, read-only MAC descriptor TX evidence. No result authorizes
// smpSynch=1/2, grandmaster quality or IED synchronization.
struct SvTxEgressObservation final {
Expand Down Expand Up @@ -54,13 +60,13 @@ struct SvTxEgressSummary final {
std::int32_t last_phase_ns{};
};

// Explicit stride corresponds to every 16th sample-counter-zero epoch in
// ESP32-P4 M2C. Readings are NOT inferred from packet rate or PCAP timestamps.
// Evidence cadence follows the shared firmware stride. Readings are NOT
// inferred from packet rate or PCAP timestamps.
// No heap, locks, I/O or clocks; suitable for strict portable unit testing.
class SvTxEgressEvidence final {
public:
explicit constexpr SvTxEgressEvidence(
const std::uint32_t expected_epoch_stride = 16U) noexcept
const std::uint32_t expected_epoch_stride = kSvTxEvidenceEpochStride) noexcept
: expected_stride_(expected_epoch_stride) {}

constexpr void reset() noexcept {
Expand Down Expand Up @@ -195,8 +201,8 @@ class SvLocalSyncEvidenceGate final {
static constexpr std::int64_t kMinCanonicalTxOffsetNs = -4000LL;
static constexpr std::int64_t kMaxCanonicalTxOffsetNs = 250000LL;
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 kEvidenceStride = kSvTxEvidenceEpochStride;
static constexpr std::uint32_t kMaxEvidenceAgeEpochs = kEvidenceStride + 1U;
static constexpr std::uint32_t kRequiredMeasuredPairs = 2U;

constexpr void reset() noexcept {
Expand All @@ -211,7 +217,7 @@ class SvLocalSyncEvidenceGate final {
// The phase here is the packet's TX offset from the nearest whole PTP
// second, i.e. a bound on *publication latency*, NOT sampling phase.
// A separate GPTimer/EMAC observer qualifies the sampling instant.
// The lower-level tracker verifies consecutive 16-second PTP epochs
// The lower-level tracker verifies consecutive evidenced PTP epochs
// and the same source/session.
constexpr void observe(const SvTxEgressObservation& o,
const SvTxEvidenceResult result,
Expand Down Expand Up @@ -239,6 +245,13 @@ class SvLocalSyncEvidenceGate final {
if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_;
}

// Diagnostic read under the same ownership/critical section as observe().
// This is a count of consecutive measured, coherent descriptor pairs,
// not a hardware-phase certificate or proof of IED subscription.
[[nodiscard]] constexpr std::uint32_t measured_pairs() const noexcept {
return matched_pairs_;
}

[[nodiscard]] constexpr bool qualified(
const std::uint32_t session,
const std::uint32_t fault_generation,
Expand All @@ -264,4 +277,39 @@ class SvLocalSyncEvidenceGate final {
std::uint32_t last_epoch_{};
};

// Fail-closed entry filter: requires consecutive, independently qualified
// real SV epochs before asserting LOCAL, but never delays loss of LOCAL.
class SvLocalRefAcquisition final {
public:
static constexpr std::uint32_t kRequiredCleanEpochs = 3U;
constexpr void reset() noexcept {
count_ = 0U; session_ = 0U; faults_ = 0U;
source_ = 0U; epoch_ = 0U;
}
[[nodiscard]] constexpr bool observe(
const bool qualified, const std::uint32_t session,
const std::uint32_t faults, const std::uint32_t source,
const std::uint32_t epoch) noexcept {
if (!qualified || session == 0U || source == 0U || epoch == 0U) {
reset();
return false;
}
if (count_ != 0U &&
(session_ != session || faults_ != faults || source_ != source ||
epoch - epoch_ != 1U)) reset();
session_ = session; faults_ = faults; source_ = source; epoch_ = epoch;
if (count_ < kRequiredCleanEpochs) ++count_;
return count_ == kRequiredCleanEpochs;
}
[[nodiscard]] constexpr std::uint32_t clean_epochs() const noexcept {
return count_;
}
private:
std::uint32_t count_{};
std::uint32_t session_{};
std::uint32_t faults_{};
std::uint32_t source_{};
std::uint32_t epoch_{};
};

} // namespace ar::iec61850::time_sync
52 changes: 52 additions & 0 deletions tests/test_live_signal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "ariec61850/sampled_values/live_signal_state.hpp"

#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <exception>
#include <iostream>
Expand Down Expand Up @@ -138,6 +140,55 @@ void fixed_point_engine_preserves_frequency_and_phase() {
CHECK(phase_120 != phase_minus_120);
}

// Independent waveform regression for the operator's non-balanced 4I+4V
// table. The firmware MUST NOT reset current phases to the voltage defaults.
// A full 80-sample/50Hz fundamental projection checks each channel's RMS
// and signed phase in the actual fixed-point generator, not a GUI mock.
void custom_four_current_four_voltage_table_reaches_sine_samples() {
using namespace ar::iec61850::sampled_values;

SvFixedPointSineEngine engine;
auto state = SvLiveSignalBank::default_state();
state.frequency_millihz = 50000U;
const std::array<std::int32_t, 8> rms_counts{
1000, 800, 1200, 0, 5774, 5774, 5774, 0};
const std::array<std::int32_t, 8> phases_mdeg{
-30000, -145000, 87000, 0, 0, -120000, 120000, 0};
for (std::size_t ch = 0; ch < state.channels.size(); ++ch) {
state.channels[ch].rms_counts = rms_counts[ch];
state.channels[ch].phase_millidegrees = phases_mdeg[ch];
state.channels[ch].enabled = true;
}

constexpr double pi = 3.14159265358979323846;
constexpr int points = 80;
std::array<double, 8> sin_projection{};
std::array<double, 8> cos_projection{};
for (int n = 0; n < points; ++n) {
const auto values = engine.next(state, 4000U);
const double angle = (2.0 * pi * n) / points;
for (std::size_t ch = 0; ch < values.size(); ++ch) {
sin_projection[ch] += values[ch] * std::sin(angle);
cos_projection[ch] += values[ch] * std::cos(angle);
}
}
for (std::size_t ch = 0; ch < rms_counts.size(); ++ch) {
const double estimated_rms = std::hypot(sin_projection[ch], cos_projection[ch])
* std::sqrt(2.0) / points;
if (rms_counts[ch] == 0) {
CHECK(estimated_rms < 1.0);
continue;
}
const double estimated_phase_deg = std::atan2(
cos_projection[ch], sin_projection[ch]) * 180.0 / pi;
const double phase_error_deg = std::remainder(
estimated_phase_deg - phases_mdeg[ch] / 1000.0, 360.0);
CHECK(std::abs(phase_error_deg) < 1.0);
CHECK(std::abs(estimated_rms - rms_counts[ch]) <
std::max(4.0, rms_counts[ch] * 0.012));
}
}

void independent_channels_change_without_stream_restart() {
using namespace ar::iec61850::sampled_values;

Expand Down Expand Up @@ -174,6 +225,7 @@ int main() {
dc_engine_outputs_constant_engineering_values();
ct_saturation_shape_is_bounded_and_asymmetric();
independent_channels_change_without_stream_restart();
custom_four_current_four_voltage_table_reaches_sine_samples();
std::cout << "[PASS] live Sampled Values signal state\n";
return 0;
} catch (const std::exception& error) {
Expand Down
43 changes: 43 additions & 0 deletions tests/test_sv_ptp_phase_authority.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,48 @@ SvPtpPhaseObservation snapshot(
epoch, kEpochZero + epoch, kPtpfractionNs};
}

void check_auto_preflight_does_not_invent_sync_or_start() {
static_assert(SvAutoPreflightPolicy::can_prepare(
true, true, true, 4000U, 4000U));
CHECK(!SvAutoPreflightPolicy::can_prepare(
false, true, true, 4000U, 4000U)); // operator FORCE_0
CHECK(!SvAutoPreflightPolicy::can_prepare(
true, false, true, 4000U, 4000U)); // PTP source OFF
CHECK(!SvAutoPreflightPolicy::can_prepare(
true, true, false, 4000U, 4000U)); // unverified profile
CHECK(!SvAutoPreflightPolicy::can_prepare(
true, true, true, 4000U, 4800U)); // no 1-second wrap
CHECK(!SvAutoPreflightPolicy::can_prepare(
true, true, true, 0U, 0U));
// Field capture: 120 seconds of AUTO=0 after a preflight phase
// observer was improperly gated on actual SV injection.
CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, false, true));
CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, true, false));
CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, true, false, false));
CHECK(!SvAutoPreflightPolicy::can_steer(true, true, false, true, false, true));
CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, false, false, true));
CHECK(!SvAutoPreflightPolicy::can_steer(true, false, true, true, false, true));
CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true));
CHECK(!SvAutoPreflightPolicy::can_advertise_local(true, false));
CHECK(SvAutoPreflightPolicy::can_advertise_local(true, true));
CHECK(SvAutoPreflightPolicy::can_reuse(true, true, true, true, true));
CHECK(!SvAutoPreflightPolicy::can_reuse(false, true, true, true, true));
CHECK(!SvAutoPreflightPolicy::can_reuse(true, false, true, true, true));
CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, false, true, true));
CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, false, true));
CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, true, false));

// A warm phase servo can track but must never be interpreted as an
// independently measured canonical SV publication. The packet evidence
// gate is reset at START, so even prequalified phase is NOT smpSynch=1.
SvPtpPhaseAuthority phase;
for (std::uint32_t epoch = 1U; epoch <= 5U; ++epoch) {
const auto d = phase.observe(snapshot(epoch, 0U, 0U));
CHECK(d.accepted);
CHECK(d.software_tracking == (epoch >= 4U));
}
}

void check_phase_sign_and_bounded_corrections() {
SvPtpPhaseAuthority controller;
const auto warm = controller.observe(snapshot(1U, 220));
Expand Down Expand Up @@ -354,6 +396,7 @@ void check_continuous_ptp_second_binding() {

int main() {
try {
check_auto_preflight_does_not_invent_sync_or_start();
check_phase_sign_and_bounded_corrections();
check_uncertain_brackets_never_claim_software_tracking();
check_per_second_slew_distribution();
Expand Down
Loading
Loading