From cbbf49f3896ffb390b4e67a3ce0dd0d71e7246a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 19:55:47 +0700 Subject: [PATCH 1/3] P1: separate SV sampling semantics from frame cadence Introduce an explicit rational timing model for sampling basis, configured sample rate, ASDUs per Ethernet frame, samples/s and frames/s. Honor SmpPerPeriod as the SCL default, represent SecPerSample without guessing, keep legacy Studio bridge keys derived from the new authority, and keep the current ESP32-P4 target fail-closed to its proven SmpPerSec/one-ASDU boundary. Tracks #139. --- NORTH_STAR.md | 3 +- apps/arstack_studio/src/SclProfileModel.cpp | 42 ++++- docs/SV_INTEROPERABILITY_ROADMAP.md | 13 +- embedded/esp32p4_smv_injector/ARCHITECTURE.md | 9 +- .../esp32p4_profile_support.hpp | 8 +- .../sampled_values/publisher_profile.hpp | 74 ++++---- .../sampled_values/timing_semantics.hpp | 162 ++++++++++++++++++ tests/test_scl.cpp | 129 +++++++++++++- tools/smv_profile_inspect.cpp | 47 ++++- 9 files changed, 424 insertions(+), 63 deletions(-) create mode 100644 include/ariec61850/sampled_values/timing_semantics.hpp diff --git a/NORTH_STAR.md b/NORTH_STAR.md index 783996849..55832f468 100644 --- a/NORTH_STAR.md +++ b/NORTH_STAR.md @@ -135,7 +135,8 @@ SvPublisherProfile nofAsdu SmvOpts field-presence policy dataSetReference - samplingBasis / sampleRate + samplingBasis / configuredSampleRate + ASDUsPerFrame / derivedFrameCadence sampleCounterPolicy synchronizationPolicy channels[] diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 2f335089c..b306bf919 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -54,6 +54,7 @@ QString sampleModeText(const SvSampleMode mode) { switch (mode) { case SvSampleMode::samples_per_second: return QStringLiteral("SmpPerSec"); case SvSampleMode::samples_per_period: return QStringLiteral("SmpPerPeriod"); + case SvSampleMode::seconds_per_sample: return QStringLiteral("SecPerSample"); default: return QStringLiteral("Unknown"); } } @@ -101,10 +102,12 @@ QVariant SclProfileModel::data(const QModelIndex& index, const int role) const { case SvIdRole: return qstring(p.sv_id); case DestinationMacRole: return macText(p.destination_mac); case AppIdRole: return static_cast(p.app_id); - case PublisherRateRole: - return p.publisher_rate_hz.has_value() - ? QVariant::fromValue(static_cast(*p.publisher_rate_hz)) + case PublisherRateRole: { + const auto frameRate = p.timing.exact_frame_rate_hz(); + return frameRate.has_value() + ? QVariant::fromValue(static_cast(*frameRate)) : QVariant{}; + } case PayloadBytesRole: return static_cast(p.payload_size_bytes); case CounterPolicyRole: return counterPolicyText(p.sample_counter_policy); case CounterModulusRole: @@ -308,6 +311,7 @@ void SclProfileModel::rebuildRows() { row.profile = std::move(compiled.profile); const auto& profile = *row.profile; row.compatibilityClass = + profile.timing.resolved() && profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus ? QStringLiteral("A") : QStringLiteral("B"); @@ -340,11 +344,33 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co map.insert(QStringLiteral("vlanId"), static_cast(p.vlan_id)); map.insert(QStringLiteral("vlanPriority"), static_cast(p.vlan_priority)); map.insert(QStringLiteral("confRev"), static_cast(p.configuration_revision)); - map.insert(QStringLiteral("sampleRate"), static_cast(p.sample_rate_value)); - map.insert(QStringLiteral("sampleMode"), sampleModeText(p.sample_mode)); + map.insert(QStringLiteral("sampleRate"), static_cast(p.timing.configured_sample_rate)); + map.insert(QStringLiteral("sampleMode"), sampleModeText(p.timing.sampling_basis)); + map.insert(QStringLiteral("timingResolved"), p.timing.resolved()); + map.insert(QStringLiteral("nominalFrequencyMilliHz"), + p.timing.nominal_frequency_millihz.has_value() + ? QVariant::fromValue(static_cast(*p.timing.nominal_frequency_millihz)) + : QVariant{}); + map.insert(QStringLiteral("sampleRatePerSecondNumerator"), + p.timing.samples_per_second.has_value() + ? QVariant::fromValue(static_cast(p.timing.samples_per_second->numerator)) + : QVariant{}); + map.insert(QStringLiteral("sampleRatePerSecondDenominator"), + p.timing.samples_per_second.has_value() + ? QVariant::fromValue(static_cast(p.timing.samples_per_second->denominator)) + : QVariant{}); + map.insert(QStringLiteral("frameRateNumerator"), + p.timing.frames_per_second.has_value() + ? QVariant::fromValue(static_cast(p.timing.frames_per_second->numerator)) + : QVariant{}); + map.insert(QStringLiteral("frameRateDenominator"), + p.timing.frames_per_second.has_value() + ? QVariant::fromValue(static_cast(p.timing.frames_per_second->denominator)) + : QVariant{}); + const auto frameRate = p.timing.exact_frame_rate_hz(); map.insert(QStringLiteral("publisherRate"), - p.publisher_rate_hz.has_value() - ? QVariant::fromValue(static_cast(*p.publisher_rate_hz)) + frameRate.has_value() + ? QVariant::fromValue(static_cast(*frameRate)) : QVariant{}); map.insert(QStringLiteral("counterPolicy"), counterPolicyText(p.sample_counter_policy)); map.insert(QStringLiteral("counterModulus"), @@ -352,7 +378,7 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co ? QVariant::fromValue(static_cast(*p.sample_counter_modulus)) : QVariant{}); map.insert(QStringLiteral("payloadBytes"), static_cast(p.payload_size_bytes)); - map.insert(QStringLiteral("nofASDU"), static_cast(p.no_asdu)); + map.insert(QStringLiteral("nofASDU"), static_cast(p.timing.asdus_per_frame)); map.insert(QStringLiteral("channelLeafCount"), static_cast(p.channels.size())); map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set); map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate); diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md index 9d8220261..6a68cd892 100644 --- a/docs/SV_INTEROPERABILITY_ROADMAP.md +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -28,7 +28,7 @@ DataSet + type resolver | | +-- L2 address / VLAN / APPID +-- svID / confRev / nofASDU - +-- sample-rate and sample-mode semantics + +-- sampling basis / sample cadence / ASDU packetization / frame cadence +-- SmvOpts field-presence policy +-- ordered wire leaf layout +-- validated sample-counter policy @@ -96,11 +96,12 @@ The practical product rule is: **parse broadly, explain normalization, deploy na 3. **Unsupported means rejected, not guessed.** Unknown basic types, ambiguous DataSet bindings, incomplete address bindings and unresolved structures must produce explicit diagnostics. 4. **Optional fields are profile data.** `SmvOpts` is preserved so canonical ASDU field presence is not inferred from what a particular analyzer prefers. 5. **Timing is rational, not truncated.** A requested rate such as 4800 samples/s must not become a fixed 208 us period. The scheduler distributes integer timer ticks so long-term rate has no truncation drift. -6. **Counter policy is independently validated.** `SmpPerSec` can establish publisher rate, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. -7. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. -8. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. -9. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. -10. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. +6. **Sampling cadence is not frame cadence.** `SmpRate` and `SmpMod` define sampling semantics; `nofASDU` defines how many consecutive ASDUs are carried by one publication. The host derives both samples/s and Ethernet frames/s explicitly instead of storing one ambiguous publisher rate. +7. **Counter policy is independently validated.** `SmpPerSec` can establish sample cadence, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. +8. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. +9. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. +10. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. +11. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. ## Compatibility classes diff --git a/embedded/esp32p4_smv_injector/ARCHITECTURE.md b/embedded/esp32p4_smv_injector/ARCHITECTURE.md index 2d403af5c..fda69c04b 100644 --- a/embedded/esp32p4_smv_injector/ARCHITECTURE.md +++ b/embedded/esp32p4_smv_injector/ARCHITECTURE.md @@ -84,9 +84,8 @@ SvPublisherProfile appId vlanPresent / vlanId / vlanPriority confRev - sampleRate / sampleMode - publisherRateHz - nofAsdu + configuredSampleRate / samplingBasis + nofAsdu / derived sample cadence / derived frame cadence sampleCounterPolicy / modulus ASDU field-presence policy ordered channels @@ -95,8 +94,8 @@ SvPublisherProfile The first embedded bridge intentionally supports a bounded subset: -- one ASDU; -- absolute `SmpPerSec` publisher rate; +- one ASDU per Ethernet frame; +- resolved absolute sample cadence and frame cadence from `SmpPerSec`; - explicit validated sample-counter modulus; - eight logical channels `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; - 16 ordered leaves as eight `INT32 value + Quality` pairs; diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 128d24054..dd03546e8 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -50,9 +50,11 @@ namespace detail { [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { - if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || - profile.channels.size() != 16U || !profile.publisher_rate_hz.has_value() || - *profile.publisher_rate_hz == 0U || *profile.publisher_rate_hz > 65535U) { + const auto frame_rate_hz = profile.timing.exact_frame_rate_hz(); + if (profile.timing.sampling_basis != SvSampleMode::samples_per_second || + profile.timing.asdus_per_frame != 1U || profile.payload_size_bytes != 64U || + profile.channels.size() != 16U || !frame_rate_hz.has_value() || + *frame_rate_hz == 0U || *frame_rate_hz > 65535U) { return false; } if (profile.asdu_options.refresh_time || profile.asdu_options.security || diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp index fc4f443ef..00a08a63b 100644 --- a/include/ariec61850/sampled_values/publisher_profile.hpp +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -3,6 +3,7 @@ #pragma once #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/timing_semantics.hpp" #include "ariec61850/scl/model.hpp" #include @@ -18,12 +19,6 @@ namespace ar::iec61850::sampled_values { -enum class SvSampleMode : std::uint8_t { - unknown, - samples_per_period, - samples_per_second, -}; - enum class SvSampleCounterPolicy : std::uint8_t { unresolved, candidate_sample_rate_modulus, @@ -59,10 +54,14 @@ struct SvPublisherProfileCompileContext final { // every runtime sample-counter wrap policy. A standards/profile rule or // independently observed evidence may validate a modulus explicitly. std::optional sample_counter_modulus; + + // SmpPerPeriod needs an explicit nominal-system-frequency context before + // it can be resolved into samples/s and Ethernet frame cadence. + std::optional nominal_frequency_millihz; }; struct SvPublisherProfile final { - std::uint32_t schema_version{1U}; + std::uint32_t schema_version{2U}; std::string control_block_reference; std::string sv_id; std::string data_set_reference; @@ -74,10 +73,7 @@ struct SvPublisherProfile final { std::uint8_t vlan_priority{}; std::uint32_t configuration_revision{}; - std::uint32_t sample_rate_value{}; - SvSampleMode sample_mode{SvSampleMode::unknown}; - std::optional publisher_rate_hz; - std::uint16_t no_asdu{1U}; + SvPublicationTiming timing; SvSampleCounterPolicy sample_counter_policy{SvSampleCounterPolicy::unresolved}; std::optional sample_counter_modulus; SvAsduOptions asdu_options; @@ -110,9 +106,11 @@ class SvPublisherProfileCompiler final { profile.sv_id = stream.sv_id.empty() ? stream.smv_id : stream.sv_id; profile.data_set_reference = stream.data_set_reference; profile.configuration_revision = stream.configuration_revision; - profile.sample_rate_value = stream.sample_rate; - profile.sample_mode = parse_sample_mode(stream.sample_mode); - profile.no_asdu = stream.no_asdu; + profile.timing = resolve_sv_publication_timing( + parse_sample_mode(stream.sample_mode), + stream.sample_rate, + stream.no_asdu, + context.nominal_frequency_millihz); profile.asdu_options = compile_options(stream.smv_options); if (!stream.address.destination_mac.has_value()) { @@ -146,20 +144,29 @@ class SvPublisherProfileCompiler final { if (profile.sv_id.empty()) { result.errors.push_back("SV stream has no svID/smvID."); } - if (profile.no_asdu == 0U) { - result.errors.push_back("SV nofASDU must be greater than zero."); - } - if (profile.sample_rate_value == 0U) { - result.errors.push_back("SV sample rate is zero or missing."); - } - if (profile.sample_mode == SvSampleMode::samples_per_second) { - profile.publisher_rate_hz = profile.sample_rate_value; - } else if (profile.sample_mode == SvSampleMode::samples_per_period) { + switch (profile.timing.resolution) { + case SvTimingResolution::resolved: + break; + case SvTimingResolution::needs_nominal_frequency: result.warnings.push_back( - "SmpPerPeriod requires a nominal-system-frequency input before an absolute publisher rate can be scheduled."); - } else { + "SmpPerPeriod requires an explicit nominal-system-frequency input before samples/s and Ethernet frame cadence are known."); + break; + case SvTimingResolution::invalid_sample_rate: + result.errors.push_back("SV sample rate is zero or missing."); + break; + case SvTimingResolution::invalid_asdu_count: + result.errors.push_back("SV nofASDU must be greater than zero."); + break; + case SvTimingResolution::unsupported_sample_mode: result.errors.push_back("SV sample mode is missing or unsupported."); + break; + case SvTimingResolution::invalid_nominal_frequency: + result.errors.push_back("SV nominal system frequency must be greater than zero."); + break; + case SvTimingResolution::arithmetic_overflow: + result.errors.push_back("SV timing semantics exceed the supported numeric range."); + break; } if (context.sample_counter_modulus.has_value()) { @@ -170,9 +177,9 @@ class SvPublisherProfileCompiler final { profile.sample_counter_modulus = context.sample_counter_modulus; } } else if ( - profile.sample_mode == SvSampleMode::samples_per_second && - profile.sample_rate_value > 0U && - profile.sample_rate_value <= std::numeric_limits::max()) { + profile.timing.sampling_basis == SvSampleMode::samples_per_second && + profile.timing.configured_sample_rate > 0U && + profile.timing.configured_sample_rate <= std::numeric_limits::max()) { // Several second-aligned interoperability families use a one-second // sample-count cycle. Preserve that useful candidate for inspection, // but mark it non-authoritative: device deployment must validate the @@ -180,7 +187,7 @@ class SvPublisherProfileCompiler final { profile.sample_counter_policy = SvSampleCounterPolicy::candidate_sample_rate_modulus; profile.sample_counter_modulus = - static_cast(profile.sample_rate_value); + static_cast(profile.timing.configured_sample_rate); result.warnings.push_back( "SV sample-counter modulus equals the SmpPerSec rate only as an unvalidated candidate; confirm it from the applicable profile rule or observed evidence before deployment."); } else { @@ -237,11 +244,16 @@ class SvPublisherProfileCompiler final { [[nodiscard]] static SvSampleMode parse_sample_mode(const std::string_view text) { const auto normalized = lower_copy(text); + // IEC 61850-6 defines SmpPerPeriod as the SCL default when smpMod is + // omitted. Keep that configured default explicit in the compiled model. + if (normalized.empty() || normalized == "smpperperiod") { + return SvSampleMode::samples_per_period; + } if (normalized == "smppersec") { return SvSampleMode::samples_per_second; } - if (normalized == "smpperperiod") { - return SvSampleMode::samples_per_period; + if (normalized == "secpersample") { + return SvSampleMode::seconds_per_sample; } return SvSampleMode::unknown; } diff --git a/include/ariec61850/sampled_values/timing_semantics.hpp b/include/ariec61850/sampled_values/timing_semantics.hpp new file mode 100644 index 000000000..c37127399 --- /dev/null +++ b/include/ariec61850/sampled_values/timing_semantics.hpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SvSampleMode : std::uint8_t { + unknown, + samples_per_period, + samples_per_second, + seconds_per_sample, +}; + +enum class SvTimingResolution : std::uint8_t { + resolved, + needs_nominal_frequency, + invalid_sample_rate, + invalid_asdu_count, + unsupported_sample_mode, + invalid_nominal_frequency, + arithmetic_overflow, +}; + +struct SvRationalRate final { + std::uint64_t numerator{}; + std::uint64_t denominator{1U}; + + [[nodiscard]] static std::optional make( + const std::uint64_t numerator_value, + const std::uint64_t denominator_value) noexcept { + if (numerator_value == 0U || denominator_value == 0U) { + return std::nullopt; + } + const auto divisor = std::gcd(numerator_value, denominator_value); + return SvRationalRate{ + numerator_value / divisor, + denominator_value / divisor, + }; + } + + [[nodiscard]] std::optional exact_hz() const noexcept { + if (denominator == 0U || numerator == 0U || + (numerator % denominator) != 0U) { + return std::nullopt; + } + const auto value = numerator / denominator; + if (value > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(value); + } + + friend bool operator==(const SvRationalRate&, const SvRationalRate&) = default; +}; + +struct SvPublicationTiming final { + SvSampleMode sampling_basis{SvSampleMode::unknown}; + std::uint32_t configured_sample_rate{}; + std::uint16_t asdus_per_frame{1U}; + std::optional nominal_frequency_millihz; + std::optional samples_per_second; + std::optional frames_per_second; + SvTimingResolution resolution{SvTimingResolution::unsupported_sample_mode}; + + [[nodiscard]] bool resolved() const noexcept { + return resolution == SvTimingResolution::resolved; + } + + [[nodiscard]] std::optional exact_sample_rate_hz() const noexcept { + return samples_per_second ? samples_per_second->exact_hz() : std::nullopt; + } + + [[nodiscard]] std::optional exact_frame_rate_hz() const noexcept { + return frames_per_second ? frames_per_second->exact_hz() : std::nullopt; + } + + friend bool operator==(const SvPublicationTiming&, const SvPublicationTiming&) = default; +}; + +[[nodiscard]] inline SvPublicationTiming resolve_sv_publication_timing( + const SvSampleMode sampling_basis, + const std::uint32_t configured_sample_rate, + const std::uint16_t asdus_per_frame, + const std::optional nominal_frequency_millihz = std::nullopt) noexcept { + SvPublicationTiming timing; + timing.sampling_basis = sampling_basis; + timing.configured_sample_rate = configured_sample_rate; + timing.asdus_per_frame = asdus_per_frame; + timing.nominal_frequency_millihz = nominal_frequency_millihz; + + if (configured_sample_rate == 0U) { + timing.resolution = SvTimingResolution::invalid_sample_rate; + return timing; + } + if (asdus_per_frame == 0U) { + timing.resolution = SvTimingResolution::invalid_asdu_count; + return timing; + } + + std::optional samples_per_second; + switch (sampling_basis) { + case SvSampleMode::samples_per_second: + samples_per_second = SvRationalRate::make(configured_sample_rate, 1U); + break; + case SvSampleMode::samples_per_period: + if (!nominal_frequency_millihz.has_value()) { + timing.resolution = SvTimingResolution::needs_nominal_frequency; + return timing; + } + if (*nominal_frequency_millihz == 0U) { + timing.resolution = SvTimingResolution::invalid_nominal_frequency; + return timing; + } + if (configured_sample_rate > + std::numeric_limits::max() / + static_cast(*nominal_frequency_millihz)) { + timing.resolution = SvTimingResolution::arithmetic_overflow; + return timing; + } + samples_per_second = SvRationalRate::make( + static_cast(configured_sample_rate) * + static_cast(*nominal_frequency_millihz), + 1000U); + break; + case SvSampleMode::seconds_per_sample: + samples_per_second = SvRationalRate::make(1U, configured_sample_rate); + break; + case SvSampleMode::unknown: + timing.resolution = SvTimingResolution::unsupported_sample_mode; + return timing; + } + + if (!samples_per_second.has_value()) { + timing.resolution = SvTimingResolution::arithmetic_overflow; + return timing; + } + if (samples_per_second->denominator > + std::numeric_limits::max() / + static_cast(asdus_per_frame)) { + timing.resolution = SvTimingResolution::arithmetic_overflow; + return timing; + } + + const auto frames_per_second = SvRationalRate::make( + samples_per_second->numerator, + samples_per_second->denominator * static_cast(asdus_per_frame)); + if (!frames_per_second.has_value()) { + timing.resolution = SvTimingResolution::arithmetic_overflow; + return timing; + } + + timing.samples_per_second = *samples_per_second; + timing.frames_per_second = *frames_per_second; + timing.resolution = SvTimingResolution::resolved; + return timing; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 0a1007f30..fa4f4cb1f 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -206,9 +206,19 @@ void parser_compiles_structured_4800_sv_profile_without_drift() { CHECK(profile.vlan_present); CHECK(profile.vlan_id == 0U); CHECK(profile.vlan_priority == 4U); + CHECK(profile.schema_version == 2U); CHECK(profile.configuration_revision == 100U); - CHECK(profile.sample_mode == SvSampleMode::samples_per_second); - CHECK(profile.publisher_rate_hz == std::optional{4800U}); + CHECK(profile.timing.sampling_basis == SvSampleMode::samples_per_second); + CHECK(profile.timing.configured_sample_rate == 4800U); + CHECK(profile.timing.asdus_per_frame == 1U); + CHECK(profile.timing.resolved()); + CHECK(profile.timing.samples_per_second.has_value()); + CHECK(profile.timing.samples_per_second->numerator == 4800U); + CHECK(profile.timing.samples_per_second->denominator == 1U); + CHECK(profile.timing.frames_per_second.has_value()); + CHECK(profile.timing.frames_per_second->numerator == 4800U); + CHECK(profile.timing.frames_per_second->denominator == 1U); + CHECK(profile.timing.exact_frame_rate_hz() == std::optional{4800U}); CHECK(profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus); CHECK(profile.sample_counter_modulus == std::optional{ static_cast(4800U)}); @@ -263,6 +273,120 @@ void parser_compiles_structured_4800_sv_profile_without_drift() { CHECK(total_ticks == 1'000'000U); } +void publisher_profile_separates_sampling_from_frame_cadence() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(document.sampled_values_streams.size() == 1U); + const auto source = document.sampled_values_streams.front(); + + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = static_cast(4800U); + + auto two_asdu = source; + two_asdu.no_asdu = 2U; + const auto two_asdu_compiled = SvPublisherProfileCompiler::compile(two_asdu, context); + CHECK(two_asdu_compiled.ok()); + CHECK(two_asdu_compiled.profile.has_value()); + const auto& two_asdu_timing = two_asdu_compiled.profile->timing; + CHECK(two_asdu_timing.configured_sample_rate == 4800U); + CHECK(two_asdu_timing.asdus_per_frame == 2U); + CHECK(two_asdu_timing.exact_sample_rate_hz() == std::optional{4800U}); + CHECK(two_asdu_timing.exact_frame_rate_hz() == std::optional{2400U}); + + auto six_asdu = source; + six_asdu.sample_rate = 14400U; + six_asdu.no_asdu = 6U; + context.sample_counter_modulus = static_cast(14400U); + const auto six_asdu_compiled = SvPublisherProfileCompiler::compile(six_asdu, context); + CHECK(six_asdu_compiled.ok()); + CHECK(six_asdu_compiled.profile.has_value()); + CHECK(six_asdu_compiled.profile->timing.exact_sample_rate_hz() == + std::optional{14400U}); + CHECK(six_asdu_compiled.profile->timing.exact_frame_rate_hz() == + std::optional{2400U}); + + auto per_period = source; + per_period.sample_mode = "SmpPerPeriod"; + per_period.sample_rate = 80U; + per_period.no_asdu = 1U; + context.sample_counter_modulus = static_cast(4000U); + context.nominal_frequency_millihz.reset(); + const auto unresolved = SvPublisherProfileCompiler::compile(per_period, context); + CHECK(unresolved.ok()); + CHECK(unresolved.profile.has_value()); + CHECK(unresolved.profile->timing.resolution == + SvTimingResolution::needs_nominal_frequency); + CHECK(!unresolved.profile->timing.samples_per_second.has_value()); + CHECK(!unresolved.profile->timing.frames_per_second.has_value()); + CHECK(std::any_of( + unresolved.warnings.begin(), unresolved.warnings.end(), + [](const std::string& warning) { + return warning.find("nominal-system-frequency") != std::string::npos; + })); + + auto default_per_period = per_period; + default_per_period.sample_mode.clear(); + const auto default_mode = SvPublisherProfileCompiler::compile(default_per_period, context); + CHECK(default_mode.ok()); + CHECK(default_mode.profile.has_value()); + CHECK(default_mode.profile->timing.sampling_basis == SvSampleMode::samples_per_period); + CHECK(default_mode.profile->timing.resolution == + SvTimingResolution::needs_nominal_frequency); + + context.nominal_frequency_millihz = 50000U; + const auto fifty_hz = SvPublisherProfileCompiler::compile(per_period, context); + CHECK(fifty_hz.ok()); + CHECK(fifty_hz.profile.has_value()); + CHECK(fifty_hz.profile->timing.resolved()); + CHECK(fifty_hz.profile->timing.exact_sample_rate_hz() == + std::optional{4000U}); + CHECK(fifty_hz.profile->timing.exact_frame_rate_hz() == + std::optional{4000U}); + + context.nominal_frequency_millihz = 60000U; + context.sample_counter_modulus = static_cast(4800U); + const auto sixty_hz = SvPublisherProfileCompiler::compile(per_period, context); + CHECK(sixty_hz.ok()); + CHECK(sixty_hz.profile.has_value()); + CHECK(sixty_hz.profile->timing.exact_sample_rate_hz() == + std::optional{4800U}); + CHECK(sixty_hz.profile->timing.exact_frame_rate_hz() == + std::optional{4800U}); + + auto eight_asdu = per_period; + eight_asdu.sample_rate = 256U; + eight_asdu.no_asdu = 8U; + context.nominal_frequency_millihz = 50000U; + context.sample_counter_modulus = static_cast(12800U); + const auto legacy_measurement = SvPublisherProfileCompiler::compile(eight_asdu, context); + CHECK(legacy_measurement.ok()); + CHECK(legacy_measurement.profile.has_value()); + CHECK(legacy_measurement.profile->timing.exact_sample_rate_hz() == + std::optional{12800U}); + CHECK(legacy_measurement.profile->timing.exact_frame_rate_hz() == + std::optional{1600U}); + + auto seconds_per_sample = source; + seconds_per_sample.sample_mode = "SecPerSample"; + seconds_per_sample.sample_rate = 2U; + seconds_per_sample.no_asdu = 1U; + context.nominal_frequency_millihz.reset(); + context.sample_counter_modulus = static_cast(1U); + const auto slow_stream = SvPublisherProfileCompiler::compile(seconds_per_sample, context); + CHECK(slow_stream.ok()); + CHECK(slow_stream.profile.has_value()); + CHECK(slow_stream.profile->timing.sampling_basis == SvSampleMode::seconds_per_sample); + CHECK(slow_stream.profile->timing.samples_per_second.has_value()); + CHECK(slow_stream.profile->timing.samples_per_second->numerator == 1U); + CHECK(slow_stream.profile->timing.samples_per_second->denominator == 2U); + CHECK(slow_stream.profile->timing.frames_per_second.has_value()); + CHECK(slow_stream.profile->timing.frames_per_second->numerator == 1U); + CHECK(slow_stream.profile->timing.frames_per_second->denominator == 2U); + CHECK(!slow_stream.profile->timing.exact_frame_rate_hz().has_value()); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -543,6 +667,7 @@ int main() { {"SCL minimal station", parser_extracts_minimal_station_semantics}, {"SCL multi-stream", parser_extracts_multiple_sampled_values_streams_and_conflicts}, {"SCL structured 4800 SV profile", parser_compiles_structured_4800_sv_profile_without_drift}, + {"SV sampling and frame cadence semantics", publisher_profile_separates_sampling_from_frame_cadence}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL configured control model", parser_preserves_configured_control_model_value}, {"Simulator semantic defaults and indexed RCBs", simulator_compiles_semantic_defaults_and_indexed_report_instances}, diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index 529f63fa7..391890b9d 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -20,6 +20,7 @@ using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; using ar::iec61850::sampled_values::SvPublisherProfileCompiler; using ar::iec61850::sampled_values::SvSampleCounterPolicy; using ar::iec61850::sampled_values::SvSampleMode; +using ar::iec61850::sampled_values::SvTimingResolution; using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; using ar::iec61850::scl::SclDocument; @@ -77,10 +78,24 @@ std::string sample_mode_name(const SvSampleMode mode) { switch (mode) { case SvSampleMode::samples_per_second: return "SmpPerSec"; case SvSampleMode::samples_per_period: return "SmpPerPeriod"; + case SvSampleMode::seconds_per_sample: return "SecPerSample"; default: return "unknown"; } } +std::string timing_resolution_name(const SvTimingResolution resolution) { + switch (resolution) { + case SvTimingResolution::resolved: return "resolved"; + case SvTimingResolution::needs_nominal_frequency: return "needs-nominal-frequency"; + case SvTimingResolution::invalid_sample_rate: return "invalid-sample-rate"; + case SvTimingResolution::invalid_asdu_count: return "invalid-asdu-count"; + case SvTimingResolution::unsupported_sample_mode: return "unsupported-sample-mode"; + case SvTimingResolution::invalid_nominal_frequency: return "invalid-nominal-frequency"; + case SvTimingResolution::arithmetic_overflow: return "arithmetic-overflow"; + } + return "unsupported-sample-mode"; +} + std::string counter_policy_name(const SvSampleCounterPolicy policy) { switch (policy) { case SvSampleCounterPolicy::explicit_modulus: return "explicit"; @@ -111,12 +126,28 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { out << "\"vlanID\":" << p.vlan_id << ','; out << "\"vlanPriority\":" << static_cast(p.vlan_priority) << ','; out << "\"confRev\":" << p.configuration_revision << ','; - out << "\"sampleRate\":" << p.sample_rate_value << ','; - out << "\"sampleMode\":"; quoted(out, sample_mode_name(p.sample_mode)); out << ','; + out << "\"sampleRate\":" << p.timing.configured_sample_rate << ','; + out << "\"sampleMode\":"; quoted(out, sample_mode_name(p.timing.sampling_basis)); out << ','; + out << "\"timingResolution\":"; quoted(out, timing_resolution_name(p.timing.resolution)); out << ','; + out << "\"nominalFrequencyMilliHz\":"; + if (p.timing.nominal_frequency_millihz) out << *p.timing.nominal_frequency_millihz; else out << "null"; + out << ','; + out << "\"sampleRatePerSecondNumerator\":"; + if (p.timing.samples_per_second) out << p.timing.samples_per_second->numerator; else out << "null"; + out << ','; + out << "\"sampleRatePerSecondDenominator\":"; + if (p.timing.samples_per_second) out << p.timing.samples_per_second->denominator; else out << "null"; + out << ','; + out << "\"frameRateNumerator\":"; + if (p.timing.frames_per_second) out << p.timing.frames_per_second->numerator; else out << "null"; + out << ','; + out << "\"frameRateDenominator\":"; + if (p.timing.frames_per_second) out << p.timing.frames_per_second->denominator; else out << "null"; + out << ','; out << "\"publisherRateHz\":"; - if (p.publisher_rate_hz) out << *p.publisher_rate_hz; else out << "null"; + if (const auto rate = p.timing.exact_frame_rate_hz()) out << *rate; else out << "null"; out << ','; - out << "\"nofASDU\":" << p.no_asdu << ','; + out << "\"nofASDU\":" << p.timing.asdus_per_frame << ','; out << "\"counterPolicy\":"; quoted(out, counter_policy_name(p.sample_counter_policy)); out << ','; out << "\"counterModulus\":"; if (p.sample_counter_modulus) out << *p.sample_counter_modulus; else out << "null"; @@ -152,7 +183,7 @@ void emit_document( const SclDocument& document, const std::optional counter_modulus) { out << '{'; - out << "\"schemaVersion\":1,"; + out << "\"schemaVersion\":2,"; out << "\"source\":"; quoted(out, document.source_name); out << ','; out << "\"edition\":"; quoted(out, edition_name(document.edition)); out << ','; out << "\"headerID\":"; quoted(out, document.header_id); out << ','; @@ -180,8 +211,10 @@ void emit_document( std::string device_support{"blocked"}; if (compiled.ok()) { const auto& profile = *compiled.profile; - compatibility = profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus - ? "A" : "B"; + compatibility = + profile.timing.resolved() && + profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus + ? "A" : "B"; device_support = std::string{ esp32p4_sv_profile_support_name(classify_esp32p4_sv_profile(profile))}; } From 213d31235b665556e339af82c147daa1ab4100b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 20:02:37 +0700 Subject: [PATCH 2/3] P1: expose explicit SmpPerPeriod timing context Allow Studio and the inspector CLI to supply nominal system frequency explicitly, keep counter confirmation independent, surface sample/frame cadence separately in the UI, and add explicit sampleRateHz/frameRateHz outputs while preserving publisherRate compatibility aliases. Tracks #139. --- apps/arstack_studio/qml/ProfileInspector.qml | 135 +++++++++++++++---- apps/arstack_studio/src/SclProfileModel.cpp | 25 ++++ apps/arstack_studio/src/SclProfileModel.hpp | 3 + tools/smv_profile_inspect.cpp | 32 ++++- 4 files changed, 167 insertions(+), 28 deletions(-) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 9785fd14e..0bb8a11de 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -190,8 +190,24 @@ SurfacePanel { Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideRight } - Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } - Label { text: panel.profile.publisherRate ? panel.profile.publisherRate + " fps" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + Label { text: "Sampling"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + text: panel.profile.sampleRate + ? panel.profile.sampleRate + " · " + (panel.profile.sampleMode || "—") + : "—" + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } + Label { text: "Frames"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + text: panel.profile.frameRateHz + ? panel.profile.frameRateHz + " fps · " + panel.profile.nofASDU + " ASDU/frame" + : "unresolved · " + (panel.profile.nofASDU || "—") + " ASDU/frame" + color: panel.profile.frameRateHz ? panel.theme.textSoft : panel.theme.amber + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } Label { text: "VLAN"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { text: panel.profile.vlanPresent ? "PCP " + panel.profile.vlanPriority + " · VID " + panel.profile.vlanId : "untagged"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } Label { text: "Payload"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } @@ -223,37 +239,106 @@ SurfacePanel { ColumnLayout { visible: panel.profile.compatibilityClass === "B" Layout.fillWidth: true - spacing: 5 - Label { text: "COUNTER POLICY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + spacing: 7 + Label { - Layout.fillWidth: true - text: "Confirm the evidenced smpCnt modulus before deployment." + text: "PROFILE CONTEXT" color: panel.theme.muted font.family: panel.uiFont font.pixelSize: panel.theme.captionSize - wrapMode: Text.WordWrap + font.weight: Font.DemiBold + font.letterSpacing: 0.9 } - RowLayout { + + ColumnLayout { + visible: panel.profile.timingResolved === false + && panel.profile.sampleMode === "SmpPerPeriod" Layout.fillWidth: true - NumericField { - id: counterField + spacing: 5 + Label { Layout.fillWidth: true - enabled: panel.session && panel.session.engineeringEditable - theme: panel.theme - monoFont: panel.monoFont - compact: true - text: panel.profile.counterModulus || "" - validator: IntValidator { bottom: 1; top: 65535 } + text: "SmpPerPeriod needs the nominal system frequency before sample and frame cadence can be resolved." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: "Confirm" - enabled: panel.session && panel.session.engineeringEditable - onClicked: { - if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { - panel.controller.profileDirty = true - panel.controller.showMessage("Sample-counter modulus confirmed.", false) + RowLayout { + Layout.fillWidth: true + NumericField { + id: timingFrequencyField + Layout.fillWidth: true + enabled: panel.session && panel.session.engineeringEditable + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.profile.nominalFrequencyMilliHz + ? String(Number(panel.profile.nominalFrequencyMilliHz) / 1000) : "" + validator: IntValidator { bottom: 1; top: 1000 } + } + Label { + text: "Hz" + color: panel.theme.muted + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply" + enabled: panel.session && panel.session.engineeringEditable + onClicked: { + if (timingFrequencyField.acceptableInput + && panel.profiles.confirmNominalFrequencyHz(parseInt(timingFrequencyField.text))) { + panel.controller.profileDirty = true + panel.controller.showMessage("Nominal system frequency applied.", false) + } + } + } + } + } + + ColumnLayout { + visible: panel.profile.counterPolicy !== "explicit" + Layout.fillWidth: true + spacing: 5 + Label { + text: "COUNTER POLICY" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "Confirm the evidenced smpCnt modulus before deployment." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + NumericField { + id: counterField + Layout.fillWidth: true + enabled: panel.session && panel.session.engineeringEditable + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.profile.counterModulus || "" + validator: IntValidator { bottom: 1; top: 65535 } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Confirm" + enabled: panel.session && panel.session.engineeringEditable + onClicked: { + if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { + panel.controller.profileDirty = true + panel.controller.showMessage("Sample-counter modulus confirmed.", false) + } } } } diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index b306bf919..3cdfce097 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -192,6 +192,7 @@ void SclProfileModel::installDocument( confirmedCounterModulus_ = referenceTemplate ? std::optional{kReferenceCounterModulus} : std::nullopt; + confirmedNominalFrequencyMilliHz_.reset(); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -252,6 +253,7 @@ void SclProfileModel::clear() { beginResetModel(); document_.reset(); confirmedCounterModulus_.reset(); + confirmedNominalFrequencyMilliHz_.reset(); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -284,6 +286,19 @@ void SclProfileModel::clearCounterConfirmation() { rebuildRows(); } +bool SclProfileModel::confirmNominalFrequencyHz(const int frequencyHz) { + if (!document_.has_value() || frequencyHz <= 0 || frequencyHz > 1000) return false; + confirmedNominalFrequencyMilliHz_ = static_cast(frequencyHz) * 1000U; + rebuildRows(); + return true; +} + +void SclProfileModel::clearNominalFrequency() { + if (!confirmedNominalFrequencyMilliHz_.has_value()) return; + confirmedNominalFrequencyMilliHz_.reset(); + rebuildRows(); +} + void SclProfileModel::rebuildRows() { if (!document_.has_value()) return; @@ -295,6 +310,7 @@ void SclProfileModel::rebuildRows() { for (const auto& stream : document_->sampled_values_streams) { SvPublisherProfileCompileContext context; context.sample_counter_modulus = confirmedCounterModulus_; + context.nominal_frequency_millihz = confirmedNominalFrequencyMilliHz_; auto compiled = SvPublisherProfileCompiler::compile(stream, context); Row row; @@ -367,7 +383,16 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co p.timing.frames_per_second.has_value() ? QVariant::fromValue(static_cast(p.timing.frames_per_second->denominator)) : QVariant{}); + const auto sampleRateHz = p.timing.exact_sample_rate_hz(); const auto frameRate = p.timing.exact_frame_rate_hz(); + map.insert(QStringLiteral("sampleRateHz"), + sampleRateHz.has_value() + ? QVariant::fromValue(static_cast(*sampleRateHz)) + : QVariant{}); + map.insert(QStringLiteral("frameRateHz"), + frameRate.has_value() + ? QVariant::fromValue(static_cast(*frameRate)) + : QVariant{}); map.insert(QStringLiteral("publisherRate"), frameRate.has_value() ? QVariant::fromValue(static_cast(*frameRate)) diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 52f1050a2..0971692e8 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -62,6 +62,8 @@ class SclProfileModel : public QAbstractListModel { Q_INVOKABLE void selectStream(int row); Q_INVOKABLE bool confirmCounterModulus(int modulus); Q_INVOKABLE void clearCounterConfirmation(); + Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz); + Q_INVOKABLE void clearNominalFrequency(); signals: void sourceChanged(); @@ -88,6 +90,7 @@ class SclProfileModel : public QAbstractListModel { std::optional document_; std::optional confirmedCounterModulus_; + std::optional confirmedNominalFrequencyMilliHz_; std::vector rows_; int selectedIndex_{-1}; QString fatalError_; diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index 391890b9d..641ad08f6 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -144,6 +144,12 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { out << "\"frameRateDenominator\":"; if (p.timing.frames_per_second) out << p.timing.frames_per_second->denominator; else out << "null"; out << ','; + out << "\"sampleRateHz\":"; + if (const auto rate = p.timing.exact_sample_rate_hz()) out << *rate; else out << "null"; + out << ','; + out << "\"frameRateHz\":"; + if (const auto rate = p.timing.exact_frame_rate_hz()) out << *rate; else out << "null"; + out << ','; out << "\"publisherRateHz\":"; if (const auto rate = p.timing.exact_frame_rate_hz()) out << *rate; else out << "null"; out << ','; @@ -181,7 +187,8 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { void emit_document( std::ostream& out, const SclDocument& document, - const std::optional counter_modulus) { + const std::optional counter_modulus, + const std::optional nominal_frequency_millihz) { out << '{'; out << "\"schemaVersion\":2,"; out << "\"source\":"; quoted(out, document.source_name); out << ','; @@ -205,6 +212,7 @@ void emit_document( const auto& stream = document.sampled_values_streams[index]; SvPublisherProfileCompileContext context; context.sample_counter_modulus = counter_modulus; + context.nominal_frequency_millihz = nominal_frequency_millihz; const auto compiled = SvPublisherProfileCompiler::compile(stream, context); std::string compatibility{"C"}; @@ -235,6 +243,16 @@ void emit_document( out << "]}"; } +std::optional parse_u32_nonzero(const std::string_view text) { + try { + const auto value = std::stoull(std::string{text}); + if (value == 0U || value > std::numeric_limits::max()) return std::nullopt; + return static_cast(value); + } catch (...) { + return std::nullopt; + } +} + std::optional parse_u16(const std::string_view text) { try { const auto value = std::stoul(std::string{text}); @@ -248,11 +266,12 @@ std::optional parse_u16(const std::string_view text) { int main(int argc, char** argv) { if (argc < 2) { - std::cerr << "usage: ariec61850_smv_profile_inspect [--counter-modulus N]\n"; + std::cerr << "usage: ariec61850_smv_profile_inspect [--counter-modulus N] [--nominal-frequency-hz N]\n"; return 2; } std::optional counter_modulus; + std::optional nominal_frequency_millihz; for (int i = 2; i < argc; ++i) { if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { counter_modulus = parse_u16(argv[++i]); @@ -260,6 +279,13 @@ int main(int argc, char** argv) { std::cerr << "invalid counter modulus\n"; return 2; } + } else if (std::string_view{argv[i]} == "--nominal-frequency-hz" && i + 1 < argc) { + const auto frequency_hz = parse_u32_nonzero(argv[++i]); + if (!frequency_hz || *frequency_hz > std::numeric_limits::max() / 1000U) { + std::cerr << "invalid nominal frequency\n"; + return 2; + } + nominal_frequency_millihz = *frequency_hz * 1000U; } else { std::cerr << "unknown argument\n"; return 2; @@ -269,7 +295,7 @@ int main(int argc, char** argv) { try { const auto document = ar::iec61850::scl::SclParser{}.load( std::filesystem::path{argv[1]}); - emit_document(std::cout, document, counter_modulus); + emit_document(std::cout, document, counter_modulus, nominal_frequency_millihz); std::cout << '\n'; return 0; } catch (const std::exception& error) { From 175de44078978f808f101295fc52a72d1c2b2963 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 20:47:33 +0700 Subject: [PATCH 3/3] P1 review: harden SCL sampling-mode compatibility Accept both current SecPerSample and legacy SecPerSmp engineering tokens as the same seconds-per-sample semantic, add regression coverage, and keep CLI fatal-error JSON on schema v2. Tracks #139. --- .../ariec61850/sampled_values/publisher_profile.hpp | 5 ++++- tests/test_scl.cpp | 11 +++++++++++ tools/smv_profile_inspect.cpp | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp index 00a08a63b..1f635ee6d 100644 --- a/include/ariec61850/sampled_values/publisher_profile.hpp +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -252,7 +252,10 @@ class SvPublisherProfileCompiler final { if (normalized == "smppersec") { return SvSampleMode::samples_per_second; } - if (normalized == "secpersample") { + // IEC 61850-6:2024 uses SecPerSample. Older SCL schema bindings and + // deployed engineering tools also expose the legacy token SecPerSmp; + // both carry the same sampling semantics and are accepted deliberately. + if (normalized == "secpersample" || normalized == "secpersmp") { return SvSampleMode::seconds_per_sample; } return SvSampleMode::unknown; diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index fa4f4cb1f..9fe7bef64 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -385,6 +385,17 @@ void publisher_profile_separates_sampling_from_frame_cadence() { CHECK(slow_stream.profile->timing.frames_per_second->numerator == 1U); CHECK(slow_stream.profile->timing.frames_per_second->denominator == 2U); CHECK(!slow_stream.profile->timing.exact_frame_rate_hz().has_value()); + + auto legacy_seconds_per_sample = seconds_per_sample; + legacy_seconds_per_sample.sample_mode = "SecPerSmp"; + const auto legacy_slow_stream = + SvPublisherProfileCompiler::compile(legacy_seconds_per_sample, context); + CHECK(legacy_slow_stream.ok()); + CHECK(legacy_slow_stream.profile.has_value()); + CHECK(legacy_slow_stream.profile->timing.sampling_basis == + SvSampleMode::seconds_per_sample); + CHECK(legacy_slow_stream.profile->timing.samples_per_second == + slow_stream.profile->timing.samples_per_second); } void dataset_reference_resolver_accepts_canonical_and_local_forms() { diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index 641ad08f6..bc84abd97 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -299,7 +299,7 @@ int main(int argc, char** argv) { std::cout << '\n'; return 0; } catch (const std::exception& error) { - std::cout << "{\"schemaVersion\":1,\"fatalError\":"; + std::cout << "{\"schemaVersion\":2,\"fatalError\":"; quoted(std::cout, error.what()); std::cout << "}\n"; return 1;