diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md new file mode 100644 index 000000000..9d8220261 --- /dev/null +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -0,0 +1,226 @@ +# Sampled Values Interoperability Roadmap + +ARStack61850 should simulate and analyze Sampled Values streams from standards-described merging-unit configurations **without vendor-specific code paths**. + +The interoperability target is not a list of brands. The target is a growing, testable set of IEC 61850 / instrument-transformer profile semantics that can be compiled from engineering intent and verified against observed wire behavior. + +## North-star behavior + +```text +SCL / CID / SCD / IID optional lab capture + | | + v v +secure SCL parser observed wire fingerprint + | | + v | +DataSet + type resolver | + | | + +-- explicit FCDA leaf | + | | + +-- structured FCDA -> ordered leaf | + | | + +------------------+-------------------+ + v + profile/evidence resolver + | + v + SvPublisherProfile compiler + | + +-- L2 address / VLAN / APPID + +-- svID / confRev / nofASDU + +-- sample-rate and sample-mode semantics + +-- SmvOpts field-presence policy + +-- ordered wire leaf layout + +-- validated sample-counter policy + +-- synchronization policy inputs + | + v + versioned binary device profile + | + v + RTOS deterministic publisher + | + v + hardware TX timestamp evidence +``` + +XML parsing, string resolution, type-template traversal and capture fingerprinting belong on the host. The embedded realtime hot path receives an already validated, immutable profile and prebuilt packet layout. + +## Three truth domains + +ARStack must never collapse these into one source of truth: + +1. **Configured intent** — what SCL/CID/SCD/IID says should exist. +2. **Observed wire facts** — what an independent capture actually contains: field presence, identity, counter progression, cadence and other measurable behavior. +3. **Profile/standard rules** — semantics required by the applicable standards/profile family. + +A conflict is diagnostic evidence, not permission to silently overwrite one domain with another. The engineer or an explicit profile rule decides which behavior is deployed. + +This separation is essential for broad interoperability because an engineering file may not encode every runtime behavior, and a capture may reflect a different configuration revision than the available engineering file. + +## Tolerance contract: forgiving representation, strict semantics + +Real vendor SCL/CID files often carry different namespace prefixes, `Private` blocks, foreign-namespace metadata, lexical formatting and tool-specific ordering around the same IEC 61850 engineering intent. ARStack should understand those differences without weakening the semantic gates that protect deterministic publishing. + +The compatibility engine therefore follows this rule: + +```text +malformed / unsafe XML -> REJECT +well-formed SCL + unfamiliar metadata -> TOLERATE + QUARANTINE/DIAGNOSE +known semantic equivalent -> NORMALIZE + PROVENANCE +unknown mandatory semantic -> MISSING_CONTEXT / UNSUPPORTED +fully resolved runtime profile -> CLASS A / DEPLOYABLE +``` + +Tolerance applies only where equivalence can be established safely. The engine may normalize namespace prefixes, standard lexical equivalents, harmless whitespace, equivalent L2 text representations and known edition/profile differences. It must not make XML element names arbitrarily case-insensitive, invent missing references, infer unknown payload layouts or promote synchronization state merely to make a file parse. + +Vendor extensions are quarantined from the canonical model unless an explicit, validated adapter maps them to a known semantic. Unknown extension data is bounded, non-executable metadata with path/namespace/type provenance; it cannot silently override core IEC 61850 values. + +Each normalized field should eventually carry explainable provenance such as: + +- `exact_scl`; +- `standard_equivalent`; +- `tolerated_lexical`; +- `profile_rule`; +- `vendor_extension`; +- `observed_wire`; +- `user_confirmed`; +- `unresolved`. + +The practical product rule is: **parse broadly, explain normalization, deploy narrowly**. A CID may be accepted for inspection even when some semantics remain unresolved; the same profile is not device-deployable until every mandatory runtime field has passed the Class-A gates. + +## Interoperability rules + +1. **SCL is configured intent.** Do not replace SCL values with convenient defaults when the configuration provides an explicit value. +2. **Resolved type order is wire order.** FCDA order is preserved. When an FCDA points at a structured data object without `daName`, ARStack expands the matching functional-constraint leaves in `DataTypeTemplates` order. +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. + +## Compatibility classes + +### Class A — deterministic publishable + +A stream can be compiled completely: + +- destination MAC and APPID resolved; +- VLAN binding either complete or absent; +- svID resolved; +- sample rate/mode understood; +- nofASDU understood; +- DataSet binding resolved; +- every transmitted leaf has a deterministic supported wire width; +- counter policy is validated, not merely guessed from rate; +- optional-field policy is known; +- any configured-vs-observed conflict required for deployment is resolved explicitly. + +Class A profiles can proceed to the device compiler/runtime. + +### Class B — valid configuration needing runtime/profile context + +The stream is structurally understood but deployment needs additional context. Examples include: + +- samples-per-period mode without nominal system frequency; +- an unresolved sample-counter wrap policy; +- a capture-derived runtime behavior that conflicts with an older engineering file; +- a profile family whose optional-field semantics require an explicit selection. + +Class B is not guessed. The host UI/CLI must request, resolve or validate the missing context explicitly before deployment. + +### Class C — unsupported or ambiguous + +Any unresolved type/layout, ambiguous binding, unknown mandatory profile rule or inconsistent configuration is rejected with a diagnostic. Class C streams are interoperability backlog items, not silent fallbacks. + +## P3-A0 — normalized host profile foundation + +Current goals: + +- preserve `SmvOpts` in the SCL model; +- expand structured FCDA definitions to ordered leaf entries; +- compile `SclSampledValuesStream` into `SvPublisherProfile`; +- derive publisher rate from supported sampling semantics without hard-coding 4000; +- keep sample-counter policy explicit and independently validated; +- provide drift-free rational timer scheduling; +- inspect arbitrary SCL/CID/SCD/IID files with a read-only CLI; +- maintain anonymous fixtures for 4000 and 4800 samples/s families; +- add bounded SCL dialect normalization and vendor-extension quarantine tracked by #40. + +The initial fixed-width compiler intentionally supports only leaf types whose wire size is explicit in ARStack. New types should be added from standards/profile evidence with regression tests rather than broad assumptions. + +## P3-A1 — compiled device profile + +Next: + +- define a versioned binary schema; +- include schema version, total length and integrity check; +- serialize only resolved runtime values, never XML; +- require a validated counter policy before a profile is device-deployable; +- validate on host and device; +- activate profiles atomically; +- prebuild packet templates and patch tables before realtime start; +- move destination MAC, APPID, VLAN, svID, confRev, nofASDU, sample rate, validated counter modulus and dataset layout out of embedded hard-coded constants; +- integrate `RationalTickSchedule` with the ESP32-P4 timer path for non-integer-microsecond rates. + +## P3-A2 — observed fingerprint + interoperability matrix + +Add a read-only capture fingerprint layer that can measure, per stream: + +- destination/source MAC and VLAN visibility; +- APPID and ASDU field presence; +- svID / confRev / nofASDU; +- sample-counter progression, wrap candidates, duplicates and gaps; +- observed frame cadence and rate distribution; +- payload length/layout consistency; +- synchronization values as **observed fields**, not synchronization proof. + +The fingerprint augments SCL; it never silently replaces SCL. Conflicts are surfaced as configured-vs-observed diagnostics. + +Grow coverage by anonymous standards/profile families: + +| Capability | Minimum regression evidence | +|---|---| +| 4000 samples/s | exact 250 us nominal schedule + validated counter policy | +| 4800 samples/s | drift-free rational schedule + validated counter policy | +| Explicit FCDA leaves | ordered primitive + quality layout | +| Structured FCDA | recursive type expansion filtered by FC | +| Optional ASDU fields | each supported `SmvOpts` combination encoded intentionally | +| Address variants | APPID, multicast MAC, tagged/untagged VLAN handling | +| Multiple streams | independent identity, counter and timing state | +| nofASDU variants | deterministic frame construction and counter semantics | +| Additional fixed leaf types | standard-derived width + golden-wire tests | +| Config vs capture conflict | explicit diagnostic, no silent override | +| Vendor metadata / dialect variance | equivalent canonical profile + normalization provenance | + +A profile family enters the supported matrix only when parser/profile tests, encoded-wire tests and physical interoperability evidence agree. + +## Relation to hardware timing work + +Profile correctness and timing correctness are separate gates that converge at the embedded publisher: + +```text +configured/profile truth timing truth +SCL -> SvPublisherProfile timer deadline + | | + +-----------+------------------+ + v + deterministic TX + | + v + EMAC HW timestamp + | + v + evidence report +``` + +A stream can be byte-correct but badly timed, or well timed but configured incorrectly. ARStack must be able to distinguish those failure modes. + +## Definition of success + +The injector should eventually let an engineer select a standards-described SV stream, validate it, optionally compare it with a lab capture fingerprint, bind signal values/scenarios, deploy it to dedicated hardware, and receive deterministic timing evidence without manually editing packet bytes or creating device-specific firmware builds. + +The analyzer should consume the same normalized profile/evidence model for expected-vs-observed checks, so injector and analyzer share one interpretation of stream identity, dataset order, validated counter policy, optional fields and timing expectations. \ No newline at end of file diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp new file mode 100644 index 000000000..f95597d6b --- /dev/null +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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, + explicit_modulus, +}; + +struct SvAsduOptions final { + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SvAsduOptions&, const SvAsduOptions&) = default; +}; + +struct SvPublisherChannel final { + std::size_t index{}; + std::string signal_reference; + std::string cdc; + std::string basic_type; + bool is_quality{}; + bool is_timestamp{}; + std::uint16_t wire_width_bytes{}; + + friend bool operator==(const SvPublisherChannel&, const SvPublisherChannel&) = default; +}; + +struct SvPublisherProfileCompileContext final { + // SCL describes sampling semantics but does not universally establish + // every runtime sample-counter wrap policy. A standards/profile rule or + // independently observed evidence may validate a modulus explicitly. + std::optional sample_counter_modulus; +}; + +struct SvPublisherProfile final { + std::uint32_t schema_version{1U}; + std::string control_block_reference; + std::string sv_id; + std::string data_set_reference; + + std::array destination_mac{}; + std::uint16_t app_id{}; + bool vlan_present{}; + std::uint16_t vlan_id{}; + 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}; + SvSampleCounterPolicy sample_counter_policy{SvSampleCounterPolicy::unresolved}; + std::optional sample_counter_modulus; + SvAsduOptions asdu_options; + + std::vector channels; + std::size_t payload_size_bytes{}; + + friend bool operator==(const SvPublisherProfile&, const SvPublisherProfile&) = default; +}; + +struct SvPublisherProfileCompileResult final { + std::optional profile; + std::vector errors; + std::vector warnings; + + [[nodiscard]] bool ok() const noexcept { + return profile.has_value() && errors.empty(); + } +}; + +class SvPublisherProfileCompiler final { +public: + [[nodiscard]] static SvPublisherProfileCompileResult compile( + const scl::SclSampledValuesStream& stream, + const SvPublisherProfileCompileContext& context = {}) { + SvPublisherProfileCompileResult result; + SvPublisherProfile profile; + + profile.control_block_reference = stream.control_block_reference; + 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.asdu_options = compile_options(stream.smv_options); + + if (!stream.address.destination_mac.has_value()) { + result.errors.push_back("SV stream has no valid destination MAC address."); + } else { + profile.destination_mac = *stream.address.destination_mac; + } + if (!stream.address.app_id.has_value()) { + result.errors.push_back("SV stream has no valid APPID."); + } else { + profile.app_id = *stream.address.app_id; + } + + const bool has_vlan_id = stream.address.vlan_id.has_value(); + const bool has_vlan_priority = stream.address.vlan_priority.has_value(); + if (has_vlan_id != has_vlan_priority) { + result.errors.push_back( + "SV stream has an incomplete VLAN binding; VLAN ID and priority must either both be present or both be absent."); + } else if (has_vlan_id) { + profile.vlan_present = true; + profile.vlan_id = *stream.address.vlan_id; + profile.vlan_priority = *stream.address.vlan_priority; + if (profile.vlan_id > 4095U) { + result.errors.push_back("SV VLAN ID exceeds the 12-bit Ethernet VLAN range."); + } + if (profile.vlan_priority > 7U) { + result.errors.push_back("SV VLAN priority exceeds the 3-bit Ethernet PCP range."); + } + } + + 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) { + result.warnings.push_back( + "SmpPerPeriod requires a nominal-system-frequency input before an absolute publisher rate can be scheduled."); + } else { + result.errors.push_back("SV sample mode is missing or unsupported."); + } + + if (context.sample_counter_modulus.has_value()) { + if (*context.sample_counter_modulus == 0U) { + result.errors.push_back("SV sample-counter modulus must be greater than zero."); + } else { + profile.sample_counter_policy = SvSampleCounterPolicy::explicit_modulus; + 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()) { + // 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 + // counter policy from a profile rule or observed evidence first. + profile.sample_counter_policy = + SvSampleCounterPolicy::candidate_sample_rate_modulus; + profile.sample_counter_modulus = + static_cast(profile.sample_rate_value); + 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 { + result.warnings.push_back( + "SV sample-counter wrap policy is unresolved; supply a validated profile rule or observed-evidence modulus before deployment."); + } + + if (stream.entries.empty()) { + result.errors.push_back("SV DataSet is empty or unresolved."); + } + + std::size_t payload_size{}; + for (const auto& entry : stream.entries) { + const auto width = wire_width(entry); + if (!width.has_value()) { + result.errors.push_back( + "Unsupported or unresolved SV leaf type for " + entry.signal_reference + + ": '" + entry.basic_type + "'."); + continue; + } + if (payload_size > std::numeric_limits::max() - *width) { + result.errors.push_back("SV payload size overflow."); + break; + } + payload_size += *width; + profile.channels.push_back({ + entry.index, + entry.signal_reference, + entry.cdc, + entry.basic_type, + entry.is_quality, + entry.is_timestamp, + *width, + }); + } + profile.payload_size_bytes = payload_size; + + if (result.errors.empty()) { + result.profile = std::move(profile); + } + return result; + } + +private: + [[nodiscard]] static std::string lower_copy(const std::string_view text) { + std::string result; + result.reserve(text.size()); + for (const char ch : text) { + result.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + return result; + } + + [[nodiscard]] static SvSampleMode parse_sample_mode(const std::string_view text) { + const auto normalized = lower_copy(text); + if (normalized == "smppersec") { + return SvSampleMode::samples_per_second; + } + if (normalized == "smpperperiod") { + return SvSampleMode::samples_per_period; + } + return SvSampleMode::unknown; + } + + [[nodiscard]] static std::optional wire_width( + const scl::SclDataSetEntry& entry) { + if (entry.is_quality || lower_copy(entry.basic_type) == "quality") { + return static_cast(4U); + } + if (entry.is_timestamp || lower_copy(entry.basic_type) == "timestamp") { + return static_cast(8U); + } + + const auto type = lower_copy(entry.basic_type); + if (type == "boolean" || type == "int8" || type == "int8u") { + return static_cast(1U); + } + if (type == "int16" || type == "int16u") { + return static_cast(2U); + } + if (type == "int32" || type == "int32u" || type == "float32") { + return static_cast(4U); + } + if (type == "int64" || type == "int64u" || type == "float64") { + return static_cast(8U); + } + return std::nullopt; + } + + [[nodiscard]] static SvAsduOptions compile_options( + const scl::SclSmvOptions& options) { + return { + options.element_present, + options.refresh_time, + options.sample_synchronized, + options.sample_rate, + options.data_set, + options.security, + options.synch_source_id, + }; + } +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/rational_schedule.hpp b/include/ariec61850/sampled_values/rational_schedule.hpp new file mode 100644 index 000000000..aa31610cc --- /dev/null +++ b/include/ariec61850/sampled_values/rational_schedule.hpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +class RationalTickSchedule final { +public: + RationalTickSchedule( + const std::uint64_t timer_frequency_hz, + const std::uint32_t event_rate_hz) + : timer_frequency_hz_(timer_frequency_hz), + event_rate_hz_(event_rate_hz) { + if (timer_frequency_hz_ == 0U || event_rate_hz_ == 0U) { + throw std::invalid_argument( + "Timer frequency and event rate must both be non-zero."); + } + if (timer_frequency_hz_ < event_rate_hz_) { + throw std::invalid_argument( + "Timer frequency must be at least as high as the event rate."); + } + + base_interval_ticks_ = timer_frequency_hz_ / event_rate_hz_; + remainder_ticks_numerator_ = static_cast( + timer_frequency_hz_ % event_rate_hz_); + } + + [[nodiscard]] std::uint64_t next_interval_ticks() noexcept { + auto interval = base_interval_ticks_; + const auto accumulated = + static_cast(remainder_accumulator_) + + remainder_ticks_numerator_; + if (accumulated >= event_rate_hz_) { + ++interval; + remainder_accumulator_ = static_cast( + accumulated - event_rate_hz_); + } else { + remainder_accumulator_ = static_cast(accumulated); + } + return interval; + } + + void reset() noexcept { + remainder_accumulator_ = 0U; + } + + [[nodiscard]] std::uint64_t timer_frequency_hz() const noexcept { + return timer_frequency_hz_; + } + [[nodiscard]] std::uint32_t event_rate_hz() const noexcept { + return event_rate_hz_; + } + [[nodiscard]] std::uint64_t base_interval_ticks() const noexcept { + return base_interval_ticks_; + } + [[nodiscard]] std::uint32_t remainder_ticks_numerator() const noexcept { + return remainder_ticks_numerator_; + } + +private: + std::uint64_t timer_frequency_hz_{}; + std::uint32_t event_rate_hz_{}; + std::uint64_t base_interval_ticks_{}; + std::uint32_t remainder_ticks_numerator_{}; + std::uint32_t remainder_accumulator_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index 5b7d3a8ce..b1ecdd523 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -94,12 +94,28 @@ struct SclGooseStream final : SclProcessBusStream { friend bool operator==(const SclGooseStream&, const SclGooseStream&) = default; }; +struct SclSmvOptions final { + // Preserve both element presence and the normalized boolean attributes. + // This lets later profile compilation distinguish an omitted SmvOpts + // element from an explicitly present element whose options are all false. + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SclSmvOptions&, const SclSmvOptions&) = default; +}; + struct SclSampledValuesStream final : SclProcessBusStream { std::string sv_id; std::string smv_id; std::uint16_t sample_rate{}; std::string sample_mode; std::uint16_t no_asdu{1U}; + SclSmvOptions smv_options; friend bool operator==(const SclSampledValuesStream&, const SclSampledValuesStream&) = default; }; diff --git a/src/scl/parser_part_02.inc b/src/scl/parser_part_02.inc index a334881ef..7c3e3875a 100644 --- a/src/scl/parser_part_02.inc +++ b/src/scl/parser_part_02.inc @@ -203,6 +203,11 @@ public: std::string enum_type; }; + struct ExpandedAttribute final { + std::string da_name; + Resolution resolution; + }; + explicit SclTypeIndex(const XmlNode& root) { for (const auto* ied : direct_children(root, "IED")) { add_first(ieds_, attribute(ied, "name"), ied); @@ -223,3 +228,149 @@ public: } } } + + [[nodiscard]] std::vector expand_data_object( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst, + const std::string& do_name, + const std::string& functional_constraint) const { + std::vector expanded; + const auto* logical_node = find_logical_node( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst); + if (logical_node == nullptr) { + return expanded; + } + + const auto* logical_node_type = find( + logical_node_types_, attribute(logical_node, "lnType")); + const auto do_segments = split_name(do_name); + if (logical_node_type == nullptr || do_segments.empty()) { + return expanded; + } + + const XmlNode* data_object{}; + for (const auto* candidate : direct_children(*logical_node_type, "DO")) { + if (same(attribute(candidate, "name"), do_segments.front())) { + data_object = candidate; + break; + } + } + if (data_object == nullptr) { + return expanded; + } + + auto current_type_id = attribute(data_object, "type"); + const XmlNode* data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return expanded; + } + + for (std::size_t index = 1U; index < do_segments.size(); ++index) { + const XmlNode* structured_data_object{}; + for (const auto* candidate : direct_children(*data_object_type, "SDO")) { + if (same(attribute(candidate, "name"), do_segments[index])) { + structured_data_object = candidate; + break; + } + } + if (structured_data_object == nullptr) { + return {}; + } + current_type_id = attribute(structured_data_object, "type"); + data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return {}; + } + } + + const auto cdc = attribute(data_object_type, "cdc"); + const auto append_leaf = [&expanded, &cdc]( + const std::string& name, + const std::string& fc, + const XmlNode& node, + const bool resolved) { + const auto basic_type = attribute(&node, "bType"); + const auto type_id = attribute(&node, "type"); + std::string enum_type; + if (same(basic_type, "Enum")) { + enum_type = type_id; + } + expanded.push_back({ + name, + {resolved, fc, cdc, basic_type, type_id, enum_type}, + }); + }; + + const auto append_bdas = [this, &expanded, &cdc]( + auto&& self, + const XmlNode& container, + const std::string& path, + const std::string& fc) -> void { + for (const auto* bda : direct_children(container, "BDA")) { + const auto name = attribute(bda, "name"); + if (name.empty()) { + continue; + } + const auto child_path = path.empty() ? name : path + "." + name; + const auto basic_type = attribute(bda, "bType"); + const auto type_id = attribute(bda, "type"); + if (same(basic_type, "Struct")) { + const auto* nested = find(data_attribute_types_, type_id); + if (nested == nullptr) { + expanded.push_back({ + child_path, + {false, fc, cdc, basic_type, type_id, {}}, + }); + } else { + self(self, *nested, child_path, fc); + } + continue; + } + std::string enum_type; + if (same(basic_type, "Enum") && + enum_type_ids_.contains(normalized_key(type_id))) { + enum_type = type_id; + } + expanded.push_back({ + child_path, + {true, fc, cdc, basic_type, type_id, enum_type}, + }); + } + }; + + for (const auto* data_attribute : direct_children(*data_object_type, "DA")) { + const auto attribute_fc = attribute(data_attribute, "fc"); + if (!functional_constraint.empty() && + !same(attribute_fc, functional_constraint)) { + continue; + } + const auto resolved_fc = functional_constraint.empty() + ? attribute_fc + : functional_constraint; + const auto name = attribute(data_attribute, "name"); + if (name.empty()) { + continue; + } + const auto basic_type = attribute(data_attribute, "bType"); + if (same(basic_type, "Struct")) { + const auto type_id = attribute(data_attribute, "type"); + const auto* attribute_type = find(data_attribute_types_, type_id); + if (attribute_type == nullptr) { + append_leaf(name, resolved_fc, *data_attribute, false); + } else { + append_bdas(append_bdas, *attribute_type, name, resolved_fc); + } + continue; + } + append_leaf(name, resolved_fc, *data_attribute, true); + } + + return expanded; + } \ No newline at end of file diff --git a/src/scl/parser_part_04.inc b/src/scl/parser_part_04.inc index 423bf94dc..27da4a265 100644 --- a/src/scl/parser_part_04.inc +++ b/src/scl/parser_part_04.inc @@ -25,6 +25,102 @@ }; } +std::vector build_data_set_entries( + const std::string& fallback_ied_name, + const XmlNode& fcda, + const std::size_t first_index, + const SclTypeIndex& type_index, + std::vector& warnings) { + const auto explicit_da_name = attribute(&fcda, "daName"); + if (!explicit_da_name.empty()) { + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + auto ied_name = attribute(&fcda, "iedName"); + if (ied_name.empty()) { + ied_name = fallback_ied_name; + } + const auto ld_inst = attribute(&fcda, "ldInst"); + const auto prefix = attribute(&fcda, "prefix"); + const auto ln_class = attribute(&fcda, "lnClass"); + const auto ln_inst = attribute(&fcda, "lnInst"); + const auto do_name = attribute(&fcda, "doName"); + const auto functional_constraint = attribute(&fcda, "fc"); + + const auto expanded = type_index.expand_data_object( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + functional_constraint); + if (expanded.empty()) { + const auto reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + {}, + functional_constraint); + warnings.push_back( + "Unable to expand structured FCDA " + reference + + "; preserving the unresolved object entry."); + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + std::vector result; + result.reserve(expanded.size()); + auto index = first_index; + for (const auto& leaf : expanded) { + const auto signal_reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint); + if (!leaf.resolution.resolved) { + warnings.push_back("Type unresolved for " + signal_reference + "."); + } + result.push_back({ + index++, + signal_reference, + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint.empty() + ? leaf.resolution.functional_constraint + : functional_constraint, + leaf.resolution.cdc, + leaf.resolution.basic_type, + leaf.resolution.type_id, + leaf.resolution.enum_type, + is_quality_attribute(leaf.da_name), + is_timestamp_attribute(leaf.da_name), + }); + } + return result; +} + SclStreamAddress build_address(const XmlNode& owner) { std::unordered_map values; for (const auto* parameter : descendants(owner, "P")) { @@ -202,12 +298,16 @@ SclDocument SclParser::parse( std::size_t entry_index = 1U; for (const auto* fcda : direct_children(*data_set_node, "FCDA")) { - data_set.entries.push_back(build_data_set_entry( + auto entries = build_data_set_entries( ied_name, *fcda, - entry_index++, + entry_index, type_index, - document.warnings)); + document.warnings); + entry_index += entries.size(); + for (auto& entry : entries) { + data_set.entries.push_back(std::move(entry)); + } } document.data_sets.push_back(std::move(data_set)); } @@ -222,4 +322,4 @@ SclDocument SclParser::parse( index); } - std::unordered_map address_index; + std::unordered_map address_index; \ No newline at end of file diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index f862ac952..b2795ad2c 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -11,6 +11,17 @@ stream.no_asdu = std::max( 1U, ushort_attribute(*control, "nofASDU")); + if (const auto* options = first_child(*control, "SmvOpts"); options != nullptr) { + stream.smv_options.element_present = true; + stream.smv_options.refresh_time = bool_attribute(*options, "refreshTime"); + stream.smv_options.sample_synchronized = + bool_attribute(*options, "sampleSynchronized"); + stream.smv_options.sample_rate = bool_attribute(*options, "sampleRate"); + stream.smv_options.data_set = bool_attribute(*options, "dataSet"); + stream.smv_options.security = bool_attribute(*options, "security"); + stream.smv_options.synch_source_id = + bool_attribute(*options, "synchSourceId"); + } document.sampled_values_streams.push_back(std::move(stream)); } } @@ -42,4 +53,4 @@ report.control_block_reference = ied_name + ld_inst + "/" + logical_node_path + (buffered ? "$BR$" : "$RP$") + report.name; report.buffered = buffered; - report.indexed = !same(attribute(control, "indexed"), "false"); + report.indexed = !same(attribute(control, "indexed"), "false"); \ No newline at end of file diff --git a/src/scl/parser_part_05_04.inc b/src/scl/parser_part_05_04.inc index 6c5f89586..d65f63cf2 100644 --- a/src/scl/parser_part_05_04.inc +++ b/src/scl/parser_part_05_04.inc @@ -1,17 +1,13 @@ detect_address_conflicts("SV", document.sampled_values_streams); + // A focused engineering file may intentionally describe only one service + // family (for example, only Sampled Values). Absence of unrelated GOOSE, + // SV, or ReportControl blocks is therefore not a parser warning. Callers + // that require a specific service should validate that requirement in + // their own profile/compiler layer. if (document.data_sets.empty()) { document.warnings.push_back("No DataSet element was found. Semantic mapping will be limited."); } - if (document.goose_streams.empty()) { - document.warnings.push_back("No GSEControl stream was found."); - } - if (document.sampled_values_streams.empty()) { - document.warnings.push_back("No SampledValueControl stream was found."); - } - if (document.report_controls.empty()) { - document.warnings.push_back("No ReportControl block was found."); - } return document; } diff --git a/tests/fixtures/scl/sv-4800-structured-4i4v.scd b/tests/fixtures/scl/sv-4800-structured-4i4v.scd new file mode 100644 index 000000000..299688306 --- /dev/null +++ b/tests/fixtures/scl/sv-4800-structured-4i4v.scd @@ -0,0 +1,71 @@ + + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-00

