From 75fc1066562e9c8a33291a41b94b3c655ca3feaf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 20:09:04 +0700 Subject: [PATCH 1/4] P0 discovery: exact MMS identity and bounded DataSet-backed LN type closure --- .../ied_simulator/src/MmsClientController.cpp | 4 + include/ariec61850/mms/live_discovery.hpp | 6 + include/ariec61850/mms/live_model_chunk01.ipp | 8 +- src/mms/live_discovery.cpp | 173 +++++++++++++----- tests/test_live_discovery.cpp | 145 +++++++++++++++ 5 files changed, 288 insertions(+), 48 deletions(-) diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp index a173f73f0..d0d74308b 100644 --- a/apps/ied_simulator/src/MmsClientController.cpp +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -438,6 +438,10 @@ bool MmsClientController::connectToIed() { mms::MmsLiveDiscoveryOptions options; options.maximum_variable_type_probes = 8'192U; + // One bounded extra-LN pass, driven only by verified static + // MMS DataSet members; do not retry/sweep an entire IED. + options.use_verified_data_set_type_hints = true; + options.maximum_data_set_type_hint_roots = 48U; options.maximum_data_set_directories = 4'096U; options.maximum_report_control_probes = 1'024U; auto discovery = std::make_shared( diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index 11a06396b..52fe9938e 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -28,6 +28,12 @@ struct MmsLiveDiscoveryOptions final { bool probe_variable_types{true}; std::size_t maximum_variable_type_probes{4'096U}; + // Opt-in, fail-closed DataSet-backed LN type closure for truncated NameList. + // Runs only after successful live directory reads, not from an SCL guess. + // Extra exact ST/MX LN roots share the total GVAA probe limit. + bool use_verified_data_set_type_hints{false}; + std::size_t maximum_data_set_type_hint_roots{48U}; + bool read_data_set_directories{true}; std::size_t maximum_data_set_directories{4'096U}; diff --git a/include/ariec61850/mms/live_model_chunk01.ipp b/include/ariec61850/mms/live_model_chunk01.ipp index ce9396315..1e9bb3460 100644 --- a/include/ariec61850/mms/live_model_chunk01.ipp +++ b/include/ariec61850/mms/live_model_chunk01.ipp @@ -50,7 +50,7 @@ namespace live_model_detail { } [[nodiscard]] inline std::string key(std::string value) { std::replace(value.begin(), value.end(), '$', '.'); - return lower(std::move(value)); + return value; } [[nodiscard]] inline std::string bit_names(const MmsReportBitField& field) { std::ostringstream out; @@ -74,16 +74,16 @@ namespace live_model_detail { const MmsTypeSpecification* best{}; std::size_t best_depth{}; for (const auto& item : evidence) { - if (!item.success() || !same(item.variable.domain, point.domain)) continue; + if (!item.success() || item.variable.domain != point.domain) continue; const auto root = split(item.variable.item, '$'); if (root.empty() || root.size() > target.size()) continue; bool prefix = true; - for (std::size_t i = 0; i < root.size(); ++i) prefix &= same(root[i], target[i]); + for (std::size_t i = 0; i < root.size(); ++i) prefix &= (root[i] == target[i]); if (!prefix) continue; const MmsTypeSpecification* current = &item.attributes->type; for (std::size_t i = root.size(); current && i < target.size(); ++i) { const auto found = std::find_if(current->children.begin(), current->children.end(), - [&](const auto& child) { return same(child.name, target[i]); }); + [&](const auto& child) { return child.name == target[i]; }); current = found == current->children.end() ? nullptr : &*found; } if (current && root.size() >= best_depth) { diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index 2da6d4b47..d2af870ae 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -37,6 +37,13 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { throw std::invalid_argument( "Live MMS discovery report-control probe limit must be positive."); } + if (options.use_verified_data_set_type_hints && + (!options.probe_variable_types || !options.read_data_set_directories || + options.maximum_data_set_type_hint_roots == 0U)) { + throw std::invalid_argument( + "Verified DataSet type closure requires type and directory reads " + "and a positive extra-LN budget."); + } } [[nodiscard]] std::string object_class_name( @@ -108,14 +115,6 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { ": " + exception.what(); } -[[nodiscard]] std::string lower_ascii(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const char character) { - return static_cast( - std::tolower(static_cast(character))); - }); - return value; -} - // Mirrors ARIEC61850 LiveIedVariableTypeProbePlanner: probe one MMS type tree at // each Logical Node root instead of probing every LN$FC$DO$DA leaf. Standards- // shaped servers usually advertise the Logical Node root directly (for example @@ -138,7 +137,8 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { ? item : item.substr(0U, separator); if (logical_node.empty()) continue; - const auto identity = lower_ascii(domain) + "\n" + lower_ascii(logical_node); + // IEC 61850/MMS references are case-sensitive identifiers. + const auto identity = domain + "\n" + logical_node; if (!seen.insert(identity).second) { continue; } @@ -152,6 +152,67 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { return candidates; } + +struct VerifiedTypeHintPlan final { + std::vector roots; + bool budget_exceeded{}; +}; + +// Trust only observed, non-deletable MMS DataSet directories, never an SCL +// declaration or a guessed LN name. An exact FC root proves a candidate for +// a targeted type probe; its type remains unknown until GVA actually succeeds. +// Reject the whole optional extension on budget overflow, preserving the +// original NameList-only discovery unchanged. +[[nodiscard]] VerifiedTypeHintPlan plan_verified_data_set_type_roots( + const MmsDiscoverySnapshot& snapshot, + const std::vector& directories, + const std::vector& existing_roots, + const std::size_t maximum_total, + const std::size_t maximum_extra) { + VerifiedTypeHintPlan plan; + std::set> seen; + for (const auto& root : existing_roots) seen.insert(root.reference()); + + for (const auto& evidence : directories) { + if (!evidence.success() || evidence.directory->deletable || + snapshot.domain_variables.find(evidence.candidate.domain) == + snapshot.domain_variables.end()) { + continue; + } + for (const auto& member : evidence.directory->members) { + const auto& name = member.object_name; + if (name.kind != MmsObjectNameKind::domain_specific || + name.domain != evidence.candidate.domain) continue; + const auto first = name.item.find('$'); + if (first == std::string::npos || first == 0U) continue; + const auto second = name.item.find('$', first + 1U); + if (second == std::string::npos || second == first + 1U || + second + 1U >= name.item.size()) continue; + const auto fc = std::string_view{name.item}.substr( + first + 1U, second - first - 1U); + if (fc != "ST" && fc != "MX") continue; + if (member.functional_constraint != fc || + (!member.logical_node.empty() && + member.logical_node != name.item.substr(0U, first)) || + (!member.mms_reference.empty() && + member.mms_reference != name.reference())) { + continue; + } + const auto root = MmsObjectName::domain_specific( + name.domain, name.item.substr(0U, first)); + if (!seen.insert(root.reference()).second) continue; + if (plan.roots.size() >= maximum_extra || + existing_roots.size() + plan.roots.size() >= maximum_total) { + plan.roots.clear(); + plan.budget_exceeded = true; + return plan; + } + plan.roots.push_back(root); + } + } + return plan; +} + } // namespace std::size_t MmsLiveDiscoveryResult::domain_count() const noexcept { @@ -326,9 +387,56 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.report_inventory = MmsReportInventoryBuilder::build(result.names); - if (options.probe_variable_types) { - const auto candidates = build_logical_node_type_probe_candidates( + const auto readDataSetDirectories = [&] { + const std::size_t count = std::min( + options.maximum_data_set_directories, + result.report_inventory.data_sets.size()); + result.data_set_directories.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + MmsDataSetDirectoryEvidence evidence; + evidence.candidate = result.report_inventory.data_sets[index]; + try { + MmsDataSetDirectoryRequest request; + request.invoke_id = association_.next_invoke_id(); + request.data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference( + evidence.candidate.reference); + const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + const auto payload = response_payload( + exchange, "GetNamedVariableListAttributes"); + evidence.directory = MmsDataSetDirectoryCodec::decode_response( + payload, request.invoke_id); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + result.diagnostics.push_back(optional_probe_error( + "DataSet directory", evidence.candidate.reference, exception)); + if (!options.continue_on_optional_probe_error) { + throw; + } + } + result.data_set_directories.push_back(std::move(evidence)); + } + }; + + const auto probeVariableTypes = [&] { + auto candidates = build_logical_node_type_probe_candidates( result.names, options.maximum_variable_type_probes); + if (options.use_verified_data_set_type_hints) { + const auto extra = plan_verified_data_set_type_roots( + result.names, result.data_set_directories, candidates, + options.maximum_variable_type_probes, + options.maximum_data_set_type_hint_roots); + if (extra.budget_exceeded) { + result.diagnostics.push_back( + "Verified DataSet LN type-root budget exceeded; " + "optional extra probes skipped without partial selection."); + } else { + candidates.insert( + candidates.end(), extra.roots.begin(), extra.roots.end()); + } + } result.variable_types.reserve(candidates.size()); for (const auto& candidate : candidates) { @@ -361,39 +469,16 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( } result.variable_types.push_back(std::move(evidence)); } - } - - if (options.read_data_set_directories) { - const std::size_t count = std::min( - options.maximum_data_set_directories, - result.report_inventory.data_sets.size()); - result.data_set_directories.reserve(count); - for (std::size_t index = 0U; index < count; ++index) { - MmsDataSetDirectoryEvidence evidence; - evidence.candidate = result.report_inventory.data_sets[index]; - try { - MmsDataSetDirectoryRequest request; - request.invoke_id = association_.next_invoke_id(); - request.data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference( - evidence.candidate.reference); - const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( - request, association_.negotiated().presentation_context_id); - const auto exchange = association_.exchange_confirmed( - encoded, request.invoke_id, stop_token); - const auto payload = response_payload( - exchange, "GetNamedVariableListAttributes"); - evidence.directory = MmsDataSetDirectoryCodec::decode_response( - payload, request.invoke_id); - } catch (const std::exception& exception) { - evidence.error = exception.what(); - result.diagnostics.push_back(optional_probe_error( - "DataSet directory", evidence.candidate.reference, exception)); - if (!options.continue_on_optional_probe_error) { - throw; - } - } - result.data_set_directories.push_back(std::move(evidence)); - } + }; + + // Only the opt-in closure path changes phase order. Existing callers + // retain their wire sequence and unchanged default request count. + if (options.use_verified_data_set_type_hints) { + readDataSetDirectories(); + probeVariableTypes(); + } else { + probeVariableTypes(); + readDataSetDirectories(); } if (options.probe_report_controls) { diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 43817e457..26b3bc33f 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -481,6 +481,148 @@ void live_discovery_builds_csharp_compatible_read_only_model() { CHECK(read_only_requests == 6U); } + +void case_distinct_references_are_not_deduplicated() { + // Two valid MMS leaf names differ only in case: both must survive the + // canonical DA index instead of overwriting one another. + mms::MmsLiveDiscoveryResult snapshot; + snapshot.endpoint = {"192.0.2.10", 102U}; + snapshot.names.domain_variables["OLS501LD0"] = { + "GGIO1$ST$Ind1$stVal", "GGIO1$ST$Ind1$StVal"}; + const auto model = mms::MmsLiveModelBuilder::build(snapshot); + CHECK(model.coverage.data_attribute_count == 2U); + CHECK(model.logical_devices.size() == 1U); + CHECK(model.logical_devices.front().logical_nodes.size() == 1U); + CHECK(model.logical_devices.front().logical_nodes.front().data_objects.size() == 1U); + const auto& attributes = + model.logical_devices.front().logical_nodes.front().data_objects.front().attributes; + CHECK(attributes.size() == 2U); + CHECK(attributes.front().attribute_path != attributes.back().attribute_path); +} + +void case_distinct_ln_type_probes_are_preserved() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"OLS501LD0"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {2U, {"GGIO1$ST$Ind1$stVal", "ggio1$ST$Ind1$stVal"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {3U, {}, false}))); + for (std::uint32_t id = 4U; id <= 5U; ++id) { + mms::MmsVariableAccessAttributesResponse type; + type.invoke_id = id; + type.type.kind = mms::MmsTypeKind::boolean; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_variable_access_attributes_response_p_data(type))); + } + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 2U; + options.read_data_set_directories = false; + options.probe_report_controls = false; + const auto result = mms::MmsLiveDiscoveryClient{association}.discover(options); + CHECK(result.variable_types.size() == 2U); + CHECK(result.variable_types[0].success()); + CHECK(result.variable_types[1].success()); + CHECK(result.variable_types[0].variable.item == "GGIO1"); + CHECK(result.variable_types[1].variable.item == "ggio1"); + CHECK(result.variable_types[0].variable.reference() != + result.variable_types[1].variable.reference()); +} + +void verified_data_set_hints_restore_late_ln_type_schema() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"OLS501LD0"}, false}))); + // NameList omits CSWI1, but an actual MMS DataSet directory includes + // CSWI1$ST$Pos (FCD). No SCL/model guess is allowed in this probe. + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {2U, {"LLN0$ST$Mod$stVal"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {3U, {"LLN0$DataSetA"}, false}))); + mms::MmsDataSetDirectoryResponse directory; + directory.invoke_id = 4U; + directory.members.push_back({ + mms::MmsObjectName::domain_specific("OLS501LD0", "CSWI1$ST$Pos"), + "OLS501LD0/CSWI1$ST$Pos", "OLS501LD0/CSWI1.Pos", "ST", + "CSWI1", "Pos", 100U}); + // Invalid/wrong-domain and wrong-FC members must never schedule GVA. + directory.members.push_back({ + mms::MmsObjectName::domain_specific("OTHERLD0", "CSWI9$ST$Pos"), + "OTHERLD0/CSWI9$ST$Pos", "OTHERLD0/CSWI9.Pos", "ST", + "CSWI9", "Pos", 100U}); + directory.members.push_back({ + mms::MmsObjectName::domain_specific("OLS501LD0", "CSWI8$CF$ctlModel"), + "OLS501LD0/CSWI8$CF$ctlModel", "OLS501LD0/CSWI8.ctlModel", "CF", + "CSWI8", "ctlModel", 100U}); + transport.push_receive(wrap_application( + mms::MmsDataSetDirectoryCodec::encode_response_p_data(directory))); + mms::MmsVariableAccessAttributesResponse first; + first.invoke_id = 5U; + first.type.kind = mms::MmsTypeKind::boolean; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_variable_access_attributes_response_p_data(first))); + + mms::MmsVariableAccessAttributesResponse late; + late.invoke_id = 6U; + late.type.kind = mms::MmsTypeKind::structure; + mms::MmsTypeSpecification fc; + fc.kind = mms::MmsTypeKind::structure; + fc.name = "ST"; + mms::MmsTypeSpecification doSpec; + doSpec.kind = mms::MmsTypeKind::structure; + doSpec.name = "Pos"; + mms::MmsTypeSpecification valueSpec; + valueSpec.kind = mms::MmsTypeKind::boolean; + valueSpec.name = "stVal"; + mms::MmsTypeSpecification qualitySpec; + qualitySpec.kind = mms::MmsTypeKind::bit_string; + qualitySpec.name = "q"; + doSpec.children.push_back(std::move(valueSpec)); + doSpec.children.push_back(std::move(qualitySpec)); + fc.children.push_back(std::move(doSpec)); + late.type.children.push_back(std::move(fc)); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_variable_access_attributes_response_p_data(late))); + + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 8U; + options.maximum_data_set_type_hint_roots = 2U; + options.use_verified_data_set_type_hints = true; + options.probe_report_controls = false; + const auto result = mms::MmsLiveDiscoveryClient{association}.discover(options); + CHECK(result.variable_types.size() == 2U); + CHECK(result.variable_types[0].variable.item == "LLN0"); + CHECK(result.variable_types[1].variable.item == "CSWI1"); + CHECK(result.variable_types[1].success()); + CHECK(!result.partial()); + const auto model = mms::MmsLiveModelBuilder::build(result); + bool found_cswi = false; + for (const auto& ld : model.logical_devices) { + for (const auto& ln : ld.logical_nodes) { + if (ln.name != "CSWI1") continue; + found_cswi = true; + for (const auto& object : ln.data_objects) { + if (object.name != "Pos") continue; + CHECK(object.attributes.size() >= 2U); + } + } + } + CHECK(found_cswi); + CHECK(transport.sent().size() == 8U); // association (2) + 6 reads +} + void pagination_without_forward_progress_is_rejected() { ScriptedTransport transport; queue_handshake(transport); @@ -509,6 +651,9 @@ int main() { csharp_control_block_inventory_cases_are_preserved(); dynamic_rcb_binding_states_are_preserved(); live_discovery_builds_csharp_compatible_read_only_model(); + case_distinct_references_are_not_deduplicated(); + case_distinct_ln_type_probes_are_preserved(); + verified_data_set_hints_restore_late_ln_type_schema(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; return 0; From 135fb73a7a62790c9e4ccf59f68e514926746d33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 20:09:41 +0700 Subject: [PATCH 2/4] fix(discovery): preserve disabled optional probes in ordered phase closures --- src/mms/live_discovery.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index d2af870ae..9eeb964b1 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -388,6 +388,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.report_inventory = MmsReportInventoryBuilder::build(result.names); const auto readDataSetDirectories = [&] { + if (!options.read_data_set_directories) return; const std::size_t count = std::min( options.maximum_data_set_directories, result.report_inventory.data_sets.size()); @@ -421,6 +422,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( }; const auto probeVariableTypes = [&] { + if (!options.probe_variable_types) return; auto candidates = build_logical_node_type_probe_candidates( result.names, options.maximum_variable_type_probes); if (options.use_verified_data_set_type_hints) { From 3702c7814ef63081c1f54e82b735d7a6bb48d90a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 20:15:28 +0700 Subject: [PATCH 3/4] fix(discovery): deterministic case-distinct model fingerprints across runtimes --- include/ariec61850/mms/live_model_fingerprint.ipp | 8 ++++++-- tests/test_live_discovery.cpp | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/mms/live_model_fingerprint.ipp b/include/ariec61850/mms/live_model_fingerprint.ipp index da0355afa..0ebd09330 100644 --- a/include/ariec61850/mms/live_model_fingerprint.ipp +++ b/include/ariec61850/mms/live_model_fingerprint.ipp @@ -68,7 +68,9 @@ inline std::string MmsLiveModelDocument::structural_manifest() const { append_control_blocks(log_controls); std::sort(lines.begin() + 2, lines.end(), [](const auto& left, const auto& right) { - return lower(left) < lower(right); + const auto folded_left = lower(left); + const auto folded_right = lower(right); + return folded_left == folded_right ? left < right : folded_left < folded_right; }); std::ostringstream out; @@ -136,7 +138,9 @@ inline std::string MmsLiveModelDocument::runtime_snapshot_manifest() const { append_runtime_controls(log_controls); std::sort(lines.begin() + 2, lines.end(), [](const auto& left, const auto& right) { - return lower(left) < lower(right); + const auto folded_left = lower(left); + const auto folded_right = lower(right); + return folded_left == folded_right ? left < right : folded_left < folded_right; }); std::ostringstream out; diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 26b3bc33f..d0ddf3cf9 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -498,6 +498,12 @@ void case_distinct_references_are_not_deduplicated() { model.logical_devices.front().logical_nodes.front().data_objects.front().attributes; CHECK(attributes.size() == 2U); CHECK(attributes.front().attribute_path != attributes.back().attribute_path); + auto reversed = model; + auto& reversed_attributes = + reversed.logical_devices.front().logical_nodes.front().data_objects.front().attributes; + std::reverse(reversed_attributes.begin(), reversed_attributes.end()); + CHECK(model.structural_manifest() == reversed.structural_manifest()); + CHECK(model.structural_fingerprint() == reversed.structural_fingerprint()); } void case_distinct_ln_type_probes_are_preserved() { From 3ad730972d510f632d2912a5c062fb43a6044f23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 20:16:00 +0700 Subject: [PATCH 4/4] test(discovery): declare algorithm include for deterministic fingerprint regression --- tests/test_live_discovery.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index d0ddf3cf9..74841a551 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -7,6 +7,7 @@ #include "ariec61850/osi/cotp.hpp" #include "ariec61850/osi/tpkt.hpp" +#include #include #include #include