diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index cbc7acdfd..f18374cba 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -111,10 +111,14 @@ jobs: } $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json if ($manifest.phaseSyncPreview -ne $true -or + $manifest.bootSimulatedGlobalGmEnabled -ne $true -or + $manifest.bootSourceAutostartEnabled -ne $true -or + $manifest.defaultPtpPolicy -ne 'INTERNAL_GM_ONLY' -or + $manifest.globalGmTraceabilityPhysicallyProven -ne $false -or $manifest.phaseServoEnabled -ne $true -or $manifest.canonicalMacTxEvidenceEnabled -ne $true -or $manifest.broadcastMirrorEnabled -ne $false) { - throw 'Studio preview refuses old/default SMV firmware without measured PTP-SV phase and MAC TX evidence' + throw 'FAT preview requires simulated Global GM BOOT + PTP-source autostart + measured SV phase/MAC evidence, never LOCAL-only firmware' } $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') foreach ($capability in $expectedCapabilities) { diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 72cf8ad26..e664ca679 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -158,12 +158,16 @@ jobs: # M3.B must really be linked into the exact shipped ESP32-P4 binary. # A disabled dependency would otherwise silently negate sdkconfig.defaults. grep -qx 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' sdkconfig + # A LOCAL default can pass phase/MAC gates but the relay requires + # globally synchronized samples. Never ship a preview where the + # isolated lab Global GM boot option silently compiled out. + grep -qx 'CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y' sdkconfig grep -qx 'CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL=y' sdkconfig grep -qx 'CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK=y' sdkconfig grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig - echo 'SOURCE local AUTO preview: servo ON; canonical MAC TX evidence ON; mirror OFF' + echo 'SOURCE simulated Global GM preview: PTP autostart ON; servo ON; canonical MAC TX evidence ON; mirror OFF' - name: Verify P0 pre-v3 silicon policy run: | @@ -259,7 +263,15 @@ jobs: servo = 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' in cfg mac_evidence = 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' in cfg no_mirror = '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' in cfg - candidate = servo and mac_evidence and no_mirror + gm_boot = 'CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y' in cfg + source_boot = 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' in cfg + candidate = servo and mac_evidence and no_mirror and gm_boot and source_boot + # External GM arbitration is compiled for Advanced operator use. + # It must NEVER silently become the power-on time source. + facade = Path('main/ptp_mode_facade.cpp').read_text(encoding='utf-8') + default_mode = facade.split('g_advanced_ptp_mode{', 1)[1].split('};', 1)[0] + if 'AR_PTP_POLICY_INTERNAL_GM_ONLY' not in default_mode or 'AR_PTP_POLICY_AUTO_PREFER_EXTERNAL' in default_mode: + raise SystemExit('Unsafe implicit AUTO GM handover: FAT SOURCE must default INTERNAL_GM_ONLY') if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { @@ -277,6 +289,10 @@ jobs: "sourceCommit": source_commit, "buildId": source_commit[:16], "phaseSyncPreview": candidate, + "bootSimulatedGlobalGmEnabled": gm_boot, + "bootSourceAutostartEnabled": source_boot, + "defaultPtpPolicy": "INTERNAL_GM_ONLY", + "globalGmTraceabilityPhysicallyProven": False, "phaseServoEnabled": servo, "canonicalMacTxEvidenceEnabled": mac_evidence, "broadcastMirrorEnabled": not no_mirror, diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a715d540d..a7a7259ab 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -82,7 +82,9 @@ def main() -> None: 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 + # Timebase continues through the short STOP->PARK interval + # independently of transient RUNNING/PREFLIGHT booleans. + and "SvAutoPreflightPolicy::can_steer_owned_grid(" in firmware_source and "SvAutoPreflightPolicy::can_advertise_local(" in firmware_source and "if (injecting)" in firmware_source and "preflight_telemetry" in firmware_source @@ -747,6 +749,40 @@ def main() -> None: and "g_warm_resume_first_epoch" in firmware_source, "isolated simulated GM must boot/fallback and warm SV resume must requalify MAC") + # M3.E: IEC 61850 sample-clock sync must survive SV-only STOP/START. + require("g_parked_source_clock_certified" in firmware_source + and "SvParkedClockAuthority::may_preserve" in firmware_source + and "SvParkedClockAuthority::may_resume" in firmware_source + and "g_preflight_phase_clock_active" in firmware_source + and "g_warm_resume_first_epoch" in firmware_source, + "SV STOP/START must preserve measured PTP clock authority") + # The 1-Hz phase servo must follow actual GPTimer/PTP ownership. + # A serial STOP flips RUNNING before PARKED; both GUI/ISR flags may be + # false briefly while the real hardware timebase is still continuous. + require("SvAutoPreflightPolicy::can_steer_owned_grid" in firmware_source + and "SvAutoPreflightPolicy::live_publisher_active" in firmware_source + and "publisher_notifications_enabled.load(" in firmware_source + and "clock_timer_owned_and_enabled" in firmware_source + and "if (preflight_active)" not in firmware_source + and "(!live_tx_running() &&" not in firmware_source, + "transition flags must not tear down an owned PTP sample clock") + + # PTP SOURCE readiness recovery cannot fabricate a NEW timebase lease. + # A source generation bump belongs to hardware initialization only. + source_generation_bump = "g_ptp_source_generation.fetch_add(1U" + require(ptp_source_task.count(source_generation_bump) == 1 + and ptp_source_task.find(source_generation_bump) < + ptp_source_task.find("std::uint32_t consecutive_failures = 0U"), + "PTP source generation must never advance on TX readiness recovery") + + # Compiled external-GM support is optional capability, never an + # implicit AUTO handover while the lab ESP32 is supposed to remain GM. + facade = (project_root / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text(encoding="utf-8") + default_mode = facade.split("g_advanced_ptp_mode{", 1)[1].split("};", 1)[0] + require("AR_PTP_POLICY_INTERNAL_GM_ONLY" in default_mode + and "AR_PTP_POLICY_AUTO_PREFER_EXTERNAL" not in default_mode, + "PTP SOURCE must default to INTERNAL_GM_ONLY, AUTO only operator opt-in") + print("Studio operator workspace contract: PASS") diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8f0a948fc..314ef2d74 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -129,6 +129,13 @@ constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz constexpr std::uint32_t kTimerResolutionHz = 1000000U; +#if AR_SV_PTP_PHASE_SUPPORTED +// Separate clock authority from SV TX session state. During SV STOP the +// exact 4-kHz grid/phase servo and PTP SOURCE continue uninterrupted; +// remember only a PREVIOUSLY MEASURED SOURCE certification. Never use this +// latch to qualify a cold start or a changed GM/clock/rate. +std::atomic g_parked_source_clock_certified{false}; +#endif // A single fail-closed operation for both AUTO roles. In particular, keep // receiver invalidation INSIDE the same conditional as SOURCE invalidation: @@ -136,6 +143,11 @@ constexpr std::uint32_t kTimerResolutionHz = 1000000U; // RECEIVER qualification impossible at every evidence epoch. inline void revoke_sv_auto_claims( const SvSyncLossReason reason = SvSyncLossReason::other) noexcept { +#if AR_SV_PTP_PHASE_SUPPORTED + // Every genuine timing/lease/transport fault revokes any parked + // certificate as well; only a verified SV-only STOP may preserve it. + g_parked_source_clock_certified.store(false, std::memory_order_release); +#endif // Only timestamp an actual lock transition; ordinary 4kHz frame // processing keeps its existing work budget. const bool was_qualified = smp_synch_source_local_qualified() || @@ -1151,17 +1163,34 @@ void publisher_task(void* argument) { // same dry-running sample grid may survive START. The 1-Hz // phase observer and per-frame link/fault/lease checks remain // authoritative. Fresh MAC descriptors must follow within 8 s. - const bool warm_resume = ar::iec61850::time_sync::SvWarmResumePolicy::may_begin( - source_unchanged, reuse_preflight, - smp_synch_source_local_qualified(), + const bool current_phase_healthy = g_sample_clock_context.phase_authority_valid.load( - std::memory_order_acquire) && + std::memory_order_acquire); + const bool phase_observer_fresh = ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( g_sample_clock_context.last_phase_observed_epoch.load( std::memory_order_acquire), g_sample_clock_context.epoch_generation.load( - std::memory_order_acquire)), - now_lease.role == PtpAuthorityRole::source); + std::memory_order_acquire)); + // STOP can occur between two control-plane phase observations. + // Do not treat the SV session switch as loss of the time domain: + // prior measured clock authority was captured when we PARKED. + const bool resume_certified = + smp_synch_source_local_qualified() || + g_parked_source_clock_certified.load(std::memory_order_acquire); + const bool warm_resume = + ar::iec61850::time_sync::SvParkedClockAuthority::may_resume( + resume_certified, source_unchanged, + reuse_preflight && profile_unchanged, + current_phase_healthy, phase_observer_fresh, + now_lease.role == PtpAuthorityRole::source, auto_mode); + // WarmResumePolicy remains an independent admission guard: + // no new MAC exception on a cold start. + const bool warm_admitted = + ar::iec61850::time_sync::SvWarmResumePolicy::may_begin( + source_unchanged, reuse_preflight, warm_resume, + current_phase_healthy && phase_observer_fresh, + now_lease.role == PtpAuthorityRole::source); #else constexpr bool reuse_preflight = false; stop_sample_clock(sample_clock); @@ -1203,7 +1232,7 @@ void publisher_task(void* argument) { portEXIT_CRITICAL(&g_tx_egress_mux); #if AR_SV_PTP_PHASE_SUPPORTED g_warm_resume_first_epoch.store( - warm_resume + warm_admitted ? g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) : 0U, std::memory_order_release); @@ -1213,6 +1242,22 @@ void publisher_task(void* argument) { #endif #if AR_SV_PTP_PHASE_SUPPORTED previous_slew_total = 0; + // Restore only an ALREADY CERTIFIED clock on the same live PTP + // lease and phase grid. The descriptor tracker will acquire a + // fresh MAC baseline within its existing bounded warm window. + // This happens before the first resumed SV packet; never force + // smpSynch 2 or bypass the bench-global/source-readiness gate. + if (warm_admitted) { + smp_synch_set_source_local_qualified( + true, SvSyncLossReason::none); + } + g_parked_source_clock_certified.store( + false, std::memory_order_release); + ESP_LOGI(kTag, "SV CLOCK RESUME %s: same PTP lease=%u phase=%u fresh=%u", + warm_admitted ? "WARM CERTIFIED" : "COLD REQUALIFY", + source_unchanged ? 1U : 0U, + current_phase_healthy ? 1U : 0U, + phase_observer_fresh ? 1U : 0U); #endif ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", @@ -1294,6 +1339,21 @@ void publisher_task(void* argument) { profile.sample_counter_modulus == active_profile.sample_counter_modulus, auto_clock_ready); if (park) { + const bool clock_was_certified = + ar::iec61850::time_sync::SvParkedClockAuthority::may_preserve( + smp_synch_source_local_qualified(), + lease.role == PtpAuthorityRole::source, same_lease, + true, + g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire), + ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire), + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire)), + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto); + g_parked_source_clock_certified.store( + clock_was_certified, std::memory_order_release); // ISR can have one in-flight wake at this edge; the next // START explicitly drains it before enabling epoch launch. g_sample_clock_context.launch_on_next_epoch.store( @@ -1305,7 +1365,8 @@ void publisher_task(void* argument) { preflight_clock = true; preflight_rate_hz = profile.publisher_rate_hz; ESP_LOGI(kTag, - "M3A PARKED: SV TX OFF, PTP/SV GPTimer and phase observer continue"); + "M3E PARKED: SV TX OFF, PTP/4k clock continuous certified=%u", + clock_was_certified ? 1U : 0U); } else { stop_sample_clock(sample_clock); epoch_armed = false; @@ -1731,9 +1792,11 @@ void sv_ptp_phase_observer_task(void* argument) { continue; } + // SV RUNNING and preflight are asynchronous publisher/session flags. + // STOP toggles RUNNING before the publisher can publish preflight. + // A still-owned GPTimer/EMAC PTP lease cannot lose clock alignment + // merely because both flags happen to be false during this handoff. if (events != 1U || - (!live_tx_running() && - !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { revoke_with_reason("epoch_notification_or_link"); @@ -1851,17 +1914,28 @@ void sv_ptp_phase_observer_task(void* argument) { g_monitored_sample_clock != nullptr && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == notified_session; - const bool injecting = live_tx_running(); - const bool preflight_active = g_preflight_phase_clock_active.load( - std::memory_order_acquire); + // Serial START becomes RUNNING before the next PTP-second gate + // physically enables publisher notifications. Until that ISR edge, + // continue observing a DRY synchronized clock, not a missing MAC + // stream. Also covers STOP arriving before the PARK control pass. + const bool injecting = SvAutoPreflightPolicy::live_publisher_active( + live_tx_running(), + g_sample_clock_context.publisher_notifications_enabled.load( + std::memory_order_acquire)); + const bool clock_timer_owned_and_enabled = + g_monitored_sample_clock != nullptr && + g_sample_clock_context.phase_enabled; + const bool auto_sync_mode = + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto; if (g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && - SvAutoPreflightPolicy::can_steer( + SvAutoPreflightPolicy::can_steer_owned_grid( session_matches, decision.accepted, same_sv_ptp_clock_lease( armed_lease, g_sample_clock_context.armed_ptp_clock) && same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()), - no_new_publisher_fault, injecting, preflight_active)) { + no_new_publisher_fault, + clock_timer_owned_and_enabled, auto_sync_mode)) { // The PRE-START clock must truly steer while SV TX is OFF. // The old live_tx_running() gate invalidated its servo on every // 1Hz observation; capture verified 120s of smpSynch=0. @@ -1958,7 +2032,9 @@ void sv_ptp_phase_observer_task(void* argument) { stable_local && lease.role == PtpAuthorityRole::receiver, SvSyncLossReason::phase_or_mac, stamp_ms); } else { - if (preflight_active) { + // M3.E: the same owned timebase keeps steering throughout + // STOP->PARK->START, even before the publisher updates its + // optional preflight flag. No SV packet is emitted here. // M3.A: keep *clock* qualification across an SV-only STOP. // No SV frame exists while TX is off. Preserve a prior // verified claim only while this SAME timer, lease, @@ -1972,13 +2048,16 @@ void sv_ptp_phase_observer_task(void* argument) { healthy_dry_phase, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); - if (!healthy_dry_phase) + if (!healthy_dry_phase) { revoke_sv_auto_claims(SvSyncLossReason::phase_or_mac); - } else { - local_acquisition.reset(); - source_fast_acquisition.reset(); - revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); - } + } else if (lease.role == PtpAuthorityRole::source && + smp_synch_source_local_qualified()) { + // Keep the established SOURCE certificate across + // arbitrarily many healthy parked PTP seconds. + // Preflight cannot promote an unqualified cold clock. + g_parked_source_clock_certified.store( + true, std::memory_order_release); + } } committed = true; } else if (session_matches) { diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 4dbca7446..090730827 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -826,6 +826,12 @@ void ptp_lab_task(void* argument) { g_bench_global_active.store( selected_ptp_config.bench_global_gm && ptp_tai_epoch_seeded, std::memory_order_release); + // Clock-generation identifies the actual EMAC IEEE1588 timebase + // lifetime. It changes when that hardware timebase is initialized, + // NEVER when a transient Announce/Sync/Pdelay TX readiness drops and + // recovers. The latter is transport health, not a PTP clock step. + // One exclusive SOURCE runtime owns the EMAC, so one bump per START. + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); const auto options = context.runtime->options(); // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the // larger validated interval. Default power-profile TTL = 3 seconds. @@ -960,7 +966,9 @@ void ptp_lab_task(void* argument) { if (!qualified_ready) { g_ptp_ready.store(false, std::memory_order_release); } else if (!was_ready) { - g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + // A recovered TX stream uses the SAME still-running EMAC clock. + // Do not report a new GM generation and force the SV sample + // phase servo to throw away its continuous 4-kHz grid. g_source_start_reason.store( PtpSourceStartReason::ready, std::memory_order_release); g_ptp_ready.store(true, std::memory_order_release); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index d0914c4cd..f37b45716 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -48,13 +48,12 @@ std::atomic_bool g_receiver_stop_pending{false}; // Cancel only automatic boot attempts; operator PTP actions are authoritative. std::atomic_bool g_boot_source_autostart_allowed{true}; std::atomic g_ptp_policy_generation{1U}; -#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL -std::atomic g_advanced_ptp_mode{ - AR_PTP_POLICY_AUTO_PREFER_EXTERNAL}; -#else +// Compiling external-GM arbitration must NEVER silently opt the relay +// test-set into handover. SOURCE stays its own GM across every SV STOP/START. +// AUTO_PREFER_EXTERNAL is an explicit, operator-selected runtime policy; +// the Kconfig option is a capability gate, NOT the power-on policy. std::atomic g_advanced_ptp_mode{ AR_PTP_POLICY_INTERNAL_GM_ONLY}; -#endif ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; void stop_active_ptp_roles() noexcept; diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 4492c7b4d..5691fe898 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -305,6 +305,25 @@ struct SvAutoPreflightPolicy final { faults_unchanged && (injecting || preflight); } + // The serial control state may say RUNNING *before* the ISR has opened + // the next PTP-second SV TX gate. Live MAC measurement may start only + // after that physical gate opened; until then the grid is still dry. + [[nodiscard]] static constexpr bool live_publisher_active( + const bool logical_running, const bool tx_notifications_enabled) noexcept { + return logical_running && tx_notifications_enabled; + } + + // Servo authority is the owned GPTimer + PTP clock lease, NOT a + // transient publisher RUNNING/preflight flag. They can both be false + // during a legitimate STOP->PARK handoff with a continuous 1-Hz clock. + [[nodiscard]] static constexpr bool can_steer_owned_grid( + const bool session_valid, const bool phase_accepted, + const bool lease_valid, const bool faults_unchanged, + const bool owned_timer_phase_enabled, const bool auto_sync_mode) noexcept { + return session_valid && phase_accepted && lease_valid && + faults_unchanged && owned_timer_phase_enabled && auto_sync_mode; + } + [[nodiscard]] static constexpr bool can_advertise_local( const bool injecting, const bool descriptor_qualified) noexcept { return injecting && descriptor_qualified; @@ -332,6 +351,37 @@ struct SvAutoPreflightPolicy final { } }; +// IEC 61850-9-2 smpSynch describes the SAMPLING TIMEBASE, not an Ethernet +// publisher session. A STOP can park the grid while the phase servo and PTP +// SOURCE remain alive; a subsequent START must use the SAME certified clock. +// In particular, deleting a serial TX/MAC observation baseline must never +// by itself invalidate a still-measured sample-clock authority. This policy +// has no clock-quality or traceability promotion function. +struct SvParkedClockAuthority final { + [[nodiscard]] static constexpr bool may_preserve( + const bool previously_certified, const bool is_internal_source, + const bool same_ptp_lease, const bool parked_grid_unchanged, + const bool measured_phase_healthy, const bool phase_observer_fresh, + const bool auto_mode) noexcept { + return previously_certified && is_internal_source && same_ptp_lease && + parked_grid_unchanged && measured_phase_healthy && + phase_observer_fresh && auto_mode; + } + + // A prior certification may be reused across STOP/START, but only after + // checking the current 1-Hz observer, source generation, exact GPTimer + // and runtime profile. A cold start or a new GM NEVER inherits a claim. + [[nodiscard]] static constexpr bool may_resume( + const bool certified_before_stop, + const bool same_ptp_lease, const bool unchanged_grid_and_profile, + const bool healthy_phase, const bool fresh_observer, + const bool internal_source, const bool auto_mode) noexcept { + return may_preserve(certified_before_stop, internal_source, + same_ptp_lease, unchanged_grid_and_profile, + healthy_phase, fresh_observer, auto_mode); + } +}; + // A certified, continuously running sample grid keeps its time authority // while SV TX is parked. A subsequent START must NOT turn the missing first // MAC timestamp BASELINE into a lost PTP lock. The publisher nevertheless diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 6efaef1a1..764657bfd 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -153,6 +153,69 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { true, true, true, 4000U, 4800U)); } +void check_m3e_transition_flags_cannot_revoke_owned_sample_clock() { + using Policy = SvAutoPreflightPolicy; + // Old implementation required RUNNING || PREFLIGHT. Both may be false + // after a legitimate serial SV STOP and before publisher PARK. + CHECK(!Policy::can_steer(true, true, true, true, false, false)); + CHECK(Policy::can_steer_owned_grid(true, true, true, true, true, true)); + // Serial control can become RUNNING before the actual second-boundary + // ISR launch. A dry clock is not an SV publication fault. + CHECK(!Policy::live_publisher_active(true, false)); + CHECK(!Policy::live_publisher_active(false, true)); + CHECK(!Policy::live_publisher_active(false, false)); + CHECK(Policy::live_publisher_active(true, true)); + // 100 STOP/START cycles; all four flag orderings share one GPTimer. + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + for (bool sv_running : {true, false}) { + for (bool preflight_flag : {true, false}) { + (void)sv_running; (void)preflight_flag; + CHECK(Policy::can_steer_owned_grid( + true, true, true, true, true, true)); + } + } + } + // No promotion for a missing timer, stale/changed PTP lease, skipped + // phase epoch, TX fault, invalid session or operator override. + for (int i = 0; i < 6; ++i) { + std::array valid{true, true, true, true, true, true}; + valid[static_cast(i)] = false; + CHECK(!Policy::can_steer_owned_grid( + valid[0], valid[1], valid[2], + valid[3], valid[4], valid[5])); + } +} + +void check_m3e_parked_certified_ptp_authority_survives_100_restarts() { + // IEC 61850-9-2: SmpSynch describes the sampled TIMEBASE, not whether + // the publisher is presently transmitting SV frames. + using Park = SvParkedClockAuthority; + using Resume = SvWarmResumePolicy; + CHECK(!Park::may_preserve(false, true, true, true, true, true, true)); + CHECK(!Park::may_resume(false, true, true, true, true, true, true)); + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + // The old certificate came from a measured MAC and qualified phase. + // The phase observer runs for sixty more seconds while SV TX is off. + bool certified = true; + CHECK(Park::may_preserve(certified, true, true, true, true, true, true)); + for (std::uint32_t second = 0U; second < 60U; ++second) + CHECK(Park::may_preserve(certified, true, true, true, true, true, true)); + CHECK(Park::may_resume(certified, true, true, true, true, true, true)); + CHECK(Resume::may_begin(true, true, certified, true, true)); + CHECK(Resume::retain(100U, 101U, true, true, true, true)); + CHECK(Resume::retain(100U, 105U, true, true, true, true)); + CHECK(!Resume::retain(100U, 109U, true, true, true, true)); + } + // A real PTP lease change, lost phase, invalid freshness, non-SOURCE + // mode, non-AUTO policy, or cold clock cannot inherit smpSynch. + for (int gate = 0; gate < 7; ++gate) { + std::array checks{true, true, true, true, true, true, true}; + checks[static_cast(gate)] = false; + CHECK(!Park::may_resume(checks[0], checks[1], checks[2], + checks[3], checks[4], checks[5], checks[6])); + } +} + void check_m3b_boot_ptp_is_safe_and_operator_owned() { using Boot = SvBootClockPolicy; static_assert(Boot::kMaxAttempts == 4U); @@ -701,6 +764,8 @@ int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); check_m3a_stop_start_keeps_qualified_sample_grid(); + check_m3e_transition_flags_cannot_revoke_owned_sample_clock(); + check_m3e_parked_certified_ptp_authority_survives_100_restarts(); check_m3b_boot_ptp_is_safe_and_operator_owned(); check_m3c_foreign_announce_and_exclusive_emac_handover(); check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion();