diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 2c28fe560..50bf7b37e 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -419,7 +419,7 @@ Rectangle { } Repeater { model: root.hasLastReport && root.latestReport.values - ? root.latestReport.values.slice(0, 256) : [] + ? root.latestReport.values : [] delegate: DataRow { received: true } } Repeater { @@ -439,6 +439,15 @@ Rectangle { color: theme.muted font.pixelSize: 9 } + Label { + visible: root.hasLastReport && + root.latestReport.valueTextTruncated === true + Layout.fillWidth: true + leftPadding: 9 + text: "Some values are preview-truncated; the report payload is preserved by MMS runtime." + color: theme.muted + font.pixelSize: 9 + } Label { visible: root.hasLastReport ? !root.latestReport.values || root.latestReport.values.length === 0 : root.configuredMembers.length === 0 @@ -455,7 +464,7 @@ Rectangle { font.pixelSize: 10 } Label { - visible: root.hasLastReport ? root.latestReport.values.length > 256 + visible: root.hasLastReport ? root.latestReport.valuesTruncated === true : root.configuredMembers.length > 256 Layout.fillWidth: true leftPadding: 10 diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp new file mode 100644 index 000000000..14554f6d2 --- /dev/null +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "MmsEngineeringEvidence.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/reporting.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// Bounded, read-only projection of already-decoded MMS report members. +// Report runtime keeps full authoritative frame. The GUI only needs a preview. +namespace arstack::iedsim { +namespace mms = ar::iec61850::mms; + +struct BrowserReportMemberProjection final { + QStringList values; + QVariantList evidence; + bool rowsTruncated{}; + bool textTruncated{}; + bool evidenceTruncated{}; +}; + +inline constexpr int kBrowserReportVisibleMemberLimit = 256; +inline constexpr int kBrowserReportTextLimit = 1024; + +[[nodiscard]] inline BrowserReportMemberProjection projectBrowserReportMembers( + const std::vector& members, + int& remainingTypedEvidence, + const int requestedVisibleMembers = kBrowserReportVisibleMemberLimit) { + BrowserReportMemberProjection projection; + const int visibleLimit = std::clamp(requestedVisibleMembers, 0, kBrowserReportVisibleMemberLimit); + const auto visibleCount = std::min(members.size(), static_cast(visibleLimit)); + projection.rowsTruncated = members.size() > visibleCount; + projection.values.reserve(static_cast(visibleCount)); + + for (std::size_t ordinal = 0; ordinal < visibleCount; ++ordinal) { + const auto& member = members[ordinal]; + QString reference = QString::fromStdString(member.data_reference); + if (reference.isEmpty() && member.member) + reference = QString::fromStdString(member.member->user_reference); + if (reference.isEmpty() && member.member) + reference = QString::fromStdString(member.member->mms_reference); + + QString displayReference = reference; + if (displayReference.isEmpty()) + displayReference = QStringLiteral("member[%1]").arg( + static_cast(member.data_set_index)); + QString displayValue = QStringLiteral(""); + if (member.value) + displayValue = QString::fromStdString(mms::MmsDataCodec::to_display_string(*member.value)); + QString reasons; + for (const auto& reason : member.reason_for_inclusion.names) { + if (!reasons.isEmpty()) reasons += QStringLiteral(", "); + reasons += QString::fromStdString(reason); + } + QString text = QStringLiteral("%1 = %2%3").arg( + displayReference, displayValue, + reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); + if (text.size() > kBrowserReportTextLimit) { + projection.textTruncated = true; + text = text.left(kBrowserReportTextLimit) + QStringLiteral(" … [preview truncated]"); + } + projection.values.push_back(std::move(text)); + + // Only read successful typed q/t values, and only when the retained + // presentation row matches the member ordinal (never DataSet position). + if (!member.value || reference.isEmpty()) continue; + if (remainingTypedEvidence > 0) { + const auto decoded = decodeEngineeringEvidence(reference, *member.value); + if (!decoded.isEmpty()) { + projection.evidence.push_back(QVariantMap{ + {QStringLiteral("index"), static_cast(ordinal)}, + {QStringLiteral("evidence"), decoded}}); + --remainingTypedEvidence; + } + } else { + // Do not decode (allocate rows) once the shared budget is empty. + const auto separator = std::max( + reference.lastIndexOf(QLatin1Char('$')), + reference.lastIndexOf(QLatin1Char('.'))); + const QString leaf = reference.mid(separator + 1); + const auto kind = member.value->kind(); + if ((leaf == QStringLiteral("q") && kind == mms::MmsDataKind::bit_string) || + (leaf == QStringLiteral("t") && kind == mms::MmsDataKind::utc_time)) + projection.evidenceTruncated = true; + } + } + return projection; +} +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index c929f338a..67103f62d 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsReportController.hpp" -#include "MmsEngineeringEvidence.hpp" +#include "BrowserReportMemberProjection.hpp" #include "ariec61850/mms/data_codec.hpp" #include "ariec61850/mms/dynamic_data_set.hpp" @@ -28,7 +28,6 @@ namespace mms = ar::iec61850::mms; namespace { constexpr int kMaximumVisibleReports = 128; constexpr int kMaximumTypedReportEvidence = 96; -constexpr int kMaximumVisibleReportMembers = 256; QString boolText(const std::optional value) { if (!value.has_value()) return QStringLiteral("—"); @@ -436,21 +435,6 @@ struct ReportUi final { bool cleanupRequired{}; }; -QString reportValueText(const mms::MmsReportValue& item) { - QString value = QStringLiteral(""); - if (item.value) value = QString::fromStdString(mms::MmsDataCodec::to_display_string(*item.value)); - QString reference = QString::fromStdString(item.data_reference); - if (reference.isEmpty() && item.member) reference = QString::fromStdString(item.member->user_reference); - if (reference.isEmpty()) reference = QStringLiteral("member[%1]").arg(static_cast(item.data_set_index)); - QString reasons; - for (const auto& reason : item.reason_for_inclusion.names) { - if (!reasons.isEmpty()) reasons += QStringLiteral(", "); - reasons += QString::fromStdString(reason); - } - return QStringLiteral("%1 = %2%3") - .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); -} - ReportUi buildSubscriptionUi( const mms::MmsReportSubscriptionSnapshot& subscription, const bool active) { @@ -480,48 +464,18 @@ ReportUi buildSubscriptionUi( map.insert(QStringLiteral("overflow"), frame.header.buffer_overflow.value_or(false)); map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode)); map.insert(QStringLiteral("valueCount"), static_cast(frame.values.size())); - QStringList values; - QVariantList typedEvidence; - bool typedEvidenceTruncated = false; - int memberIndex = 0; - for (const auto& item : frame.values) { - values.push_back(reportValueText(item)); - if (item.value && memberIndex < kMaximumVisibleReportMembers) { - QString memberRef = QString::fromStdString(item.data_reference); - if (memberRef.isEmpty() && item.member) - memberRef = QString::fromStdString(item.member->user_reference); - if (memberRef.isEmpty() && item.member) - memberRef = QString::fromStdString(item.member->mms_reference); - if (typedEvidenceBudget > 0) { - const auto evidence = arstack::iedsim::decodeEngineeringEvidence( - memberRef, *item.value); - if (!evidence.isEmpty()) { - typedEvidence.push_back(QVariantMap{ - {QStringLiteral("index"), memberIndex}, - {QStringLiteral("evidence"), evidence}}); - --typedEvidenceBudget; - } - } else { - // Mark truncation only for real q/t typed members, not - // unrelated BIT STRING or UTC-valued attributes. - const auto kind = item.value->kind(); - const auto separator = std::max( - memberRef.lastIndexOf(QLatin1Char('$')), - memberRef.lastIndexOf(QLatin1Char('.'))); - const QString leaf = memberRef.mid(separator + 1); - if ((leaf == QStringLiteral("q") && - kind == mms::MmsDataKind::bit_string) || - (leaf == QStringLiteral("t") && - kind == mms::MmsDataKind::utc_time)) - typedEvidenceTruncated = true; - } - } - ++memberIndex; - } - map.insert(QStringLiteral("values"), values); - map.insert(QStringLiteral("valueEvidence"), typedEvidence); - map.insert(QStringLiteral("valueEvidenceTruncated"), typedEvidenceTruncated); - map.insert(QStringLiteral("valuesSummary"), values.mid(0, 3).join(QStringLiteral(" | "))); + // A frame can include far more DataSet members than the 256 GUI + // rows. Project only retained rows, preserving actual total count + // and a shared typed-evidence budget across recent frames. + const auto projected = arstack::iedsim::projectBrowserReportMembers( + frame.values, typedEvidenceBudget); + map.insert(QStringLiteral("values"), projected.values); + map.insert(QStringLiteral("valueEvidence"), projected.evidence); + map.insert(QStringLiteral("valueEvidenceTruncated"), projected.evidenceTruncated); + map.insert(QStringLiteral("valuesTruncated"), projected.rowsTruncated); + map.insert(QStringLiteral("valueTextTruncated"), projected.textTruncated); + map.insert(QStringLiteral("valuesSummary"), + projected.values.mid(0, 3).join(QStringLiteral(" | "))); map.insert(QStringLiteral("duplicateCount"), static_cast(streamIt->state.duplicate_count)); map.insert(QStringLiteral("gapCount"), static_cast(streamIt->state.gap_count)); map.insert(QStringLiteral("resetCount"), static_cast(streamIt->state.reset_count)); diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index baa9d7fa8..39e2c419b 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsLiveTreeModel.hpp" #include "MmsEngineeringEvidence.hpp" +#include "BrowserReportMemberProjection.hpp" #include #include @@ -311,6 +312,48 @@ int main(int argc, char** argv) { return 25; } + // Project 300 decoded report members but allocate only 256 GUI rows. + // A shared one-item evidence budget prevents a q/t storm from allocating + // a detailed map per member or per retained report frame. + std::vector largeFrame(300); + for (std::size_t index = 0; index < largeFrame.size(); ++index) { + largeFrame[index].data_set_index = index; + largeFrame[index].data_reference = "LD4/LLN19.DO99.stVal"; + largeFrame[index].value = ar::iec61850::mms::MmsDataValue::boolean(true); + } + largeFrame[0].data_reference = "LD4/LLN19.DO99.q"; + largeFrame[0].value = ar::iec61850::mms::MmsDataValue::bit_string(3U, bits); + largeFrame[1].data_reference = "LD4/LLN19.DO99.t"; + largeFrame[1].value = ar::iec61850::mms::MmsDataValue::utc_time(utc); + largeFrame[2].value.reset(); // An unsuccessful member has no typed evidence + largeFrame[3].data_reference = "LD4/LLN19.DO99.q"; + largeFrame[3].value = ar::iec61850::mms::MmsDataValue::boolean(false); // Wrong kind + largeFrame[4].data_reference = std::string(2048, 'X'); // Bounded preview + int typedBudget = 1; + const auto projected = arstack::iedsim::projectBrowserReportMembers( + largeFrame, typedBudget); + if (projected.values.size() != 256 || + projected.evidence.size() != 1 || + projected.evidence.constFirst().toMap().value(QStringLiteral("index")).toInt() != 0 || + typedBudget != 0 || + !projected.rowsTruncated || !projected.textTruncated || + !projected.evidenceTruncated || + !projected.values.at(2).contains(QStringLiteral("")) || + projected.values.at(4).size() > 1100) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL bounded_report_projection\n"; + return 27; + } + int smallBudget = 2; + const auto smallProjection = arstack::iedsim::projectBrowserReportMembers( + largeFrame, smallBudget, 3); + if (smallProjection.values.size() != 3 || + smallProjection.evidence.size() != 2 || + smallBudget != 0 || !smallProjection.rowsTruncated || + smallProjection.evidenceTruncated) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL bounded_report_small_window\n"; + return 28; + } + const bool passes = applyMs <= 5000 && filterMs <= 1500 && selectMs <= 500; std::cout << "IED_BROWSER_LARGE_MODEL_" << (passes ? "PASS" : "FAIL") << " nodes=" << model.totalNodeCount() diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 2e460b182..da8d7063d 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -88,8 +88,7 @@ report_controller = (workspace_path.parent.parent / "src" / "MmsReportController.cpp").read_text(encoding="utf-8") for token in [ 'kMaximumTypedReportEvidence = 96', - 'kMaximumVisibleReportMembers = 256', - 'decodeEngineeringEvidence(', + 'projectBrowserReportMembers(', 'QStringLiteral("valueEvidence")', 'QStringLiteral("valueEvidenceTruncated")', ]: @@ -103,6 +102,22 @@ "const QString leaf = mmsItem.mid(leafSeparator + 1);" not in typed_decoder): raise SystemExit("BROWSER_REPORT_TYPED_EVIDENCE_FAIL invalid shared decoder source") +projection_header = (workspace_path.parent.parent / "src" / "BrowserReportMemberProjection.hpp").read_text(encoding="utf-8") +for token in [ + 'kBrowserReportVisibleMemberLimit = 256', + 'kBrowserReportTextLimit = 1024', + 'std::min(members.size(),', + 'projection.rowsTruncated = members.size() > visibleCount', + 'if (!member.value || reference.isEmpty()) continue', + 'decodeEngineeringEvidence(', +]: + if token not in projection_header: + raise SystemExit("BROWSER_REPORT_BOUNDED_PROJECTION_FAIL missing=" + token) +if 'for (const auto& item : frame.values)' in report_controller: + raise SystemExit("BROWSER_REPORT_BOUNDED_PROJECTION_FAIL full-frame QML string conversion") +if 'decodeEngineeringEvidence(' in report_controller: + raise SystemExit("BROWSER_REPORT_BOUNDED_PROJECTION_FAIL duplicate inline decoding") + report_required = [ 'objectName: "browserReportUnifiedInspector"', 'Control Block Attributes', @@ -122,6 +137,8 @@ 'root.selectedReportMemberIndex = dataRow.index', 'onLatestReportIdentityChanged: selectedReportMemberIndex = -1', 'BrowserEngineeringEvidence {', + 'root.latestReport.valuesTruncated === true', + 'root.latestReport.valueTextTruncated === true', 'String(frame.reportId) === wanted', 'Offline values are not fabricated.', ]