+

4001

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index a501fcc12..770ac8346 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -1,13 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" #include "ariec61850/scl/dataset_reference.hpp" #include "ariec61850/scl/parser.hpp" #include +#include #include #include #include #include +#include #include #include #include @@ -137,6 +141,98 @@ void parser_extracts_multiple_sampled_values_streams_and_conflicts() { })); } +void parser_compiles_structured_4800_sv_profile_without_drift() { + 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); + CHECK(document.data_sets.size() == 1U); + CHECK(document.warnings.empty()); + + const auto& stream = document.sampled_values_streams.front(); + CHECK(stream.address.destination_mac_text == "01:0C:CD:04:00:00"); + CHECK(stream.address.app_id == std::optional{ + static_cast(0x4001U)}); + CHECK(stream.address.vlan_id == std::optional{ + static_cast(0U)}); + CHECK(stream.address.vlan_priority == std::optional{ + static_cast(4U)}); + CHECK(stream.configuration_revision == 100U); + CHECK(stream.sample_rate == 4800U); + CHECK(stream.sample_mode == "SmpPerSec"); + CHECK(stream.no_asdu == 1U); + CHECK(stream.smv_options.element_present); + CHECK(!stream.smv_options.refresh_time); + CHECK(!stream.smv_options.sample_synchronized); + CHECK(!stream.smv_options.sample_rate); + CHECK(!stream.smv_options.data_set); + CHECK(!stream.smv_options.security); + CHECK(!stream.smv_options.synch_source_id); + CHECK(stream.entries.size() == 16U); + + for (std::size_t channel = 0U; channel < 8U; ++channel) { + const auto& value = stream.entries[channel * 2U]; + const auto& quality = stream.entries[channel * 2U + 1U]; + CHECK(value.da_name == "instMag.i"); + CHECK(value.cdc == "SAV"); + CHECK(value.basic_type == "INT32"); + CHECK(!value.is_quality); + CHECK(quality.da_name == "q"); + CHECK(quality.cdc == "SAV"); + CHECK(quality.basic_type == "Quality"); + CHECK(quality.is_quality); + } + + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = static_cast(4800U); + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + CHECK(compiled.ok()); + CHECK(compiled.profile.has_value()); + const auto& profile = *compiled.profile; + CHECK(profile.app_id == 0x4001U); + CHECK(profile.vlan_present); + CHECK(profile.vlan_id == 0U); + CHECK(profile.vlan_priority == 4U); + CHECK(profile.configuration_revision == 100U); + CHECK(profile.sample_mode == SvSampleMode::samples_per_second); + CHECK(profile.publisher_rate_hz == std::optional{4800U}); + CHECK(profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus); + CHECK(profile.sample_counter_modulus == std::optional{ + static_cast(4800U)}); + CHECK(profile.channels.size() == 16U); + CHECK(profile.payload_size_bytes == 64U); + CHECK(profile.asdu_options.element_present); + + RationalTickSchedule schedule_4800{1'000'000U, 4800U}; + std::uint64_t total_ticks{}; + std::uint32_t intervals_208{}; + std::uint32_t intervals_209{}; + for (std::uint32_t sample = 0U; sample < 4800U; ++sample) { + const auto interval = schedule_4800.next_interval_ticks(); + total_ticks += interval; + if (interval == 208U) { + ++intervals_208; + } else if (interval == 209U) { + ++intervals_209; + } else { + CHECK(false); + } + } + CHECK(total_ticks == 1'000'000U); + CHECK(intervals_208 == 3200U); + CHECK(intervals_209 == 1600U); + + RationalTickSchedule schedule_4000{1'000'000U, 4000U}; + total_ticks = 0U; + for (std::uint32_t sample = 0U; sample < 4000U; ++sample) { + const auto interval = schedule_4000.next_interval_ticks(); + CHECK(interval == 250U); + total_ticks += interval; + } + CHECK(total_ticks == 1'000'000U); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -263,6 +359,7 @@ int main() { const std::vector>> tests{ {"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}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL conflicts and warnings", parser_detects_duplicate_ieds_and_missing_dataset_references}, {"SCL edition detection", parser_detects_editions_from_root_metadata},