From bf1c8adaa5667eaafa08b970fa742122840316b1 Mon Sep 17 00:00:00 2001 From: Adrian Pop Date: Sun, 1 Nov 2026 16:34:18 +0100 Subject: [PATCH] fmi-ls-dae: acausal connections through FMU terminals (#1686) Acausal components, each an FMI 3.0 Model Exchange FMU with fmi-ls-dae and OpenModelica's fmi-ls-dae-acausal manifest, are connected through their terminals and integrated as one DAE with IDA. - The members of an acausal component's terminals become connectors of SSP 2.0 kind "unspecified". A connection between two of them is acausal (oms_connection_acausal): it stays out of the directed graphs, a member may be connected any number of times, and the SSD stores it as an ordinary connection. - SystemSC builds the connection sets and replaces the residuals the manifest names for the connected flows (flow = 0) with the equations of the sets: k-1 rows for the potentials, one flow-sum row. The system stays square; the rows' sparsity comes from the replaced residuals. Mixed or unbalanced sets, and acausal connections outside DAE mode, are refused. - Stream variables: the manifest names, per stream variable of a terminal, the residual that reads inStream(h) = h (StreamResidual), and the terminal's TerminalStreamMemberVariable names the variable that stands for its inStream. The stream variable is connected like the other members, and SystemSC replaces each connected stream's residual by the mixing rule of its set, s_i - sum_{j<>i} max(-m_j, eps)*h_j / sum_{j<>i} max(-m_j, eps) (s_i - h_j for two members), m_j being the flow of h_j's terminal. - A terminal member may be an FMI 3.0 (b.p with a.p = b.p), which the FMI library does not list; it is resolved from modelDescription.xml. A terminal member that is a (calculated) parameter, e.g. the fixed pressure of a boundary, is connected acausally too. - Python: Causality.unspecified, the terminals read from the FMU, and addConnection between two terminals expands them member by member. Tests: DaeMode3.py (source, RC, ground) and DaeMode4.py (a three-way node, an unconnected component, a flow connected to a potential) with three electrical FMUs, and DaeMode5.py (a source, a volume and a sink; two branches mixing in a node of three terminals) with two fluid FMUs, all exported by OpenModelica --fmuAcausalComponent (OpenModelica/OpenModelica#17665), x86_64-linux only. Refs #1686, #1432 Co-Authored-By: Claude Opus 5.5 --- include/OMSimulator/Types.h | 1 + src/OMSimulatorLib/ComponentFMU3ME.cpp | 67 ++++ src/OMSimulatorLib/ComponentFMU3ME.h | 13 + src/OMSimulatorLib/Connection.cpp | 4 +- src/OMSimulatorLib/Connector.cpp | 9 + src/OMSimulatorLib/LsDae.cpp | 84 +++++ src/OMSimulatorLib/LsDae.h | 88 +++++ src/OMSimulatorLib/System.cpp | 16 +- src/OMSimulatorLib/SystemSC.cpp | 337 ++++++++++++++++++ src/OMSimulatorLib/SystemSC.h | 56 +++ src/OMSimulatorPython/connection.py | 5 + src/OMSimulatorPython/fmu.py | 61 +++- src/OMSimulatorPython/system.py | 30 +- src/OMSimulatorPython/variable.py | 4 + .../binaries/x86_64-linux/AcausalGround.so | Bin 0 -> 3814504 bytes .../fmi-ls-manifest.xml | 14 + .../fmi-ls-manifest.xml | 10 + .../AcausalGround/modelDescription.xml | 46 +++ .../terminalsAndIcons/terminalsAndIcons.xml | 9 + .../binaries/x86_64-linux/AcausalRC.so | Bin 0 -> 3818600 bytes .../fmi-ls-manifest.xml | 18 + .../fmi-ls-manifest.xml | 10 + .../resources/AcausalRC/modelDescription.xml | 54 +++ .../terminalsAndIcons/terminalsAndIcons.xml | 9 + .../binaries/x86_64-linux/AcausalSource.so | Bin 0 -> 3818600 bytes .../fmi-ls-manifest.xml | 18 + .../fmi-ls-manifest.xml | 11 + .../AcausalSource/modelDescription.xml | 51 +++ .../terminalsAndIcons/terminalsAndIcons.xml | 13 + .../x86_64-linux/AcausalStreamBoundary.so | Bin 0 -> 3818600 bytes .../fmi-ls-manifest.xml | 18 + .../fmi-ls-manifest.xml | 11 + .../modelDescription.xml | 55 +++ .../terminalsAndIcons/terminalsAndIcons.xml | 10 + .../x86_64-linux/AcausalStreamVolume.so | Bin 0 -> 3826792 bytes .../fmi-ls-manifest.xml | 24 ++ .../fmi-ls-manifest.xml | 13 + .../AcausalStreamVolume/modelDescription.xml | 64 ++++ .../terminalsAndIcons/terminalsAndIcons.xml | 15 + testsuite/tests/resources/CMakeLists.txt | 5 + testsuite/tests/simulation/CMakeLists.txt | 3 + testsuite/tests/simulation/DaeMode3.py | 139 ++++++++ testsuite/tests/simulation/DaeMode4.py | 111 ++++++ testsuite/tests/simulation/DaeMode5.py | 152 ++++++++ 44 files changed, 1654 insertions(+), 4 deletions(-) create mode 100755 testsuite/tests/resources/AcausalGround/binaries/x86_64-linux/AcausalGround.so create mode 100644 testsuite/tests/resources/AcausalGround/extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalGround/extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalGround/modelDescription.xml create mode 100644 testsuite/tests/resources/AcausalGround/terminalsAndIcons/terminalsAndIcons.xml create mode 100755 testsuite/tests/resources/AcausalRC/binaries/x86_64-linux/AcausalRC.so create mode 100644 testsuite/tests/resources/AcausalRC/extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalRC/extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalRC/modelDescription.xml create mode 100644 testsuite/tests/resources/AcausalRC/terminalsAndIcons/terminalsAndIcons.xml create mode 100755 testsuite/tests/resources/AcausalSource/binaries/x86_64-linux/AcausalSource.so create mode 100644 testsuite/tests/resources/AcausalSource/extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalSource/extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalSource/modelDescription.xml create mode 100644 testsuite/tests/resources/AcausalSource/terminalsAndIcons/terminalsAndIcons.xml create mode 100755 testsuite/tests/resources/AcausalStreamBoundary/binaries/x86_64-linux/AcausalStreamBoundary.so create mode 100644 testsuite/tests/resources/AcausalStreamBoundary/extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalStreamBoundary/extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalStreamBoundary/modelDescription.xml create mode 100644 testsuite/tests/resources/AcausalStreamBoundary/terminalsAndIcons/terminalsAndIcons.xml create mode 100755 testsuite/tests/resources/AcausalStreamVolume/binaries/x86_64-linux/AcausalStreamVolume.so create mode 100644 testsuite/tests/resources/AcausalStreamVolume/extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalStreamVolume/extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml create mode 100644 testsuite/tests/resources/AcausalStreamVolume/modelDescription.xml create mode 100644 testsuite/tests/resources/AcausalStreamVolume/terminalsAndIcons/terminalsAndIcons.xml create mode 100644 testsuite/tests/simulation/DaeMode3.py create mode 100644 testsuite/tests/simulation/DaeMode4.py create mode 100644 testsuite/tests/simulation/DaeMode5.py diff --git a/include/OMSimulator/Types.h b/include/OMSimulator/Types.h index fe4fa0401..3a8f9b0d7 100644 --- a/include/OMSimulator/Types.h +++ b/include/OMSimulator/Types.h @@ -140,6 +140,7 @@ typedef enum { typedef enum { oms_connection_single, ///< FMI conncection (directed & undelayed signal connection) oms_connection_bus, ///< Bus connection + oms_connection_acausal, ///< acausal connection between terminal members (potentials equal, flows sum to zero) } oms_connection_type_enu_t; /** diff --git a/src/OMSimulatorLib/ComponentFMU3ME.cpp b/src/OMSimulatorLib/ComponentFMU3ME.cpp index 9dbb5ddc4..d4aeb9aa4 100644 --- a/src/OMSimulatorLib/ComponentFMU3ME.cpp +++ b/src/OMSimulatorLib/ComponentFMU3ME.cpp @@ -203,6 +203,25 @@ oms::Component* oms::ComponentFMU3ME::NewComponent(const oms::ComRef& cref, oms: component->lsDae.getVersion() + "): " + std::to_string(component->lsDae.getResiduals().size()) + " residuals over " + std::to_string(component->derivativeVrs.size()) + " states and " + std::to_string(component->lsDae.getAlgebraicVariables().size()) + " algebraic variables"); + + // An acausal component: its terminals are connected here, and the + // residuals that stand for their flows are replaced by the equations of + // those connections. + if (component->lsDaeAcausal.load(tempDir.generic_string())) + { + if (!component->terminals.load(tempDir.generic_string())) + { + logWarning("FMU \"" + std::string(cref) + "\" carries an acausal manifest (" + LsDaeAcausalManifest::name() + + ") but no terminals; it cannot be connected acausally"); + component->lsDaeAcausal = LsDaeAcausalManifest(); + } + else + logInfo("FMU \"" + std::string(cref) + "\" is an acausal component (" + LsDaeAcausalManifest::name() + " " + + component->lsDaeAcausal.getVersion() + "): " + std::to_string(component->terminals.getTerminals().size()) + + " terminal(s), " + std::to_string(component->lsDaeAcausal.getConnectorResiduals().size()) + + " connector residual(s)" + (component->lsDaeAcausal.getStreamResiduals().empty() ? std::string() : + ", " + std::to_string(component->lsDaeAcausal.getStreamResiduals().size()) + " stream residual(s)")); + } } // update FMU info @@ -288,6 +307,43 @@ oms::Component* oms::ComponentFMU3ME::NewComponent(const oms::ComRef& cref, oms: component->connectors.push_back(new Connector(oms_causality_calculatedParameter, component->allVariables[i].getType(), component->allVariables[i].getCref(), component->getFullCref())); component->connectors.back()->setNumericType(component->allVariables[i].getNumericType()); } + // The members of an acausal component's terminals that are not inputs or + // outputs, connected acausally (SSP kind "unspecified"). A stream member is + // connected by its stream variable; the variable that stands for its inStream + // is the importer's to compute. + if (component->lsDaeAcausal.isValid()) + { + auto addConnector = [&](const Terminals::Terminal& terminal, const std::string& variableName) { + const std::string resolved = component->terminals.resolveAlias(variableName); + const Variable* v = component->getVariable(ComRef(resolved)); + if (!v) + { + logWarning("FMU \"" + std::string(cref) + "\": terminal \"" + terminal.name + "\" names an unknown variable \"" + variableName + "\""); + return; + } + if (resolved == variableName && (v->isInput() || v->isOutput())) + return; // a causal signal, already a connector + // A (calculated) parameter of a terminal, e.g. the fixed pressure of a + // boundary, is connected acausally like the other members. + for (auto it = component->connectors.begin(); it != component->connectors.end(); ++it) + if ((*it)->getName() == ComRef(variableName)) + { + delete *it; + component->connectors.erase(it); + break; + } + component->connectors.push_back(new Connector(oms_causality_bidir, v->getType(), ComRef(variableName), component->getFullCref())); + component->connectors.back()->setNumericType(v->getNumericType()); + }; + for (const Terminals::Terminal& terminal : component->terminals.getTerminals()) + { + for (const Terminals::Member& member : terminal.members) + addConnector(terminal, member.variableName); + for (const Terminals::StreamMember& member : terminal.streamMembers) + addConnector(terminal, member.outStreamVariableName); + } + } + component->connectors.push_back(NULL); component->element.setConnectors(&component->connectors[0]); @@ -2206,6 +2262,17 @@ oms_status_enu_t oms::ComponentFMU3ME::getDaeResiduals(double* residuals) return oms_status_ok; } +oms_status_enu_t oms::ComponentFMU3ME::getFloat64(const std::vector& vrs, double* values) +{ + CallClock callClock(clock); + if (vrs.empty()) + return oms_status_ok; + fmi3Status fmistatus = fmi3_getFloat64(instance, vrs.data(), vrs.size(), values, vrs.size()); + if (fmi3OK != fmistatus) + return logError_FMUCall("fmi3_getFloat64", this); + return oms_status_ok; +} + oms_status_enu_t oms::ComponentFMU3ME::getAlgebraicVariables(double* values) { CallClock callClock(clock); diff --git a/src/OMSimulatorLib/ComponentFMU3ME.h b/src/OMSimulatorLib/ComponentFMU3ME.h index 9cbd6aeb5..65a2d2457 100644 --- a/src/OMSimulatorLib/ComponentFMU3ME.h +++ b/src/OMSimulatorLib/ComponentFMU3ME.h @@ -134,6 +134,16 @@ namespace oms size_t getNumberOfDaeResiduals() const {return lsDae.getResiduals().size();} size_t getNumberOfAlgebraicVariables() const {return lsDae.getAlgebraicVariables().size();} oms_status_enu_t getDaeResiduals(double* residuals); + /** + * \brief Whether the FMU is an acausal component: one whose terminals are + * connected by the importer, which then replaces the residuals the + * acausal manifest names with the equations of its connections. + */ + bool isAcausalComponent() const {return lsDaeAcausal.isValid();} + const LsDaeAcausalManifest& getLsDaeAcausal() const {return lsDaeAcausal;} + const Terminals& getTerminals() const {return terminals;} + /// Read Float64 variables by value reference, in one call into the FMU. + oms_status_enu_t getFloat64(const std::vector& vrs, double* values); /// The value references of the continuous states, in the order /// fmi3GetContinuousStates uses — that of . const std::vector& getStateValueReferences() const {return stateVrs;} @@ -196,6 +206,9 @@ namespace oms /// fmi-ls-dae: the manifest, and whether this instance runs in DAE mode. LsDaeManifest lsDae; bool daeMode = false; + /// The acausal extension of the manifest, and the terminals it refers to. + LsDaeAcausalManifest lsDaeAcausal; + Terminals terminals; /// The value references of the model /// description, in the order fmi3GetContinuousStates uses for the states. std::vector derivativeVrs; diff --git a/src/OMSimulatorLib/Connection.cpp b/src/OMSimulatorLib/Connection.cpp index 0c72bfa34..fb0358f25 100644 --- a/src/OMSimulatorLib/Connection.cpp +++ b/src/OMSimulatorLib/Connection.cpp @@ -123,7 +123,9 @@ oms::Connection& oms::Connection::operator=(const oms::Connection& rhs) oms_status_enu_t oms::Connection::exportToSSD(pugi::xml_node &root) const { pugi::xml_node node; - if(type == oms_connection_single) + // An acausal connection is an ordinary SSD connection between connectors of + // kind "unspecified". + if(type == oms_connection_single || type == oms_connection_acausal) node = root.append_child(oms::ssp::Draft20180219::ssd::connection); else if(type == oms_connection_bus) node = root.append_child(oms::ssp::Draft20180219::bus_connection); diff --git a/src/OMSimulatorLib/Connector.cpp b/src/OMSimulatorLib/Connector.cpp index ed9a170b9..2e014fe0a 100644 --- a/src/OMSimulatorLib/Connector.cpp +++ b/src/OMSimulatorLib/Connector.cpp @@ -139,6 +139,8 @@ oms::Connector* oms::Connector::NewConnector(const pugi::xml_node& node, const s causality = oms_causality_parameter; else if (causalityString == "calculatedParameter") causality = oms_causality_calculatedParameter; + else if (causalityString == "unspecified") + causality = oms_causality_bidir; // an acausal connector (SSP 2.0) else { logError("Failed to import connector \"" + std::string(cref) + "\" with causality \"" + causalityString + "\""); @@ -231,6 +233,10 @@ oms_status_enu_t oms::Connector::exportToSSD(pugi::xml_node &root) const node.append_attribute("kind") = "calculatedParameter"; break; case oms_causality_bidir: + // SSP 2.0: a connector whose direction is given by the connections, as + // for an acausal connection of Modelica models + node.append_attribute("kind") = "unspecified"; + break; case oms_causality_undefined: break; } @@ -359,6 +365,9 @@ std::string oms::Connector::getCausalityString() const case oms_causality_calculatedParameter: return "calculatedParameter"; + case oms_causality_bidir: + return "unspecified"; + default: return std::string("unknown"); } diff --git a/src/OMSimulatorLib/LsDae.cpp b/src/OMSimulatorLib/LsDae.cpp index f6bfafbe0..c73666d01 100644 --- a/src/OMSimulatorLib/LsDae.cpp +++ b/src/OMSimulatorLib/LsDae.cpp @@ -112,3 +112,87 @@ bool oms::LsDaeManifest::load(const std::string& unzippedFmuPath) valid = true; return true; } + +bool oms::LsDaeAcausalManifest::load(const std::string& unzippedFmuPath) +{ + const filesystem::path manifest = filesystem::path(unzippedFmuPath) / filesystem::path(path()); + if (!filesystem::exists(manifest)) + return false; // not an acausal component + + pugi::xml_document doc; + pugi::xml_parse_result result = doc.load_file(manifest.string().c_str()); + if (!result) + { + logWarning("fmi-ls-dae-acausal: could not read \"" + manifest.string() + "\": " + std::string(result.description())); + return false; + } + + const pugi::xml_node root = doc.child("fmiDAEAcausalManifest"); + if (!root) + { + logWarning("fmi-ls-dae-acausal: \"" + manifest.string() + "\" has no element"); + return false; + } + + version = root.attribute("fmi-ls:fmi-ls-version").as_string(); + for (const pugi::xml_node& node : root.child("ConnectorResiduals").children("ConnectorResidual")) + connectorResiduals.push_back({node.attribute("valueReference").as_uint(), + node.attribute("terminal").as_string(), + node.attribute("variableName").as_string()}); + for (const pugi::xml_node& node : root.child("ConnectorResiduals").children("StreamResidual")) + streamResiduals.push_back({node.attribute("valueReference").as_uint(), + node.attribute("terminal").as_string(), + node.attribute("variableName").as_string()}); + + valid = true; + return true; +} + +bool oms::Terminals::load(const std::string& unzippedFmuPath) +{ + const filesystem::path file = filesystem::path(unzippedFmuPath) / filesystem::path(path()); + if (!filesystem::exists(file)) + return false; + + pugi::xml_document doc; + pugi::xml_parse_result result = doc.load_file(file.string().c_str()); + if (!result) + { + logWarning("could not read \"" + file.string() + "\": " + std::string(result.description())); + return false; + } + + // Only the top-level terminals: a nested terminal is not a connector an + // acausal connection is made between. + for (const pugi::xml_node& node : doc.child("fmiTerminalsAndIcons").child("Terminals").children("Terminal")) + { + Terminal terminal; + terminal.name = node.attribute("name").as_string(); + terminal.terminalKind = node.attribute("terminalKind").as_string(); + for (const pugi::xml_node& member : node.children("TerminalMemberVariable")) + terminal.members.push_back({member.attribute("variableName").as_string(), + member.attribute("memberName").as_string(), + member.attribute("variableKind").as_string()}); + for (const pugi::xml_node& member : node.children("TerminalStreamMemberVariable")) + terminal.streamMembers.push_back({member.attribute("inStreamMemberName").as_string(), + member.attribute("outStreamMemberName").as_string(), + member.attribute("inStreamVariableName").as_string(), + member.attribute("outStreamVariableName").as_string()}); + terminals.push_back(terminal); + } + + // The aliases, which a member may be: e.g. b.p of a component with a.p = b.p. + pugi::xml_document md; + const filesystem::path modelDescription = filesystem::path(unzippedFmuPath) / filesystem::path("modelDescription.xml"); + if (md.load_file(modelDescription.string().c_str())) + for (const pugi::xml_node& variable : md.child("fmiModelDescription").child("ModelVariables").children()) + for (const pugi::xml_node& alias : variable.children("Alias")) + aliasOf[alias.attribute("name").as_string()] = variable.attribute("name").as_string(); + return true; +} + +std::string oms::Terminals::resolveAlias(const std::string& variableName) const +{ + const auto it = aliasOf.find(variableName); + return it == aliasOf.end() ? variableName : it->second; +} diff --git a/src/OMSimulatorLib/LsDae.h b/src/OMSimulatorLib/LsDae.h index 309d9d064..40c3efcd1 100644 --- a/src/OMSimulatorLib/LsDae.h +++ b/src/OMSimulatorLib/LsDae.h @@ -37,6 +37,7 @@ #define _OMS_LSDAE_H_ #include +#include #include #include @@ -109,6 +110,93 @@ namespace oms std::vector> residualDependencies; std::vector continuousStateDerivatives; }; + /** + * \brief OpenModelica's extension of fmi-ls-dae for an acausal component: a + * model connected through its terminals after it was compiled. + * + * The component carries one residual per flow member of its terminals that, + * unconnected, reads flow = 0. An importer that connects the terminal drops + * those residuals and adds the equations of its connections instead: + * potentials equal, flows summing to zero. The manifest names them. + */ + class LsDaeAcausalManifest + { + public: + static const char* name() { return "org.openmodelica.fmi-ls-dae-acausal"; } + static const char* path() { return "extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml"; } + + /// A residual that stands for a flow of a terminal (ConnectorResidual), or + /// for inStream of a stream variable of a terminal (StreamResidual). + struct ConnectorResidual + { + uint32_t valueReference; ///< of the residual, one of LsDaeManifest::getResiduals() + std::string terminal; ///< the terminal the flow belongs to + std::string variableName; ///< the flow variable, or the stream variable + }; + + /// \return true if a manifest is there and could be read; a missing file is not an error. + bool load(const std::string& unzippedFmuPath); + + bool isValid() const { return valid; } + const std::string& getVersion() const { return version; } + const std::vector& getConnectorResiduals() const { return connectorResiduals; } + const std::vector& getStreamResiduals() const { return streamResiduals; } + + private: + bool valid = false; + std::string version; + std::vector connectorResiduals; + std::vector streamResiduals; + }; + + /** + * \brief The terminals of an FMI 3.0 FMU (terminalsAndIcons/terminalsAndIcons.xml), + * as far as an acausal connection needs them. + */ + class Terminals + { + public: + static const char* path() { return "terminalsAndIcons/terminalsAndIcons.xml"; } + + struct Member + { + std::string variableName; + std::string memberName; + std::string variableKind; ///< "signal" (a potential), "inflow" or "outflow" (a flow) + bool isFlow() const { return variableKind == "inflow" || variableKind == "outflow"; } + }; + + /// A stream variable and the variable that stands for its inStream + /// (TerminalStreamMemberVariable). + struct StreamMember + { + std::string inStreamMemberName; + std::string outStreamMemberName; + std::string inStreamVariableName; + std::string outStreamVariableName; + }; + + struct Terminal + { + std::string name; + std::string terminalKind; + std::vector members; + std::vector streamMembers; + }; + + /// \return true if the file is there and could be read. + bool load(const std::string& unzippedFmuPath); + + const std::vector& getTerminals() const { return terminals; } + + /// The variable a terminal member names: itself, or the variable it is an + /// FMI 3.0 of, which the FMI library does not list as a variable. + std::string resolveAlias(const std::string& variableName) const; + + private: + std::vector terminals; + std::map aliasOf; + }; } #endif diff --git a/src/OMSimulatorLib/System.cpp b/src/OMSimulatorLib/System.cpp index 1a7a26a20..1ae02c64d 100644 --- a/src/OMSimulatorLib/System.cpp +++ b/src/OMSimulatorLib/System.cpp @@ -776,7 +776,7 @@ oms_status_enu_t oms::System::exportToSSD(pugi::xml_node& node, Snapshot& snapsh for (const auto& connection : connections) { - if (connection && connection->getType() == oms_connection_single) + if (connection && (connection->getType() == oms_connection_single || connection->getType() == oms_connection_acausal)) ssdconnections.push_back(connection); else if (connection) busconnections.push_back(connection); @@ -1203,6 +1203,20 @@ oms_status_enu_t oms::System::addConnection(const oms::ComRef& crefA, const oms: if (getConnection(crefA, crefB)) return logError_ConnectionExistsAlready(crefA, crefB, this); + // An acausal connection: two members of terminals, neither one driving the + // other. A member may be connected any number of times; the system builds + // the connection sets from these and adds their equations itself. + if (conA->getCausality() == oms_causality_bidir || conB->getCausality() == oms_causality_bidir) + { + if (conA->getCausality() != conB->getCausality()) + return logError("Acausal connection between an acausal and a causal connector: " + std::string(crefA) + " -> " + std::string(crefB)); + if (conA->getType() != oms_signal_type_real || conB->getType() != oms_signal_type_real) + return logError("Acausal connection of a non-real connector: " + std::string(crefA) + " -> " + std::string(crefB)); + connections.back() = new oms::Connection(crefA, crefB, suppressUnitConversion, oms_connection_acausal); + connections.push_back(NULL); + return oms_status_ok; + } + auto componentA = getComponent(headA); if(componentA && componentA->getType() == oms_component_table) { diff --git a/src/OMSimulatorLib/SystemSC.cpp b/src/OMSimulatorLib/SystemSC.cpp index 4720250bd..f786590f5 100644 --- a/src/OMSimulatorLib/SystemSC.cpp +++ b/src/OMSimulatorLib/SystemSC.cpp @@ -44,6 +44,7 @@ #include "ssd/Tags.h" #include +#include #include #include #include @@ -256,6 +257,236 @@ static bool componentOwnsNode(const oms::Component* component, const oms::ComRef return false; } +oms_status_enu_t oms::SystemSC::collectAcausalConnections() +{ + acausalRows.clear(); + streamRows.clear(); + + // The members of the terminals of the components, by the name a connection + // of this system gives them ("R.p.v"). + struct Member + { + size_t component; + fmi3ValueReference vr; + bool flow; + double sign; ///< +1 for an inflow, -1 for an outflow, which the flow sum negates + int column; ///< its column if it is an unknown, else -1 + int residualRow; ///< for a flow or a stream, the row of the residual that stands for it, else -1 + std::string terminal; ///< "R.p", to find the rows of its terminal + bool stream = false; ///< a stream variable, connected by its outStream variable + fmi3ValueReference inStreamVr = 0; ///< for a stream, the variable that stands for its inStream + int inStreamColumn = -1; ///< and its column if it is an unknown + }; + std::map members; + std::map> rowsOfTerminal; + std::map> flowsOfTerminal; + + bool anyAcausalComponent = false; + for (size_t i = 0, j = 0; i < fmus.size(); ++i) + { + const size_t base = j; + j += nStates[i] + nAlgebraic[i]; + + ComponentFMU3ME* fmu3 = dynamic_cast(fmus[i]); + if (!fmu3 || !fmu3->isAcausalComponent()) + continue; + anyAcausalComponent = true; + const std::string prefix = std::string(fmus[i]->getCref()) + "."; + + // The unknowns of the component a member may be. + std::map columnOf; + if (daeFmus[i]) + { + const std::vector& stateVrs = daeFmus[i]->getStateValueReferences(); + for (size_t k = 0; k < stateVrs.size() && k < nStates[i]; ++k) + columnOf[stateVrs[k]] = static_cast(base + k); + const std::vector& algVrs = daeFmus[i]->getLsDae().getAlgebraicVariables(); + for (size_t k = 0; k < algVrs.size(); ++k) + columnOf[algVrs[k]] = static_cast(base + nStates[i] + k); + } + + // The row of each residual, and which of them stand for a flow. + std::map rowOfResidual; + const std::vector& residualVrs = fmu3->getLsDae().getResiduals(); + for (size_t k = 0; k < residualVrs.size(); ++k) + rowOfResidual[residualVrs[k]] = static_cast(base + k); + std::map rowOfFlow; + for (const LsDaeAcausalManifest::ConnectorResidual& r : fmu3->getLsDaeAcausal().getConnectorResiduals()) + { + const auto it = rowOfResidual.find(r.valueReference); + if (it == rowOfResidual.end()) + return logError("system \"" + std::string(getFullCref()) + "\": the acausal manifest of \"" + std::string(fmus[i]->getFullCref()) + + "\" names residual " + std::to_string(r.valueReference) + " for \"" + r.variableName + "\", which is not one of its fmi-ls-dae residuals"); + rowOfFlow[r.variableName] = it->second; + rowsOfTerminal[prefix + r.terminal].push_back(it->second); + } + std::map rowOfStream; + for (const LsDaeAcausalManifest::ConnectorResidual& r : fmu3->getLsDaeAcausal().getStreamResiduals()) + { + const auto it = rowOfResidual.find(r.valueReference); + if (it == rowOfResidual.end()) + return logError("system \"" + std::string(getFullCref()) + "\": the acausal manifest of \"" + std::string(fmus[i]->getFullCref()) + + "\" names residual " + std::to_string(r.valueReference) + " for inStream of \"" + r.variableName + "\", which is not one of its fmi-ls-dae residuals"); + rowOfStream[r.variableName] = it->second; + rowsOfTerminal[prefix + r.terminal].push_back(it->second); + } + + for (const Terminals::Terminal& terminal : fmu3->getTerminals().getTerminals()) + { + for (const Terminals::Member& m : terminal.members) + { + const Variable* v = fmu3->getVariable(ComRef(fmu3->getTerminals().resolveAlias(m.variableName))); + if (!v) + continue; + const fmi3ValueReference vr = v->getValueReferenceFMI3(); + const auto column = columnOf.find(vr); + const auto row = rowOfFlow.find(m.variableName); + members[prefix + m.variableName] = {i, vr, m.isFlow(), m.variableKind == "outflow" ? -1.0 : 1.0, + column == columnOf.end() ? -1 : column->second, + row == rowOfFlow.end() ? -1 : row->second, + prefix + terminal.name}; + if (m.isFlow()) + flowsOfTerminal[prefix + terminal.name].push_back(prefix + m.variableName); + } + for (const Terminals::StreamMember& m : terminal.streamMembers) + { + const Variable* out = fmu3->getVariable(ComRef(fmu3->getTerminals().resolveAlias(m.outStreamVariableName))); + const Variable* in = fmu3->getVariable(ComRef(fmu3->getTerminals().resolveAlias(m.inStreamVariableName))); + if (!out || !in) + continue; + const fmi3ValueReference vr = out->getValueReferenceFMI3(); + const fmi3ValueReference inVr = in->getValueReferenceFMI3(); + const auto column = columnOf.find(vr); + const auto inColumn = columnOf.find(inVr); + const auto row = rowOfStream.find(m.outStreamVariableName); + Member member = {i, vr, false, 1.0, column == columnOf.end() ? -1 : column->second, + row == rowOfStream.end() ? -1 : row->second, prefix + terminal.name}; + member.stream = true; + member.inStreamVr = inVr; + member.inStreamColumn = inColumn == columnOf.end() ? -1 : inColumn->second; + members[prefix + m.outStreamVariableName] = member; + } + } + } + + // The connection sets: the members joined by acausal connections. + std::map parent; + std::function find = [&](const std::string& x) -> std::string { + auto it = parent.find(x); + if (it == parent.end() || it->second == x) + return x; + return it->second = find(it->second); + }; + + size_t nAcausalConnections = 0; + for (const Connection* connection : getConnections()) + { + if (!connection || connection->getType() != oms_connection_acausal) + continue; + ++nAcausalConnections; + const std::string a = std::string(connection->getSignalA()); + const std::string b = std::string(connection->getSignalB()); + for (const std::string& name : {a, b}) + { + const auto it = members.find(name); + if (it == members.end()) + return logError("system \"" + std::string(getFullCref()) + "\": \"" + name + "\" is acausally connected but is not a terminal member of an acausal component"); + if (!daeFmus[it->second.component]) + return logError("system \"" + std::string(getFullCref()) + "\": \"" + name + "\" is acausally connected but its component does not run in DAE mode (fmi-ls-dae)"); + if (parent.find(name) == parent.end()) + parent[name] = name; + } + if (members[a].flow != members[b].flow) + return logError("system \"" + std::string(getFullCref()) + "\": acausal connection between a flow and a potential: " + a + " -> " + b); + if (members[a].stream != members[b].stream) + return logError("system \"" + std::string(getFullCref()) + "\": acausal connection between a stream variable and a potential: " + a + " -> " + b); + parent[find(a)] = find(b); + } + + if (0 == nAcausalConnections) + { + if (anyAcausalComponent) + logInfo("system \"" + std::string(getFullCref()) + "\": no acausal connections; the terminals of its acausal components stay unconnected (flow = 0)"); + return oms_status_ok; + } + + std::map> sets; + for (const auto& p : parent) + sets[find(p.first)].push_back(p.first); + + // The rows the connected flows free, and the equations that take them. + std::vector freedRows; + std::vector rows; + auto termOf = [&](const Member& m, double factor) -> AcausalTerm { + return {m.component, m.vr, factor, m.column, rowsOfTerminal[m.terminal]}; + }; + for (const auto& set : sets) + { + const std::vector& names = set.second; + const Member& first = members[names.front()]; + if (first.flow) + { + AcausalRow row{0, {}}; + for (const std::string& name : names) + { + const Member& m = members[name]; + if (m.residualRow < 0) + return logError("system \"" + std::string(getFullCref()) + "\": the flow \"" + name + "\" is connected, but its component's acausal manifest names no residual for it"); + freedRows.push_back(m.residualRow); + row.terms.push_back(termOf(m, m.sign)); + } + rows.push_back(row); + } + else if (first.stream) + { + // Each stream variable gets the mixing rule of the others, in the row of + // the residual that stood for its own inStream. + for (const std::string& name : names) + { + const Member& m = members[name]; + if (m.residualRow < 0) + return logError("system \"" + std::string(getFullCref()) + "\": the stream variable \"" + name + "\" is connected, but its component's acausal manifest names no residual for its inStream"); + StreamRow row{static_cast(m.residualRow), {m.component, m.inStreamVr, 1.0, m.inStreamColumn, rowsOfTerminal[m.terminal]}, {}}; + for (const std::string& other : names) + { + if (other == name) + continue; + const Member& o = members[other]; + const std::vector& flows = flowsOfTerminal[o.terminal]; + if (flows.size() != 1) + return logError("system \"" + std::string(getFullCref()) + "\": the stream variable \"" + other + "\" is connected, but its terminal has " + + std::to_string(flows.size()) + " flow variables; the mixing rule needs exactly one"); + const Member& f = members[flows.front()]; + row.others.push_back({termOf(o, 1.0), termOf(f, f.sign)}); + } + streamRows.push_back(row); + } + } + else + { + for (size_t k = 1; k < names.size(); ++k) + rows.push_back({0, {termOf(first, 1.0), termOf(members[names[k]], -1.0)}}); + } + } + + if (rows.size() != freedRows.size()) + return logError("system \"" + std::string(getFullCref()) + "\": the acausal connections give " + std::to_string(rows.size()) + + " equation(s) for the " + std::to_string(freedRows.size()) + " residual(s) of the connected flows; " + "every connection set of a terminal needs as many potentials as flows"); + + std::sort(freedRows.begin(), freedRows.end()); + for (size_t k = 0; k < rows.size(); ++k) + { + rows[k].row = static_cast(freedRows[k]); + acausalRows.push_back(rows[k]); + } + + logInfo("system \"" + std::string(getFullCref()) + "\": " + std::to_string(nAcausalConnections) + " acausal connection(s) in " + + std::to_string(sets.size()) + " connection set(s) replace " + std::to_string(freedRows.size()) + " connector residual(s)" + + (streamRows.empty() ? std::string() : " and " + std::to_string(streamRows.size()) + " stream residual(s)")); + return oms_status_ok; +} + void oms::SystemSC::buildDaeJacobianSparsity() { daeJacRowsByColumn.clear(); @@ -402,6 +633,44 @@ void oms::SystemSC::buildDaeJacobianSparsity() columnsByRow[row].push_back(static_cast(c)); } + // An acausal connection row reaches the members it sums: each one's own + // column if it is an unknown, and whatever the residuals of its terminal + // reached — those take the flow and the potentials as arguments, so their + // dependencies cover what a member that is computed by the component (an + // alias, a constant) depends on. + // A stream row reaches the same for its inStream variable and for the stream + // and flow variables of the other members. + if (!acausalRows.empty() || !streamRows.empty()) + { + const std::vector> componentColumnsByRow = columnsByRow; + auto addColumns = [&](const AcausalTerm& term, std::vector& columns) { + if (term.column >= 0) + columns.push_back(term.column); + if (term.terminalRows.empty()) + columnsAround(term.component, columns); + for (const int r : term.terminalRows) + columns.insert(columns.end(), componentColumnsByRow[r].begin(), componentColumnsByRow[r].end()); + }; + for (const AcausalRow& acausal : acausalRows) + { + std::vector& columns = columnsByRow[acausal.row]; + columns.clear(); + for (const AcausalTerm& term : acausal.terms) + addColumns(term, columns); + } + for (const StreamRow& stream : streamRows) + { + std::vector& columns = columnsByRow[stream.row]; + columns.clear(); + addColumns(stream.inStream, columns); + for (const StreamTerm& other : stream.others) + { + addColumns(other.stream, columns); + addColumns(other.flow, columns); + } + } + } + // Transpose, and drop the duplicates a conservative row may have collected. daeJacRowsByColumn.assign(n, {}); size_t nnz = 0; @@ -682,6 +951,55 @@ int oms::ida_res(sunrealtype t, N_Vector yy, N_Vector yp, N_Vector rr, void* use NV_Ith_S(rr, j) = value - NV_Ith_S(yy, j); } + // The acausal connection rows, in place of the residuals of the connected + // flows: what the terminal members now hold, summed. + for (const SystemSC::AcausalRow& acausal : system->acausalRows) + { + double sum = 0.0; + for (const SystemSC::AcausalTerm& term : acausal.terms) + { + double value = 0.0; + if (oms_status_ok != system->daeFmus[term.component]->getFloat64({term.vr}, &value)) + return -1; + sum += term.factor * value; + } + NV_Ith_S(rr, acausal.row) = sum; + } + + // The stream rows, in place of the residuals of the connected streams' + // inStream: the mixing rule of the other members of the set. + for (const SystemSC::StreamRow& stream : system->streamRows) + { + double inStream = 0.0; + if (oms_status_ok != system->daeFmus[stream.inStream.component]->getFloat64({stream.inStream.vr}, &inStream)) + return -1; + double mixed = 0.0; + if (stream.others.size() == 1) + { + if (oms_status_ok != system->daeFmus[stream.others[0].stream.component]->getFloat64({stream.others[0].stream.vr}, &mixed)) + return -1; + } + else + { + // The regularization of OpenModelica's flowThreshold, so that a set + // without flow out of the others still has a mixing temperature. + const double eps = 1e-7; + double num = 0.0, den = 0.0; + for (const SystemSC::StreamTerm& other : stream.others) + { + double h = 0.0, m = 0.0; + if (oms_status_ok != system->daeFmus[other.stream.component]->getFloat64({other.stream.vr}, &h) || + oms_status_ok != system->daeFmus[other.flow.component]->getFloat64({other.flow.vr}, &m)) + return -1; + const double w = std::max(-other.flow.factor * m, eps); + num += w * h; + den += w; + } + mixed = num / den; + } + NV_Ith_S(rr, stream.row) = inStream - mixed; + } + return 0; } @@ -908,6 +1226,21 @@ oms_status_enu_t oms::SystemSC::initialize() nDaeUnknowns += liftedConnections.size(); } + // Acausal connections are rows of the DAE system, so they need one; and + // without them an acausal component simply keeps flow = 0 at its terminals. + if (daeMode) + { + if (oms_status_ok != collectAcausalConnections()) + return oms_status_error; + } + else + { + for (const Connection* connection : getConnections()) + if (connection && connection->getType() == oms_connection_acausal) + return logError("system "" + std::string(getFullCref()) + "": acausal connection " + std::string(connection->getSignalA()) + " -> " + + std::string(connection->getSignalB()) + " needs its components in DAE mode (fmi-ls-dae)"); + } + if (daeMode && oms_solver_sc_ida != solverMethod) { // DAE mode is not a choice the user makes: a component that declares a DAE @@ -1304,6 +1637,8 @@ oms_status_enu_t oms::SystemSC::terminate() daeMode = false; nDaeUnknowns = 0; liftedConnections.clear(); + acausalRows.clear(); + streamRows.clear(); liftedLoops.clear(); daeJacRowsByColumn.clear(); daeJacColorOfColumn.clear(); @@ -1415,6 +1750,8 @@ oms_status_enu_t oms::SystemSC::reset() daeMode = false; nDaeUnknowns = 0; liftedConnections.clear(); + acausalRows.clear(); + streamRows.clear(); liftedLoops.clear(); daeJacRowsByColumn.clear(); daeJacColorOfColumn.clear(); diff --git a/src/OMSimulatorLib/SystemSC.h b/src/OMSimulatorLib/SystemSC.h index af689da2b..0eaca74b5 100644 --- a/src/OMSimulatorLib/SystemSC.h +++ b/src/OMSimulatorLib/SystemSC.h @@ -122,6 +122,17 @@ namespace oms void buildDaeJacobianSparsity(); /// Distribute the DAE unknowns y (and the state derivatives yp) into the FMUs. oms_status_enu_t setDaePoint(double t, N_Vector yy, N_Vector yp); + /** + * \brief The equations of the acausal connections, in place of the + * residuals that stood for the connected flows and inStreams. + * + * Builds the connection sets from the system's acausal connections and + * fills acausalRows and streamRows. Fails if a set mixes flows, potentials + * and stream variables, if the sets do not give as many equations as there + * are flow residuals to replace, or if a connected stream variable's terminal + * does not have exactly one flow. + */ + oms_status_enu_t collectAcausalConnections(); /// Read the point the FMUs hold back into y and yp, after a solve or an event. oms_status_enu_t getDaePoint(N_Vector yy, N_Vector yp); /// IDACalcIC over the current point, then read the consistent point back. @@ -186,6 +197,51 @@ namespace oms /// updateInputsInternal so the inner solver leaves those alone. std::vector liftedLoops; + /** + * An acausal connection set gives the equations of a node: for potentials + * v_1 = v_2 = ... = v_k, k-1 rows v_1 - v_m = 0, and for flows the single + * row sum(i_m) = 0. They take the rows of the residuals the components' + * acausal manifests name for the connected flows (flow = 0, which is what an + * unconnected terminal means), so the system stays square and the layout of + * the unknowns and the rows is the one above. + */ + struct AcausalTerm + { + size_t component; ///< index into fmus + fmi3ValueReference vr; ///< the terminal member + double factor; ///< its factor in the row + int column; ///< its column if it is an unknown, else -1 + std::vector terminalRows; ///< the rows of the residuals that stood for its terminal's flows + }; + struct AcausalRow + { + size_t row; ///< the replaced residual's row + std::vector terms; + }; + std::vector acausalRows; + + /** + * A stream variable h_i of a connection set takes the row of the residual + * that stood for its inStream (inStream(h_i) = h_i, an unconnected terminal) + * with the mixing rule of the set: + * s_i - sum_{j<>i} w_j*h_j / sum_{j<>i} w_j = 0, w_j = max(-m_j, eps) + * where s_i is the variable that stands for inStream(h_i) and m_j is the flow + * of the terminal of h_j, into its component. With two members it is + * s_i - h_j = 0. + */ + struct StreamTerm + { + AcausalTerm stream; ///< h_j + AcausalTerm flow; ///< m_j, factor -1 for an outflow + }; + struct StreamRow + { + size_t row; ///< the replaced residual's row + AcausalTerm inStream; ///< s_i + std::vector others; + }; + std::vector streamRows; + /// Per unknown, the rows that reach it; empty when the structure is unknown /// and IDA differences the Jacobian itself. std::vector> daeJacRowsByColumn; diff --git a/src/OMSimulatorPython/connection.py b/src/OMSimulatorPython/connection.py index 95b1e92fe..0e6e6cdfa 100644 --- a/src/OMSimulatorPython/connection.py +++ b/src/OMSimulatorPython/connection.py @@ -135,6 +135,11 @@ def importFromNode(node, root): def is_validConnection(source_owner: str, source_kind: Causality, dest_owner: str, dest_kind: Causality) -> bool: """Return True if the connection is allowed according to SSP 2.0 table.""" + # An acausal connection: two connectors of kind "unspecified" of elements, + # neither one driving the other. + if source_kind == Causality.unspecified or dest_kind == Causality.unspecified: + return source_owner == "Element" and dest_owner == "Element" and source_kind == dest_kind + # Allowed causality by owner type allowed_source_by_owner = { "System": {Causality.input, Causality.parameter}, diff --git a/src/OMSimulatorPython/fmu.py b/src/OMSimulatorPython/fmu.py index 16775972a..c049384b4 100644 --- a/src/OMSimulatorPython/fmu.py +++ b/src/OMSimulatorPython/fmu.py @@ -39,7 +39,7 @@ from lxml import etree as ET from OMSimulator.connector import Connector from OMSimulator.unit import Unit -from OMSimulator.variable import Variable, SignalType +from OMSimulator.variable import Variable, SignalType, Causality from OMSimulator.values import Values from OMSimulator import namespace, utils from OMSimulator.capi import Capi, Status @@ -133,6 +133,12 @@ def __init__(self, fmu_path: Union[str, Path], instanceName: str = None): self.maxOutputDerivativeOrder = 0 self._variables = [] + # An acausal component (OpenModelica's fmi-ls-dae-acausal extension): its + # terminals, {name: {memberName: (variableName, variableKind)}}, connected + # acausally by the importer. Empty for any other FMU. + self._acausalTerminals = {} + # FMI 3.0 names, {alias: Variable} + self._aliases = {} self._states = [] self._unitDefinitions = [] self._enumerationDefinitions = [] @@ -294,9 +300,39 @@ def _load_model_description(self): self._parse_typeDefinitions_fmi3(model_description) # Parse ModelVariables self._parse_variables_fmi3(model_description) + # Parse the terminals of an acausal component + self._parse_acausal_terminals(fmu_zip) except ET.XMLSyntaxError as e: raise ValueError(f'Error parsing {model_desc_name}: {e}') + _ACAUSAL_MANIFEST = 'extra/org.openmodelica.fmi-ls-dae-acausal/fmi-ls-manifest.xml' + _TERMINALS = 'terminalsAndIcons/terminalsAndIcons.xml' + + def _parse_acausal_terminals(self, fmu_zip): + '''The terminals of an acausal component: an FMU with an fmi-ls-dae-acausal + manifest and FMI 3.0 terminals. Their members become connectors of kind + "unspecified", connected acausally.''' + names = fmu_zip.namelist() + if self._ACAUSAL_MANIFEST not in names or self._TERMINALS not in names: + return + with fmu_zip.open(self._TERMINALS) as f: + terminals = ET.fromstring(f.read()) + for terminal in terminals.findall('./Terminals/Terminal'): + members = {} + for member in terminal.findall('./TerminalMemberVariable'): + members[member.get('memberName')] = (member.get('variableName'), member.get('variableKind')) + # A stream member is connected by its stream variable; the variable that + # stands for its inStream is computed by the importer. + for member in terminal.findall('./TerminalStreamMemberVariable'): + members[member.get('outStreamMemberName')] = (member.get('outStreamVariableName'), 'stream') + self._acausalTerminals[terminal.get('name')] = members + + @property + def acausalTerminals(self) -> dict: + '''The terminals of an acausal component, {name: {memberName: (variableName, variableKind)}}; + variableKind is "stream" for the stream variable of a TerminalStreamMemberVariable.''' + return self._acausalTerminals + def _parse_default_experiment(self, model_description): """Extract default experiment settings from modelDescription.xml.""" default_experiment = model_description.find('.//DefaultExperiment') @@ -414,6 +450,10 @@ def _parse_variables_fmi3(self, model_description): self._variables.append(variable) + # The aliases, which an acausal component's terminal member may be + for alias in element.findall("Alias"): + self._aliases[alias.get("name")] = variable + # Raise an error if ModelVariables section is missing if not model_variables: raise Exception('ModelVariables section not found in modelDescription.xml') @@ -458,7 +498,12 @@ def _parse_typeDefinitions_fmi3(self, model_description): def makeConnectors(self): connectors = [] + # The members of an acausal component's terminals that are not inputs or + # outputs are connected acausally, (calculated) parameters among them. + acausal = {variableName for members in self._acausalTerminals.values() for (variableName, _) in members.values()} for var in self.variables: + if (var.isParameter() or var.isCalculatedParameter()) and str(var.name) in acausal: + continue if var.isInput() or var.isOutput() or var.isParameter() or var.isCalculatedParameter(): connector = Connector(var.name, var.causality, var.signal_type) connector.setUnit(var.unit) @@ -468,6 +513,20 @@ def makeConnectors(self): if enum.name == var.declaredType: connector.setEnumerationName(var.declaredType) connectors.append(connector) + for var in self.variables: + if str(var.name) in acausal and not (var.isInput() or var.isOutput()): + connector = Connector(var.name, Causality.unspecified, var.signal_type) + connector.setUnit(var.unit) + connector.description = var.description + connectors.append(connector) + # a member that is an alias of another variable, e.g. b.p with a.p = b.p + for name in sorted(acausal): + var = self._aliases.get(name) + if var is not None: + connector = Connector(name, Causality.unspecified, var.signal_type) + connector.setUnit(var.unit) + connector.description = var.description + connectors.append(connector) return connectors def getVariableByName(self, name: str) -> Variable | None: diff --git a/src/OMSimulatorPython/system.py b/src/OMSimulatorPython/system.py index 71bc52b1e..512ccba26 100644 --- a/src/OMSimulatorPython/system.py +++ b/src/OMSimulatorPython/system.py @@ -718,14 +718,42 @@ def _findConnector(self, element_name, connector_name): raise ValueError(f"Connector '{connector_name}' not found in element '{element_name or self.name}'") + def _acausalTerminal(self, element_name, connector_name): + """The members of a terminal of an acausal component, or None.""" + if not element_name: + return None + element = self.elements.get(CRef(element_name)) + fmu = getattr(element, 'fmu', None) + if fmu is None: + return None + return fmu.acausalTerminals.get(str(connector_name)) + def addConnection(self, startElement : str, startConnector : str, endElement : str, endConnector : str, suppressUnitConversion : bool = False): """Adds a connection to the system.""" + # Two terminals of acausal components: connect them member by member. The + # SSD holds the member connections, which is all SSP has a notion of. + startTerminal = self._acausalTerminal(startElement, startConnector) + endTerminal = self._acausalTerminal(endElement, endConnector) + if startTerminal is not None and endTerminal is not None: + if set(startTerminal.keys()) != set(endTerminal.keys()): + raise ValueError(f"Terminals '{startElement}.{startConnector}' and '{endElement}.{endConnector}' have different members: {sorted(startTerminal.keys())} and {sorted(endTerminal.keys())}") + for memberName, (startVariable, startKind) in startTerminal.items(): + (endVariable, endKind) = endTerminal[memberName] + if startKind != endKind: + raise ValueError(f"Member '{memberName}' is a {startKind} of '{startElement}.{startConnector}' but a {endKind} of '{endElement}.{endConnector}'") + self.addConnection(startElement, startVariable, endElement, endVariable, suppressUnitConversion) + return # Resolve source connector owner and causality (source_owner, source_kind) = self._findConnector(startElement, startConnector) # Resolve destination connector owner and causality (dest_owner, dest_kind) = self._findConnector(endElement, endConnector) - if Connection.is_validConnection(source_owner, source_kind, dest_owner, dest_kind): + if source_kind == Causality.unspecified and dest_kind == Causality.unspecified: + # an acausal connector may be connected any number of times + if not Connection.is_validConnection(source_owner, source_kind, dest_owner, dest_kind): + raise ValueError(f"info: Invalid acausal connection '{startElement}.{startConnector}'->'{endElement}.{endConnector}': both must be terminal members of components") + self.connections.append(Connection(startElement, startConnector, endElement, endConnector, suppressUnitConversion)) + elif Connection.is_validConnection(source_owner, source_kind, dest_owner, dest_kind): self.isConnectorAlreadyConnected(startElement, startConnector, endElement, endConnector) self.connections.append(Connection(startElement, startConnector, endElement, endConnector, suppressUnitConversion)) # flipped connection diff --git a/src/OMSimulatorPython/variable.py b/src/OMSimulatorPython/variable.py index 3111522dd..af3a491e6 100644 --- a/src/OMSimulatorPython/variable.py +++ b/src/OMSimulatorPython/variable.py @@ -49,6 +49,10 @@ class Causality(Enum): # states, of variables, of an array's dimensions) and can only be set in # Configuration Mode. fmi-ls-dae's DAE-mode switch is one. structuralParameter = 6 + # SSP 2.0: a connector whose direction is given by the connections, as for an + # acausal connection of Modelica models: the members of an acausal + # component's terminals. + unspecified = 7 class SignalType(Enum): diff --git a/testsuite/tests/resources/AcausalGround/binaries/x86_64-linux/AcausalGround.so b/testsuite/tests/resources/AcausalGround/binaries/x86_64-linux/AcausalGround.so new file mode 100755 index 0000000000000000000000000000000000000000..78087c8be7cb7c5f3a7652b1eff0fc0bee01a2be GIT binary patch literal 3814504 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