Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion NORTH_STAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ SvPublisherProfile
nofAsdu
SmvOpts field-presence policy
dataSetReference
samplingBasis / sampleRate
samplingBasis / configuredSampleRate
ASDUsPerFrame / derivedFrameCadence
sampleCounterPolicy
synchronizationPolicy
channels[]
Expand Down
135 changes: 110 additions & 25 deletions apps/arstack_studio/qml/ProfileInspector.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
67 changes: 59 additions & 8 deletions apps/arstack_studio/src/SclProfileModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Expand Down Expand Up @@ -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<int>(p.app_id);
case PublisherRateRole:
return p.publisher_rate_hz.has_value()
? QVariant::fromValue(static_cast<qulonglong>(*p.publisher_rate_hz))
case PublisherRateRole: {
const auto frameRate = p.timing.exact_frame_rate_hz();
return frameRate.has_value()
? QVariant::fromValue(static_cast<qulonglong>(*frameRate))
: QVariant{};
}
case PayloadBytesRole: return static_cast<qulonglong>(p.payload_size_bytes);
case CounterPolicyRole: return counterPolicyText(p.sample_counter_policy);
case CounterModulusRole:
Expand Down Expand Up @@ -189,6 +192,7 @@ void SclProfileModel::installDocument(
confirmedCounterModulus_ = referenceTemplate
? std::optional<std::uint16_t>{kReferenceCounterModulus}
: std::nullopt;
confirmedNominalFrequencyMilliHz_.reset();
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand Down Expand Up @@ -249,6 +253,7 @@ void SclProfileModel::clear() {
beginResetModel();
document_.reset();
confirmedCounterModulus_.reset();
confirmedNominalFrequencyMilliHz_.reset();
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand Down Expand Up @@ -281,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<std::uint32_t>(frequencyHz) * 1000U;
rebuildRows();
return true;
}

void SclProfileModel::clearNominalFrequency() {
if (!confirmedNominalFrequencyMilliHz_.has_value()) return;
confirmedNominalFrequencyMilliHz_.reset();
rebuildRows();
}

void SclProfileModel::rebuildRows() {
if (!document_.has_value()) return;

Expand All @@ -292,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;
Expand All @@ -308,6 +327,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");
Expand Down Expand Up @@ -340,19 +360,50 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co
map.insert(QStringLiteral("vlanId"), static_cast<int>(p.vlan_id));
map.insert(QStringLiteral("vlanPriority"), static_cast<int>(p.vlan_priority));
map.insert(QStringLiteral("confRev"), static_cast<qulonglong>(p.configuration_revision));
map.insert(QStringLiteral("sampleRate"), static_cast<qulonglong>(p.sample_rate_value));
map.insert(QStringLiteral("sampleMode"), sampleModeText(p.sample_mode));
map.insert(QStringLiteral("sampleRate"), static_cast<qulonglong>(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<qulonglong>(*p.timing.nominal_frequency_millihz))
: QVariant{});
map.insert(QStringLiteral("sampleRatePerSecondNumerator"),
p.timing.samples_per_second.has_value()
? QVariant::fromValue(static_cast<qulonglong>(p.timing.samples_per_second->numerator))
: QVariant{});
map.insert(QStringLiteral("sampleRatePerSecondDenominator"),
p.timing.samples_per_second.has_value()
? QVariant::fromValue(static_cast<qulonglong>(p.timing.samples_per_second->denominator))
: QVariant{});
map.insert(QStringLiteral("frameRateNumerator"),
p.timing.frames_per_second.has_value()
? QVariant::fromValue(static_cast<qulonglong>(p.timing.frames_per_second->numerator))
: QVariant{});
map.insert(QStringLiteral("frameRateDenominator"),
p.timing.frames_per_second.has_value()
? QVariant::fromValue(static_cast<qulonglong>(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<qulonglong>(*sampleRateHz))
: QVariant{});
map.insert(QStringLiteral("frameRateHz"),
frameRate.has_value()
? QVariant::fromValue(static_cast<qulonglong>(*frameRate))
: QVariant{});
map.insert(QStringLiteral("publisherRate"),
p.publisher_rate_hz.has_value()
? QVariant::fromValue(static_cast<qulonglong>(*p.publisher_rate_hz))
frameRate.has_value()
? QVariant::fromValue(static_cast<qulonglong>(*frameRate))
: QVariant{});
map.insert(QStringLiteral("counterPolicy"), counterPolicyText(p.sample_counter_policy));
map.insert(QStringLiteral("counterModulus"),
p.sample_counter_modulus.has_value()
? QVariant::fromValue(static_cast<int>(*p.sample_counter_modulus))
: QVariant{});
map.insert(QStringLiteral("payloadBytes"), static_cast<qulonglong>(p.payload_size_bytes));
map.insert(QStringLiteral("nofASDU"), static_cast<int>(p.no_asdu));
map.insert(QStringLiteral("nofASDU"), static_cast<int>(p.timing.asdus_per_frame));
map.insert(QStringLiteral("channelLeafCount"), static_cast<qulonglong>(p.channels.size()));
map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set);
map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate);
Expand Down
3 changes: 3 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -88,6 +90,7 @@ class SclProfileModel : public QAbstractListModel {

std::optional<ar::iec61850::scl::SclDocument> document_;
std::optional<std::uint16_t> confirmedCounterModulus_;
std::optional<std::uint32_t> confirmedNominalFrequencyMilliHz_;
std::vector<Row> rows_;
int selectedIndex_{-1};
QString fatalError_;
Expand Down
13 changes: 7 additions & 6 deletions docs/SV_INTEROPERABILITY_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading