From 5e42f6a48d2477b8b2321cf8c31d09bd870466fa Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 00:27:26 -0300 Subject: [PATCH 1/7] Fem: Add ParaView .pvd file importer --- src/Mod/Fem/App/FemPostPipeline.cpp | 50 +++++++++++++++++++++++++++-- src/Mod/Fem/App/FemPostPipeline.h | 4 ++- src/Mod/Fem/Init.py | 2 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Mod/Fem/App/FemPostPipeline.cpp b/src/Mod/Fem/App/FemPostPipeline.cpp index 6a25b79f1d..4d3f118161 100644 --- a/src/Mod/Fem/App/FemPostPipeline.cpp +++ b/src/Mod/Fem/App/FemPostPipeline.cpp @@ -44,6 +44,7 @@ #include #include +#include #include "FemMesh.h" #include "FemMeshObject.h" @@ -130,10 +131,10 @@ bool FemPostPipeline::canRead(Base::FileInfo File) { // from FemResult only unstructural mesh is supported in femvtktoools.cpp - return File.hasExtension({"vtk", "vtp", "vts", "vtr", "vti", "vtu", "pvtu", "vtm"}); + return File.hasExtension({"vtk", "vtp", "vts", "vtr", "vti", "vtu", "pvtu", "vtm", "pvd"}); } -vtkSmartPointer FemPostPipeline::dataObjectFromFile(Base::FileInfo File) +vtkSmartPointer FemPostPipeline::dataObjectFromFile(const Base::FileInfo& File) { // checking on the file if (!File.isReadable()) { @@ -164,10 +165,55 @@ vtkSmartPointer FemPostPipeline::dataObjectFromFile(Base::FileInf else if (File.hasExtension("vtm")) { return readXMLFile(File.filePath()); } + else if (File.hasExtension("pvd")) { + return readPVD(File); + } throw Base::FileException("Unknown extension"); } +vtkSmartPointer FemPostPipeline::readPVD(const Base::FileInfo& file) +{ + std::string path = file.filePath(); + + std::ifstream ifstr(path, std::ios::in | std::ios::binary); + Base::XMLReader reader(path.c_str(), ifstr); + reader.readElement("DataSet"); + std::map values; + std::vector files; + while (strcmp(reader.localName(), "DataSet") == 0) { + values.emplace(std::make_pair(reader.getAttribute("timestep"), + reader.getAttribute("file"))); + reader.readNextElement(); + } + + auto timeInfo = vtkSmartPointer::New(); + timeInfo->SetName("TimeInfo"); + timeInfo->InsertNextValue("TimeStep"); + // set unit to empty string + timeInfo->InsertNextValue(""); + + auto multiBlock = vtkSmartPointer::New(); + multiBlock->GetFieldData()->AddArray(timeInfo); + + int i = 0; + std::string dir = file.dirPath(); + for (auto v : values) { + Base::FileInfo fi(dir + "/" + v.second); + auto data = dataObjectFromFile(fi); + auto time = vtkSmartPointer::New(); + time->SetName("TimeValue"); + time->InsertNextValue(v.first); + data->GetFieldData()->AddArray(time); + data->GetFieldData()->AddArray(timeInfo); + + multiBlock->SetBlock(i, data); + ++i; + } + + return multiBlock; +} + void FemPostPipeline::read(Base::FileInfo File) { Data.setValue(dataObjectFromFile(File)); diff --git a/src/Mod/Fem/App/FemPostPipeline.h b/src/Mod/Fem/App/FemPostPipeline.h index 62a5dfdad7..08ec619aea 100644 --- a/src/Mod/Fem/App/FemPostPipeline.h +++ b/src/Mod/Fem/App/FemPostPipeline.h @@ -129,7 +129,9 @@ private: reader->Update(); return reader->GetOutput(); } - vtkSmartPointer dataObjectFromFile(Base::FileInfo File); + vtkSmartPointer dataObjectFromFile(const Base::FileInfo& File); + // read .pvd file into multiblock dataset + vtkSmartPointer readPVD(const Base::FileInfo& file); }; } // namespace Fem diff --git a/src/Mod/Fem/Init.py b/src/Mod/Fem/Init.py index 7da674c38d..33bbd3d613 100644 --- a/src/Mod/Fem/Init.py +++ b/src/Mod/Fem/Init.py @@ -91,7 +91,7 @@ FreeCAD.addImportType("FEM result Z88 displacements (*.txt *.TXT)", "feminout.im if "BUILD_FEM_VTK" in FreeCAD.__cmake__: FreeCAD.addImportType( - "FEM result VTK (*.vtk *.VTK *.vtu *.VTU *.pvtu *.PVTU *.vtm .VTM)", + "FEM result VTK (*.vtk *.VTK *.vtu *.VTU *.pvtu *.PVTU *.vtm *.VTM, *.pvd)", "feminout.importVTKResults", ) FreeCAD.addExportType( From d018b3f8fbd44531311133f0415cdce03e726fd1 Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 00:38:41 -0300 Subject: [PATCH 2/7] Fem: Add method to rename mesh group --- src/Mod/Fem/App/FemMesh.cpp | 8 ++++++++ src/Mod/Fem/App/FemMesh.h | 2 ++ src/Mod/Fem/App/FemMesh.pyi | 8 ++++++++ src/Mod/Fem/App/FemMeshPyImp.cpp | 12 ++++++++++++ 4 files changed, 30 insertions(+) diff --git a/src/Mod/Fem/App/FemMesh.cpp b/src/Mod/Fem/App/FemMesh.cpp index 52e0ed8fa0..4a493c0d04 100644 --- a/src/Mod/Fem/App/FemMesh.cpp +++ b/src/Mod/Fem/App/FemMesh.cpp @@ -2705,3 +2705,11 @@ bool FemMesh::removeGroup(int GroupId) { return this->getSMesh()->RemoveGroup(GroupId); } + +void FemMesh::renameGroup(int id, const std::string& name) +{ + SMESH_Group* grp = this->getSMesh()->GetGroup(id); + if (grp) { + grp->SetName(name.c_str()); + } +} diff --git a/src/Mod/Fem/App/FemMesh.h b/src/Mod/Fem/App/FemMesh.h index af79553857..3edc6a1a17 100644 --- a/src/Mod/Fem/App/FemMesh.h +++ b/src/Mod/Fem/App/FemMesh.h @@ -180,6 +180,8 @@ public: void addGroupElements(int, const std::set&); /// Remove group (Name due to similarity to SMESH basis functions) bool removeGroup(int); + /// Rename group + void renameGroup(int id, const std::string& name); //@} diff --git a/src/Mod/Fem/App/FemMesh.pyi b/src/Mod/Fem/App/FemMesh.pyi index 04f2fa8f3b..c48dec78c6 100644 --- a/src/Mod/Fem/App/FemMesh.pyi +++ b/src/Mod/Fem/App/FemMesh.pyi @@ -239,6 +239,14 @@ class FemMesh(ComplexGeoData): Returns boolean.""" ... + @constmethod + def renameGroup(self) -> Any: + """Rename a group with a given group ID + renameGroup(id, name) + groupid: int + name: string""" + ... + @constmethod def getElementType(self) -> Any: """Return the element type of a given ID""" diff --git a/src/Mod/Fem/App/FemMeshPyImp.cpp b/src/Mod/Fem/App/FemMeshPyImp.cpp index c4450193b4..bfb923ebc7 100644 --- a/src/Mod/Fem/App/FemMeshPyImp.cpp +++ b/src/Mod/Fem/App/FemMeshPyImp.cpp @@ -1718,6 +1718,18 @@ PyObject* FemMeshPy::removeGroup(PyObject* args) const return PyBool_FromLong((long)(getFemMeshPtr()->removeGroup(theId))); } +PyObject* FemMeshPy::renameGroup(PyObject* args) const +{ + int id; + const char* name; + if (!PyArg_ParseTuple(args, "is", &id, &name)) { + return nullptr; + } + + getFemMeshPtr()->renameGroup(id, name); + + Py_Return; +} PyObject* FemMeshPy::getElementType(PyObject* args) const { From 275ece0bb223f1bf3aec9013d4349034557ac064 Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 00:45:09 -0300 Subject: [PATCH 3/7] Fem: Create element groups with gmsh --- src/Mod/Fem/femmesh/gmshtools.py | 39 ++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Mod/Fem/femmesh/gmshtools.py b/src/Mod/Fem/femmesh/gmshtools.py index a7ddf539f7..2ce06baa47 100644 --- a/src/Mod/Fem/femmesh/gmshtools.py +++ b/src/Mod/Fem/femmesh/gmshtools.py @@ -229,18 +229,13 @@ class GmshTools: def update_properties(self): self.mesh_obj.FemMesh = Fem.read(self.temp_file_mesh) + self.rename_groups() def create_mesh(self): - try: - self.update_mesh_data() - self.get_tmp_file_paths() - self.get_gmsh_command() - self.write_gmsh_input_files() - error = self.run_gmsh_with_geo() - self.read_and_set_new_mesh() - except GmshError as e: - error = str(e) - return error + self.prepare() + p = self.compute() + p.waitForFinished() + self.update_properties() def start_logs(self): Console.PrintLog("\nGmsh FEM mesh run is being started.\n") @@ -369,6 +364,19 @@ class GmshTools: def get_group_data(self): # mesh group objects. Only one shape type is expected + geom = self.mesh_obj.Shape.getPropertyOfGeometry() + r_solids = range(1, len(geom.Solids) + 1) + r_faces = range(1, len(geom.Faces) + 1) + r_edges = range(1, len(geom.Edges) + 1) + solids = [(f"Solid{i}", [f"Solid{i}"]) for i in r_solids] + faces = [(f"Face{i}", [f"Face{i}"]) for i in r_faces] + edges = [(f"Edge{i}", [f"Edge{i}"]) for i in r_edges] + shapes = [] + shapes.extend(solids) + shapes.extend(faces) + shapes.extend(edges) + + self.group_elements = dict(shapes) if not self.mesh_obj.MeshGroupList: # print(" No mesh group objects.") pass @@ -406,6 +414,17 @@ class GmshTools: # if self.group_elements: # Console.PrintMessage(" {}\n".format(self.group_elements)) + def rename_groups(self): + # salomemesh adds a suffix to the names of element groups if there are also nodes + # in the groups in the .unv file. This method removes the suffix + reg_exp = re.compile(r"(?P(Edge|Face|Solid)\d+)_(?!Nodes)\w+$") + fem_mesh = self.mesh_obj.FemMesh + for i in fem_mesh.Groups: + grp = fem_mesh.getGroupName(i) + m = reg_exp.match(grp) + if m: + fem_mesh.renameGroup(i, m.group("item")) + def version(self): self.get_gmsh_command() if shutil.which(self.gmsh_bin): From 243b42f4cbedfbd659cb28a6300720ed94a378d6 Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 01:44:26 -0300 Subject: [PATCH 4/7] Fem: Rework Elmer solver - fixes #21479 --- src/Mod/Fem/CMakeLists.txt | 6 +- src/Mod/Fem/Gui/CMakeLists.txt | 1 + src/Mod/Fem/Gui/DlgSettingsFemElmer.ui | 34 +- src/Mod/Fem/Gui/DlgSettingsFemElmerImp.cpp | 6 +- src/Mod/Fem/Gui/DlgSettingsFemElmerImp.h | 1 - src/Mod/Fem/Gui/Resources/Fem.qrc | 1 + src/Mod/Fem/Gui/Resources/ui/SolverElmer.ui | 147 ++++++++ src/Mod/Fem/ObjectsFem.py | 10 +- src/Mod/Fem/femcommands/commands.py | 22 +- src/Mod/Fem/femobjects/solver_elmer.py | 163 +++++++++ src/Mod/Fem/femsolver/elmer/elmertools.py | 175 +++++++++ src/Mod/Fem/femsolver/elmer/solver.py | 242 ------------- src/Mod/Fem/femsolver/elmer/tasks.py | 339 ------------------ src/Mod/Fem/femsolver/elmer/writer.py | 162 +-------- .../Fem/femtaskpanels/task_solver_elmer.py | 157 ++++++++ .../Fem/femviewprovider/view_solver_elmer.py | 51 +++ 16 files changed, 764 insertions(+), 753 deletions(-) create mode 100644 src/Mod/Fem/Gui/Resources/ui/SolverElmer.ui create mode 100644 src/Mod/Fem/femobjects/solver_elmer.py create mode 100644 src/Mod/Fem/femsolver/elmer/elmertools.py delete mode 100644 src/Mod/Fem/femsolver/elmer/solver.py delete mode 100644 src/Mod/Fem/femsolver/elmer/tasks.py create mode 100644 src/Mod/Fem/femtaskpanels/task_solver_elmer.py create mode 100644 src/Mod/Fem/femviewprovider/view_solver_elmer.py diff --git a/src/Mod/Fem/CMakeLists.txt b/src/Mod/Fem/CMakeLists.txt index 6a00241619..8f634afb87 100755 --- a/src/Mod/Fem/CMakeLists.txt +++ b/src/Mod/Fem/CMakeLists.txt @@ -211,6 +211,7 @@ SET(FemObjects_SRCS femobjects/result_mechanical.py femobjects/solver_calculix.py femobjects/solver_ccxtools.py + femobjects/solver_elmer.py ) if(BUILD_FEM_VTK_PYTHON) @@ -285,9 +286,8 @@ SET(FemSolverCalculix_SRCS SET(FemSolverElmer_SRCS femsolver/elmer/__init__.py femsolver/elmer/sifio.py - femsolver/elmer/solver.py - femsolver/elmer/tasks.py femsolver/elmer/writer.py + femsolver/elmer/elmertools.py ) SET(FemSolverElmerEquations_SRCS @@ -631,6 +631,7 @@ SET(FemGuiTaskPanels_SRCS femtaskpanels/task_result_mechanical.py femtaskpanels/task_solver_calculix.py femtaskpanels/task_solver_ccxtools.py + femtaskpanels/task_solver_elmer.py ) if(BUILD_FEM_VTK_PYTHON) @@ -701,6 +702,7 @@ SET(FemGuiViewProvider_SRCS femviewprovider/view_result_mechanical.py femviewprovider/view_solver_calculix.py femviewprovider/view_solver_ccxtools.py + femviewprovider/view_solver_elmer.py ) if(BUILD_FEM_VTK_PYTHON) diff --git a/src/Mod/Fem/Gui/CMakeLists.txt b/src/Mod/Fem/Gui/CMakeLists.txt index 74c7a339a4..8b06dc096e 100755 --- a/src/Mod/Fem/Gui/CMakeLists.txt +++ b/src/Mod/Fem/Gui/CMakeLists.txt @@ -449,6 +449,7 @@ SET(FemGuiPythonUI_SRCS Resources/ui/ResultShow.ui Resources/ui/SolverCalculiX.ui Resources/ui/SolverCcxTools.ui + Resources/ui/SolverElmer.ui Resources/ui/TaskPostGlyph.ui Resources/ui/TaskPostExtraction.ui Resources/ui/TaskPostHistogram.ui diff --git a/src/Mod/Fem/Gui/DlgSettingsFemElmer.ui b/src/Mod/Fem/Gui/DlgSettingsFemElmer.ui index 9efc747758..05531a9d1c 100644 --- a/src/Mod/Fem/Gui/DlgSettingsFemElmer.ui +++ b/src/Mod/Fem/Gui/DlgSettingsFemElmer.ui @@ -144,14 +144,14 @@ - + - Number of processes + Number of tasks - + Qt::AlignLeft|Qt::AlignTrailing|Qt::AlignVCenter @@ -162,7 +162,33 @@ 1 - UseNumberOfCores + NumberOfTasks + + + Mod/Fem/Elmer + + + + + + + Threads per task + + + + + + + Qt::AlignLeft|Qt::AlignTrailing|Qt::AlignVCenter + + + Number of threads per task. Take effect if Elmer uses OpenMP. + + + 1 + + + ThreadsPerTask Mod/Fem/Elmer diff --git a/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.cpp b/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.cpp index 5430b8723b..0edae3b094 100644 --- a/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.cpp +++ b/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.cpp @@ -54,7 +54,8 @@ void DlgSettingsFemElmerImp::saveSettings() ui->fc_elmer_binary_path->onSave(); ui->fc_grid_binary_path->onSave(); - ui->sb_num_processes->onSave(); + ui->sb_num_tasks->onSave(); + ui->sb_threads_per_task->onSave(); ui->ckb_binary_format->onSave(); ui->ckb_geom_id->onSave(); @@ -65,7 +66,8 @@ void DlgSettingsFemElmerImp::loadSettings() ui->fc_elmer_binary_path->onRestore(); ui->fc_grid_binary_path->onRestore(); - ui->sb_num_processes->onRestore(); + ui->sb_num_tasks->onRestore(); + ui->sb_threads_per_task->onRestore(); ui->ckb_binary_format->onRestore(); ui->ckb_geom_id->onRestore(); diff --git a/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.h b/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.h index 827dcfe136..8521089136 100644 --- a/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.h +++ b/src/Mod/Fem/Gui/DlgSettingsFemElmerImp.h @@ -50,7 +50,6 @@ protected: private: std::unique_ptr ui; - int processor_count; }; } // namespace FemGui diff --git a/src/Mod/Fem/Gui/Resources/Fem.qrc b/src/Mod/Fem/Gui/Resources/Fem.qrc index 351dad3e48..f08d0b2e4c 100755 --- a/src/Mod/Fem/Gui/Resources/Fem.qrc +++ b/src/Mod/Fem/Gui/Resources/Fem.qrc @@ -156,6 +156,7 @@ ui/ResultShow.ui ui/SolverCalculiX.ui ui/SolverCcxTools.ui + ui/SolverElmer.ui ui/TaskPostGlyph.ui diff --git a/src/Mod/Fem/Gui/Resources/ui/SolverElmer.ui b/src/Mod/Fem/Gui/Resources/ui/SolverElmer.ui new file mode 100644 index 0000000000..c9d1635fad --- /dev/null +++ b/src/Mod/Fem/Gui/Resources/ui/SolverElmer.ui @@ -0,0 +1,147 @@ + + + SolverElmer + + + + 0 + 0 + 400 + 475 + + + + Solver Elmer Control + + + + + + Working directory + + + + + + + + + + + + + + + Write + + + + + + + false + + + Edit + + + + + + + + + + + Path to working directory + + + false + + + Gui::FileChooser::Mode::Directory + + + + + + + + + + + + Solver Parameters + + + + + + + + Simulation type + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + QTextEdit::NoWrap + + + true + + + + + + + + 12 + + + + Time + + + + + + + Solver Version + + + + + + + + + + + Gui::FileChooser + QWidget +
Gui/FileDialog.h
+
+
+ + +
diff --git a/src/Mod/Fem/ObjectsFem.py b/src/Mod/Fem/ObjectsFem.py index eeddb44748..32a8acf790 100644 --- a/src/Mod/Fem/ObjectsFem.py +++ b/src/Mod/Fem/ObjectsFem.py @@ -941,9 +941,15 @@ def makeSolverCalculiX(doc, name="SolverCalculiX"): def makeSolverElmer(doc, name="SolverElmer"): """makeSolverElmer(document, [name]): makes a Elmer solver object""" - import femsolver.elmer.solver + obj = doc.addObject("Fem::FemSolverObjectPython", name) + from femobjects import solver_elmer - obj = femsolver.elmer.solver.create(doc, name) + solver_elmer.SolverElmer(obj) + obj.SimulationType = "Steady State" + if FreeCAD.GuiUp: + from femviewprovider import view_solver_elmer + + view_solver_elmer.VPSolverElmer(obj.ViewObject) return obj diff --git a/src/Mod/Fem/femcommands/commands.py b/src/Mod/Fem/femcommands/commands.py index fc773ae7d7..bf0fb7c1f1 100644 --- a/src/Mod/Fem/femcommands/commands.py +++ b/src/Mod/Fem/femcommands/commands.py @@ -1174,18 +1174,17 @@ class _SolverRun(CommandManager): self.tool = None def Activated(self): - if self.selobj.Proxy.Type == "Fem::SolverCalculiX": - QtGui.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + if self.selobj.Proxy.Type in ["Fem::SolverCalculiX", "Fem::SolverElmer"]: try: - from femsolver.calculix.calculixtools import CalculiXTools - - self.tool = CalculiXTools(self.selobj) + QtGui.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + self._set_tool() self._conn(self.tool) self.tool.prepare() self.tool.compute() except Exception as e: QtGui.QApplication.restoreOverrideCursor() - raise + FreeCAD.Console.PrintError(e) + return else: from femsolver.run import run_fem_solver @@ -1194,6 +1193,17 @@ class _SolverRun(CommandManager): FreeCADGui.Selection.clearSelection() FreeCAD.ActiveDocument.recompute() + def _set_tool(self): + match self.selobj.Proxy.Type: + case "Fem::SolverCalculiX": + from femsolver.calculix.calculixtools import CalculiXTools + + self.tool = CalculiXTools(self.selobj) + case "Fem::SolverElmer": + from femsolver.elmer.elmertools import ElmerTools + + self.tool = ElmerTools(self.selobj) + def _conn(self, tool): QtCore.QObject.connect( tool.process, diff --git a/src/Mod/Fem/femobjects/solver_elmer.py b/src/Mod/Fem/femobjects/solver_elmer.py new file mode 100644 index 0000000000..c855c5083f --- /dev/null +++ b/src/Mod/Fem/femobjects/solver_elmer.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2017 Markus Hovorka * +# * Copyright (c) 2025 Mario Passaglia * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +__title__ = "FreeCAD FEM solver Elmer document object" +__author__ = "Markus Hovorka, Mario Passaglia" +__url__ = "https://www.freecad.org" + +## @package solver_elmer +# \ingroup FEM +# \brief solver Elmer object + +from FreeCAD import Base +from . import base_fempythonobject + +_PropHelper = base_fempythonobject._PropHelper + + +class SolverElmer(base_fempythonobject.BaseFemPythonObject): + + Type = "Fem::SolverElmer" + + def __init__(self, obj): + super().__init__(obj) + obj.addExtension("App::GroupExtensionPython") + + for prop in self._get_properties(): + prop.add_to_object(obj) + + def _get_properties(self): + prop = [] + + prop.append( + _PropHelper( + type="App::PropertyEnumeration", + name="CoordinateSystem", + group="Solver", + doc="Type of the analysis", + value=[ + "Cartesian", + "Cartesian 1D", + "Cartesian 2D", + "Cartesian 3D", + "Polar 2D", + "Polar 3D", + "Cylindric", + "Cylindric Symmetric", + "Axi Symmetric", + ], + ) + ) + prop.append( + _PropHelper( + type="App::PropertyIntegerConstraint", + name="BDFOrder", + group="Timestepping", + doc="Order of time stepping method 'BDF'", + value={"value": 2, "min": 1, "max": 5}, + ) + ) + prop.append( + _PropHelper( + type="App::PropertyIntegerList", + name="OutputIntervals", + group="Timestepping", + doc="After how many time steps a result file is output", + value=[1], + ) + ) + prop.append( + _PropHelper( + type="App::PropertyIntegerList", + name="TimestepIntervals", + group="Timestepping", + doc="List of times if Simulation Type\n" + "is either `Scanning` or `Transient`", + value=[100], + ) + ) + prop.append( + _PropHelper( + type="App::PropertyFloatList", + name="TimestepSizes", + group="Timestepping", + doc="List of times steps if Simulation Type\n" + + "is either `Scanning` or `Transient`", + value=[0.1], + ) + ) + prop.append( + _PropHelper( + type="App::PropertyEnumeration", + name="SimulationType", + group="Solver", + doc="Simulation type", + value=["Scanning", "Steady State", "Transient"], + ) + ) + prop.append( + _PropHelper( + type="App::PropertyInteger", + name="SteadyStateMaxIterations", + group="Solver", + doc="Maximal steady state iterations", + value=1, + ) + ) + prop.append( + _PropHelper( + type="App::PropertyInteger", + name="SteadyStateMinIterations", + group="Solver", + doc="Minimal steady state iterations", + value=0, + ) + ) + prop.append( + _PropHelper( + type="App::PropertyBool", + name="BinaryOutput", + group="Solver", + doc="Save result in binary format", + value=False, + ) + ) + prop.append( + _PropHelper( + type="App::PropertyBool", + name="SaveGeometryIndex", + group="Solver", + doc="Save geometry IDs", + value=False, + ) + ) + + return prop + + def onDocumentRestored(self, obj): + # update old project with new properties + for prop in self._get_properties(): + try: + obj.getPropertyByName(prop.name) + except Base.PropertyError: + prop.add_to_object(obj) diff --git a/src/Mod/Fem/femsolver/elmer/elmertools.py b/src/Mod/Fem/femsolver/elmer/elmertools.py new file mode 100644 index 0000000000..2587a9d687 --- /dev/null +++ b/src/Mod/Fem/femsolver/elmer/elmertools.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2025 Mario Passaglia * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +__title__ = "Tools for the work with Elmer solver" +__author__ = "Mario Passaglia" +__url__ = "https://www.freecad.org" + + +from PySide.QtCore import QProcess, QProcessEnvironment +import tempfile +import os +import re +import shutil + +import FreeCAD + +from . import writer +from .. import settings + +from femtools import membertools + + +class ElmerTools: + + name = "Elmer" + + def __init__(self, obj): + self.obj = obj + self.process = QProcess() + self.model_file = "" + self.analysis = obj.getParentGroup() + self.fem_param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Fem") + self._create_working_directory(obj) + self._result_format = "" + + def _create_working_directory(self, obj): + """ + Create working directory according to preferences + """ + if not os.path.isdir(obj.WorkingDirectory): + gen_param = self.fem_param.GetGroup("General") + if gen_param.GetBool("UseTempDirectory"): + self.obj.WorkingDirectory = tempfile.mkdtemp(prefix="fem_") + elif gen_param.GetBool("UseBesideDirectory"): + root, ext = os.path.splitext(obj.Document.FileName) + if root: + self.obj.WorkingDirectory = os.path.join(root, obj.Label) + os.makedirs(self.obj.WorkingDirectory, exist_ok=True) + else: + # file not saved, use temporary + self.obj.WorkingDirectory = tempfile.mkdtemp(prefix="fem_") + elif gen_param.GetBool("UseCustomDirectory"): + self.obj.WorkingDirectory = gen_param.GetString("CustomDirectoryPath") + os.makedirs(self.obj.WorkingDirectory, exist_ok=True) + + def prepare(self): + w = writer.Writer(self.obj, self.obj.WorkingDirectory) + w.write_solver_input() + + mesh = w.getSingleMember("Fem::FemMeshObject") + if not mesh.FemMesh.Groups: + raise ValueError(f"Mesh object '{mesh.Label}' has no groups, please remesh\n") + + mesh_file = os.path.join(self.obj.WorkingDirectory, "mesh.unv") + mesh.FemMesh.write(mesh_file) + + grid_bin = settings.get_binary("ElmerGrid") + env = QProcessEnvironment.systemEnvironment() + p = QProcess() + p.setProcessEnvironment(env) + p.setWorkingDirectory(self.obj.WorkingDirectory) + grid_args = ["8", "2", mesh_file, "-out", self.obj.WorkingDirectory] + p.start(grid_bin, grid_args) + p.waitForFinished() + num_proc = self.fem_param.GetGroup("Elmer").GetInt("NumberOfTasks", 1) + if num_proc > 1: + # MPI parallel computing version + grid_args.extend(["-partdual", "-metiskway", str(num_proc)]) + p.start(grid_bin, grid_args) + p.waitForFinished() + + self.model_file = os.path.join(self.obj.WorkingDirectory, writer._SIF_NAME) + handled = w.getHandledConstraints() + allConstraints = membertools.get_member(self.analysis, "Fem::Constraint") + for obj in set(allConstraints) - handled: + FreeCAD.Console.PrintWarning(f"Ignored constraint {obj.Label}") + + def compute(self): + self._clear_results() + elmer_bin = settings.get_binary("ElmerSolver") + num_proc = self.fem_param.GetGroup("Elmer").GetInt("NumberOfTasks", 1) + num_thr = self.fem_param.GetGroup("Elmer").GetInt("ThreadsPerTask", 1) + env = QProcessEnvironment.systemEnvironment() + env.insert("OMP_NUM_THREADS", str(num_thr)) + self.process.setProcessEnvironment(env) + self.process.setWorkingDirectory(self.obj.WorkingDirectory) + + if num_proc > 1: + # MPI parallel computing version + mpi = shutil.which("mpiexec") + self._result_format = ".pvtu" + command_list = ["-n", str(num_proc), elmer_bin] + self.process.start(mpi, command_list) + else: + self._result_format = ".vtu" + command_list = [] + self.process.start(elmer_bin, command_list) + + if self.obj.SimulationType == "Transient": + self._result_format = ".pvd" + + return self.process + + def update_properties(self): + keep_result = self.fem_param.GetGroup("General").GetBool("KeepResultsOnReRun", False) + if not self.obj.Results or keep_result: + pipeline = self.obj.Document.addObject("Fem::FemPostPipeline", self.obj.Name + "Result") + self.analysis.addObject(pipeline) + temp_res = self.obj.Results + temp_res.append(pipeline) + self.obj.Results = temp_res + self._load_results() + # default display mode + pipeline.ViewObject.DisplayMode = "Surface" + pipeline.ViewObject.SelectionStyle = "BoundBox" + else: + self._load_results() + + def _clear_results(self): + dir_content = os.listdir(self.obj.WorkingDirectory) + for f in dir_content: + path = os.path.join(self.obj.WorkingDirectory, f) + base, ext = os.path.splitext(path) + if ext in [".vtu", ".vtp", ".pvtu", ".pvd"]: + os.remove(path) + + def _load_results(self): + files = os.listdir(self.obj.WorkingDirectory) + for f in files: + base, ext = os.path.splitext(f) + if ext == self._result_format: + res = os.path.join(self.obj.WorkingDirectory, f) + self.obj.Results[-1].read(res) + break + + def version(self): + p = QProcess() + elmer_bin = settings.get_binary("ElmerSolver") + p.start(elmer_bin, ["-v"]) + p.waitForFinished() + info = p.readAll().data().decode() + reg_exp = re.compile(r"Version:\s*(?P.*)$", re.M) + m = reg_exp.search(info) + ver = "Version: {}".format(m.group("version") if m else "") + return ver diff --git a/src/Mod/Fem/femsolver/elmer/solver.py b/src/Mod/Fem/femsolver/elmer/solver.py deleted file mode 100644 index 077ddfd490..0000000000 --- a/src/Mod/Fem/femsolver/elmer/solver.py +++ /dev/null @@ -1,242 +0,0 @@ -# *************************************************************************** -# * Copyright (c) 2017 Markus Hovorka * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -__title__ = "FreeCAD FEM solver object Elmer" -__author__ = "Markus Hovorka" -__url__ = "https://www.freecad.org" - -## \addtogroup FEM -# @{ - -import glob -import os - -import FreeCAD - -from . import tasks -from .equations import deformation -from .equations import elasticity -from .equations import electricforce -from .equations import electrostatic -from .equations import flow -from .equations import flux -from .equations import heat -from .equations import magnetodynamic -from .equations import magnetodynamic2D -from .. import run -from .. import solverbase -from femtools import femutils - -if FreeCAD.GuiUp: - import FemGui - -COORDINATE_SYSTEM = [ - "Cartesian", - "Cartesian 1D", - "Cartesian 2D", - "Cartesian 3D", - "Polar 2D", - "Polar 3D", - "Cylindric", - "Cylindric Symmetric", - "Axi Symmetric", -] -SIMULATION_TYPE = ["Scanning", "Steady State", "Transient"] - - -def create(doc, name="ElmerSolver"): - return femutils.createObject(doc, name, Proxy, ViewProxy) - - -class Proxy(solverbase.Proxy): - """Proxy for FemSolverElmers Document Object.""" - - Type = "Fem::SolverElmer" - - _EQUATIONS = { - "Deformation": deformation, - "Elasticity": elasticity, - "Electrostatic": electrostatic, - "Flux": flux, - "Electricforce": electricforce, - "Flow": flow, - "Heat": heat, - "Magnetodynamic": magnetodynamic, - "Magnetodynamic2D": magnetodynamic2D, - } - - def __init__(self, obj): - super().__init__(obj) - - obj.addProperty( - "App::PropertyEnumeration", "CoordinateSystem", "Coordinate System", "", locked=True - ) - obj.CoordinateSystem = COORDINATE_SYSTEM - obj.CoordinateSystem = "Cartesian" - - obj.addProperty( - "App::PropertyIntegerConstraint", - "BDFOrder", - "Timestepping", - "Order of time stepping method 'BDF'", - locked=True, - ) - # according to the Elmer manual recommended is order 2 - # possible range is 1 - 5 - obj.BDFOrder = (2, 1, 5, 1) - - obj.addProperty( - "App::PropertyIntegerList", - "OutputIntervals", - "Timestepping", - "After how many time steps a result file is output", - locked=True, - ) - obj.OutputIntervals = [1] - - obj.addProperty( - "App::PropertyIntegerList", - "TimestepIntervals", - "Timestepping", - ("List of times if 'Simulation Type'\nis either 'Scanning' or 'Transient'"), - locked=True, - ) - obj.addProperty( - "App::PropertyFloatList", - "TimestepSizes", - "Timestepping", - ( - "List of time steps sizes if 'Simulation Type'\n" - "is either 'Scanning' or 'Transient'" - ), - locked=True, - ) - # there is no universal default, it all depends on the analysis, however - # we have to set something and set 10 seconds in steps of 0.1s - obj.TimestepIntervals = [100] - obj.TimestepSizes = [0.1] - - obj.addProperty("App::PropertyEnumeration", "SimulationType", "Type", "") - obj.SimulationType = SIMULATION_TYPE - obj.SimulationType = "Steady State" - - obj.addProperty( - "App::PropertyInteger", - "SteadyStateMaxIterations", - "Type", - "Maximal steady state iterations", - locked=True, - ) - obj.SteadyStateMaxIterations = 1 - - obj.addProperty( - "App::PropertyInteger", - "SteadyStateMinIterations", - "Type", - "Minimal steady state iterations", - locked=True, - ) - obj.SteadyStateMinIterations = 0 - - obj.addProperty("App::PropertyLink", "ElmerResult", "Base", "", 4 | 8, locked=True) - - obj.addProperty("App::PropertyLink", "ElmerOutput", "Base", "", 4 | 8, locked=True) - - obj.addProperty( - "App::PropertyBool", - "BinaryOutput", - "Result File", - "Save result in binary format", - locked=True, - ) - obj.BinaryOutput = False - - obj.addProperty( - "App::PropertyBool", - "SaveGeometryIndex", - "Result File", - "Save geometry IDs", - locked=True, - ) - obj.SaveGeometryIndex = False - - def onDocumentRestored(self, obj): - # update old project with new properties - try: - obj.getPropertyByName("BinaryOutput") - except FreeCAD.Base.PropertyError: - obj.addProperty( - "App::PropertyBool", - "BinaryOutput", - "Result File", - "Save result in binary format", - locked=True, - ) - obj.BinaryOutput = False - try: - obj.getPropertyByName("SaveGeometryIndex") - except FreeCAD.Base.PropertyError: - obj.addProperty( - "App::PropertyBool", - "SaveGeometryIndex", - "Result File", - "Save geometry IDs", - locked=True, - ) - obj.SaveGeometryIndex = False - - def createMachine(self, obj, directory, testmode=False): - return run.Machine( - solver=obj, - directory=directory, - check=tasks.Check(), - prepare=tasks.Prepare(), - solve=tasks.Solve(), - results=tasks.Results(), - testmode=testmode, - ) - - def createEquation(self, doc, eqId): - return self._EQUATIONS[eqId].create(doc) - - def isSupported(self, eqId): - return eqId in self._EQUATIONS - - def editSupported(self): - return True - - def edit(self, directory): - pattern = os.path.join(directory, "case.sif") - FreeCAD.Console.PrintMessage(f"{pattern}\n") - f = glob.glob(pattern)[0] - FemGui.open(f) - - -class ViewProxy(solverbase.ViewProxy): - """Proxy for FemSolverElmers View Provider.""" - - def getIcon(self): - return ":/icons/FEM_SolverElmer.svg" - - -## @} diff --git a/src/Mod/Fem/femsolver/elmer/tasks.py b/src/Mod/Fem/femsolver/elmer/tasks.py deleted file mode 100644 index 0f947a6ab2..0000000000 --- a/src/Mod/Fem/femsolver/elmer/tasks.py +++ /dev/null @@ -1,339 +0,0 @@ -# *************************************************************************** -# * Copyright (c) 2017 Markus Hovorka * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -__title__ = "FreeCAD FEM solver Elmer tasks" -__author__ = "Markus Hovorka" -__url__ = "https://www.freecad.org" - -## \addtogroup FEM -# @{ - -import cmath -import os -import os.path -import subprocess -from platform import system - -import FreeCAD - -from . import writer -from .. import run -from .. import settings -from femtools import femutils -from femtools import membertools - - -class Check(run.Check): - - def run(self): - self.pushStatus("Checking analysis...\n") - if self.check_mesh_exists(): - self.checkMeshType() - self.check_material_exists() - self.checkEquations() - - def checkMeshType(self): - mesh = membertools.get_single_member(self.analysis, "Fem::FemMeshObject") - if not femutils.is_of_type(mesh, "Fem::FemMeshGmsh"): - self.report.error("Unsupported type of mesh. Mesh must be created with gmsh.") - self.fail() - return False - return True - - def checkEquations(self): - equations = self.solver.Group - if not equations: - self.report.error("Solver has no equations. Add at least one equation.") - self.fail() - - -class Prepare(run.Prepare): - - def run(self): - # TODO print working dir to report console - self.pushStatus("Preparing input files...\n") - num_cores = settings.get_cores("ElmerGrid") - self.pushStatus(f"Number of CPU cores to be used for the solver run: {num_cores}\n") - if self.testmode: - # test mode: neither gmsh, nor elmergrid nor elmersolver binaries needed - FreeCAD.Console.PrintMessage(f"Machine testmode: {self.testmode}\n") - w = writer.Writer(self.solver, self.directory, True) - else: - FreeCAD.Console.PrintLog(f"Machine testmode: {self.testmode}\n") - w = writer.Writer(self.solver, self.directory) - try: - w.write_solver_input() - self.checkHandled(w) - self.pushStatus("Writing solver input completed.") - except writer.WriteError as e: - self.report.error(str(e)) - self.fail() - except OSError: - self.report.error("Can't access working directory.") - self.fail() - - def checkHandled(self, w): - handled = w.getHandledConstraints() - allConstraints = membertools.get_member(self.analysis, "Fem::Constraint") - for obj in set(allConstraints) - handled: - self.report.warning("Ignored constraint %s." % obj.Label) - - -class Solve(run.Solve): - - def run(self): - # on rerun the result file will not deleted before starting the solver - # if the solver fails, the existing result from a former run file will be loaded - # TODO: delete result file (may be delete all files which will be recreated) - self.pushStatus("Executing solver...\n") - binary = settings.get_binary("ElmerSolver") - if binary is not None: - # if ELMER_HOME is not set, set it. - # Needed if elmer is compiled but not installed on Linux - # http://www.elmerfem.org/forum/viewtopic.php?f=2&t=7119 - # https://stackoverflow.com/questions/1506010/how-to-use-export-with-python-on-linux - # TODO move retrieving the param to solver settings module - elparams = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Fem/Elmer") - elmer_env = elparams.GetBool("SetElmerEnvVariables", False) - if elmer_env is True and system() == "Linux" and "ELMER_HOME" not in os.environ: - solvpath = os.path.split(binary)[0] - if os.path.isdir(solvpath): - os.environ["ELMER_HOME"] = solvpath - os.environ["LD_LIBRARY_PATH"] = f"$LD_LIBRARY_PATH:{solvpath}/modules" - # different call depending if with multithreading or not - num_cores = settings.get_cores("ElmerSolver") - self.pushStatus(f"Number of CPU cores to be used for the solver run: {num_cores}\n") - args = [] - if num_cores > 1: - if system() != "Windows": - args.extend(["mpirun"]) - else: - args.extend(["mpiexec"]) - args.extend(["-np", str(num_cores)]) - args.extend([binary]) - self._process = subprocess.Popen( - args, - cwd=self.directory, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - startupinfo=femutils.startProgramInfo("hide"), - ) - self.signalAbort.add(self._process.terminate) - output = self._observeSolver(self._process) - self._process.communicate() - self.signalAbort.remove(self._process.terminate) - if not self.aborted: - self._updateOutput(output) - else: - self.report.error("ElmerSolver binary not found.") - self.pushStatus("Error: ElmerSolver binary has not been found!") - self.fail() - - def _updateOutput(self, output): - if self.solver.ElmerOutput is None: - self._createOutput() - # check if eigenmodes were calculated and if so append them to output - output = self._calculateEigenfrequencies(output) - self.solver.ElmerOutput.Text = output - - def _createOutput(self): - self.solver.ElmerOutput = self.analysis.Document.addObject( - "App::TextDocument", self.solver.Name + "Output" - ) - self.solver.ElmerOutput.Label = self.solver.Label + "Output" - # App::TextDocument has no Attribute ReadOnly - # TODO check if the attribute has been removed from App::TextDocument - # self.solver.ElmerOutput.ReadOnly = True - self.analysis.addObject(self.solver.ElmerOutput) - self.solver.Document.recompute() - - def _calculateEigenfrequencies(self, output): - # takes the EigenSolve results and performs the calculation - # sqrt(aResult) / 2*PI but with aResult as complex number - - # first search the output file for the results - OutputList = output.split("\n") - modeNumber = 0 - modeCount = 0 - real = 0 - imaginary = 0 - haveImaginary = False - FrequencyList = [] - for line in OutputList: - LineList = line.split(" ") - if len(LineList) > 1 and LineList[0] == "EigenSolve:" and LineList[1] == "Computed": - # we found a result and take now the next LineList[2] lines - modeCount = int(LineList[2]) - modeNumber = modeCount - continue - if modeCount > 0: - for LineString in reversed(LineList): - # the output of Elmer may vary, we only know the last float - # is the imaginary and second to last float the real part - if self._isNumber(LineString): - if not haveImaginary: - imaginary = float(LineString) - haveImaginary = True - else: - real = float(LineString) - break - eigenFreq = complex(real, imaginary) - haveImaginary = False - # now we can perform the calculation - eigenFreq = cmath.sqrt(eigenFreq) / (2 * cmath.pi) - # create an output line - FrequencyList.append(f"Mode {modeNumber - modeCount + 1}: {eigenFreq.real} Hz") - modeCount = modeCount - 1 - if modeNumber > 0: - # push the results and append to output - self.pushStatus("\n\nEigenfrequency results:") - output = output + "\n\nEigenfrequency results:" - for i in range(0, modeNumber): - output = output + "\n" + FrequencyList[i] - self.pushStatus("\n" + FrequencyList[i]) - self.pushStatus("\n") - return output - - def _isNumber(self, string): - try: - float(string) - return True - except ValueError: - return False - - -class Results(run.Results): - - def run(self): - if self.solver.SimulationType == "Steady State": - self._handleStedyStateResult() - else: - self._handleTransientResults() - - def _handleStedyStateResult(self): - if self.solver.ElmerResult is None: - self._createResults() - postPath = self._getResultFile() - if postPath is None: - self.pushStatus("\nNo result file was created.\n") - self.fail() - return - self.solver.ElmerResult.read(postPath) - # at the moment we scale the mesh back using Elmer - # this might be changed in future, this commented code is left as info - # self.solver.ElmerResult.scale(1000) - - # for eigen analyses the resulting values are by a factor 1000 to high - # therefore scale all *EigenMode results - self.solver.ElmerResult.ViewObject.transformField("displacement EigenMode1", 0.001) - - self.solver.ElmerResult.recomputeChildren() - self.solver.Document.recompute() - # recompute() updated the result mesh data - # but not the shape and bar coloring - self.solver.ElmerResult.ViewObject.updateColorBars() - - def _createResults(self): - self.solver.ElmerResult = self.analysis.Document.addObject( - "Fem::FemPostPipeline", self.solver.Name + "Result" - ) - self.solver.ElmerResult.Label = self.solver.ElmerResult.Name - self.solver.ElmerResult.ViewObject.SelectionStyle = "BoundBox" - self.analysis.addObject(self.solver.ElmerResult) - # to assure the user sees something, set the default to Surface - self.solver.ElmerResult.ViewObject.DisplayMode = "Surface" - - def _handleTransientResults(self): - # for transient results we must create a result pipeline for every time - # the connection between result files and and their time is in the FreeCAD.pvd file - # therefore first open FreeCAD.pvd - pvdFilePath = os.path.join(self.directory, "FreeCAD.pvd") - if not os.path.exists(pvdFilePath): - self.pushStatus("\nNo result file was created.\n") - self.fail() - return - pvdFile = open(pvdFilePath) - # read all lines - pvdContent = pvdFile.readlines() - # skip header and footer line and evaluate all lines - # a line has the form like this: - # - # so .split("\"") gives as 2nd the time and as 7th the filename - files = [] - values = [] - for i in range(0, len(pvdContent) - 2): - # get time - lineArray = pvdContent[i + 1].split('"') - time = float(lineArray[1]) - filename = os.path.join(self.directory, lineArray[7]) - if os.path.isfile(filename): - values.append(time) - files.append(filename) - else: - self.pushStatus(f"\nResult file for time {time} is missing.\n") - self.fail() - return - - if self.solver.ElmerResult is None: - self._createResults() - - self.solver.ElmerResult.read(files, values, FreeCAD.Units.TimeSpan, "Time") - - # for eigen analyses the resulting values are by a factor 1000 to high - # therefore scale all *EigenMode results - self.solver.ElmerResult.ViewObject.transformField("displacement EigenMode1", 0.001) - - self.solver.ElmerResult.recomputeChildren() - self.solver.Document.recompute() - # recompute() updated the result mesh data - # but not the shape and bar coloring - self.solver.ElmerResult.ViewObject.updateColorBars() - - def _getResultFile(self): - postPath = None - # elmer post file path changed with version x.x - # see https://forum.freecad.org/viewtopic.php?f=18&t=42732 - # workaround - possible_post_file_old = os.path.join(self.directory, "case0001.vtu") - possible_post_file_single = os.path.join(self.directory, "FreeCAD_t0001.vtu") - possible_post_file_multi = os.path.join(self.directory, "FreeCAD_t0001.pvtu") - # depending on the currently set number of cores we try to load either - # the multi-thread result or the single result - if settings.get_cores("ElmerSolver") > 1: - if os.path.isfile(possible_post_file_multi): - postPath = possible_post_file_multi - else: - self.report.error("Result file not found.") - self.fail() - else: - if os.path.isfile(possible_post_file_single): - postPath = possible_post_file_single - elif os.path.isfile(possible_post_file_old): - postPath = possible_post_file_old - else: - self.report.error("Result file not found.") - self.fail() - return postPath - - -## @} diff --git a/src/Mod/Fem/femsolver/elmer/writer.py b/src/Mod/Fem/femsolver/elmer/writer.py index 974729667f..b311a09c33 100644 --- a/src/Mod/Fem/femsolver/elmer/writer.py +++ b/src/Mod/Fem/femsolver/elmer/writer.py @@ -42,9 +42,7 @@ from FreeCAD import ParamGet import Fem from . import sifio -from . import solver as solverClass from .. import settings -from femmesh import gmshtools from femtools import constants from femtools import femutils from femtools import membertools @@ -98,7 +96,7 @@ class Writer: def getHandledConstraints(self): return self._handledObjects - def write_solver_input(self): + def _writeBlocks(self): self._handleRedifinedConstants() self._handleSimulation() self._handleDeformation() @@ -113,8 +111,9 @@ class Writer: self._handleStaticCurrent() self._addOutputSolver() + def write_solver_input(self): + self._writeBlocks() self._writeSif() - self._writeMesh() self._writeStartinfo() def _handleUnits(self): @@ -212,90 +211,6 @@ class Writer: "BoltzmannConstant": constants.boltzmann_constant(), } - def _writeMesh(self): - mesh = self.getSingleMember("Fem::FemMeshObject") - unvPath = os.path.join(self.directory, "mesh.unv") - groups = [] - groups.extend(self._builder.getBodyNames()) - groups.extend(self._builder.getBoundaryNames()) - self._exportToUnv(groups, mesh, unvPath) - if self.testmode: - Console.PrintMessage( - "Solver Elmer testmode, ElmerGrid will not be used. It might not be installed.\n" - ) - else: - binary = settings.get_binary("ElmerGrid") - num_cores = settings.get_cores("ElmerGrid") - if binary is None: - raise WriteError("Could not find ElmerGrid binary.") - # for multithreading we first need a normal mesh creation run - # then a second to split the mesh into the number of used cores - argsBasic = [binary, _ELMERGRID_IFORMAT, _ELMERGRID_OFORMAT, unvPath] - args = argsBasic - args.extend(["-out", self.directory]) - if system() == "Windows": - subprocess.call( - args, stdout=subprocess.DEVNULL, startupinfo=femutils.startProgramInfo("hide") - ) - else: - subprocess.call(args, stdout=subprocess.DEVNULL) - if num_cores > 1: - args = argsBasic - args.extend(["-partdual", "-metiskway", str(num_cores), "-out", self.directory]) - if system() == "Windows": - subprocess.call( - args, - stdout=subprocess.DEVNULL, - startupinfo=femutils.startProgramInfo("hide"), - ) - else: - subprocess.call(args, stdout=subprocess.DEVNULL) - - def _writeStartinfo(self): - path = os.path.join(self.directory, _STARTINFO_NAME) - with open(path, "w") as f: - f.write(f"{_SIF_NAME}\n") - - def _exportToUnv(self, groups, mesh, meshPath): - unvGmshFd, unvGmshPath = tempfile.mkstemp(suffix=".unv") - brepFd, brepPath = tempfile.mkstemp(suffix=".brep") - geoFd, geoPath = tempfile.mkstemp(suffix=".geo") - os.close(brepFd) - os.close(geoFd) - os.close(unvGmshFd) - - tools = gmshtools.GmshTools(mesh) - tools.group_elements = {g: [g] for g in groups} - tools.group_nodes_export = False - tools.ele_length_map = {} - tools.temp_file_geometry = brepPath - tools.temp_file_geo = geoPath - tools.temp_file_mesh = unvGmshPath - - tools.get_dimension() - tools.get_region_data() - tools.get_boundary_layer_data() - tools.write_part_file() - tools.write_geo() - if self.testmode: - Console.PrintMessage( - "Solver Elmer testmode, Gmsh will not be used. It might not be installed.\n" - ) - import shutil - - shutil.copyfile(geoPath, os.path.join(self.directory, "group_mesh.geo")) - else: - tools.get_gmsh_command() - tools.run_gmsh_with_geo() - - ioMesh = Fem.FemMesh() - ioMesh.read(unvGmshPath) - ioMesh.write(meshPath) - - os.remove(brepPath) - os.remove(geoPath) - os.remove(unvGmshPath) - def _handleRedifinedConstants(self): """ redefine constants in self.constsdef according constant redefine objects @@ -315,17 +230,6 @@ class Writer: ) def _handleSimulation(self): - # check if we need to update the equation - self._updateSimulation(self.solver) - # output the equation parameters - # first check what equations we have - - # hasHeat ist not used, thus commented ATM - # hasHeat = False - # for equation in self.solver.Group: - # if femutils.is_of_type(equation, "Fem::EquationElmerHeat"): - # hasHeat = True - self._simulation("Coordinate System", self.solver.CoordinateSystem) self._simulation("Coordinate Mapping", (1, 2, 3)) # Elmer uses SI base units, but our mesh is in mm, therefore we must tell @@ -343,62 +247,10 @@ class Writer: self._simulation("Timestepping Method", "BDF") self._simulation("Use Mesh Names", True) - def _updateSimulation(self, solver): - # updates older simulations - if not hasattr(self.solver, "CoordinateSystem"): - solver.addProperty( - "App::PropertyEnumeration", "CoordinateSystem", "Coordinate System", "", locked=True - ) - solver.CoordinateSystem = solverClass.COORDINATE_SYSTEM - solver.CoordinateSystem = "Cartesian" - if not hasattr(self.solver, "BDFOrder"): - solver.addProperty( - "App::PropertyIntegerConstraint", - "BDFOrder", - "Timestepping", - "Order of time stepping method 'BDF'", - locked=True, - ) - solver.BDFOrder = (2, 1, 5, 1) - if not hasattr(self.solver, "OutputIntervals"): - solver.addProperty( - "App::PropertyIntegerList", - "OutputIntervals", - "Timestepping", - "After how many time steps a result file is output", - locked=True, - ) - solver.OutputIntervals = [1] - if not hasattr(self.solver, "SimulationType"): - solver.addProperty( - "App::PropertyEnumeration", "SimulationType", "Type", "", locked=True - ) - solver.SimulationType = solverClass.SIMULATION_TYPE - solver.SimulationType = "Steady State" - if not hasattr(self.solver, "TimestepIntervals"): - solver.addProperty( - "App::PropertyIntegerList", - "TimestepIntervals", - "Timestepping", - ( - "List of maximum optimization rounds if 'Simulation Type'\n" - "is either 'Scanning' or 'Transient'" - ), - locked=True, - ) - solver.TimestepIntervals = [100] - if not hasattr(self.solver, "TimestepSizes"): - solver.addProperty( - "App::PropertyFloatList", - "TimestepSizes", - "Timestepping", - ( - "List of time steps of optimization if 'Simulation Type'\n" - "is either 'Scanning' or 'Transient'" - ), - locked=True, - ) - solver.TimestepSizes = [0.1] + def _writeStartinfo(self): + path = os.path.join(self.directory, _STARTINFO_NAME) + with open(path, "w") as f: + f.write(f"{_SIF_NAME}\n") # ------------------------------------------------------------------------------------------- # Deformation diff --git a/src/Mod/Fem/femtaskpanels/task_solver_elmer.py b/src/Mod/Fem/femtaskpanels/task_solver_elmer.py new file mode 100644 index 0000000000..ff451f4705 --- /dev/null +++ b/src/Mod/Fem/femtaskpanels/task_solver_elmer.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2025 Mario Passaglia * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +__title__ = "FreeCAD task panel for Elmer solver" +__author__ = "Mario Passaglia" +__url__ = "https://www.freecad.org" + +## @package task_solver_elmer +# \ingroup FEM +# \brief task panel for Elmer solver + +from PySide import QtCore +from PySide import QtGui + +import FreeCAD +import FreeCADGui + +import FemGui + +from femsolver.elmer import elmertools + +from . import base_femlogtaskpanel + + +class _TaskPanel(base_femlogtaskpanel._BaseLogTaskPanel): + """ + The TaskPanel for run Elmer solver + """ + + def __init__(self, obj): + super().__init__(obj, elmertools.ElmerTools(obj)) + + self.form = FreeCADGui.PySideUic.loadUi( + FreeCAD.getHomePath() + "Mod/Fem/Resources/ui/SolverElmer.ui" + ) + + self.text_log = self.form.te_output + self.text_time = self.form.l_time + self.prepared = False + self.run_complete = False + + self.setup_connections() + + def setup_connections(self): + super().setup_connections() + + QtCore.QObject.connect( + self.form.ckb_working_directory, + QtCore.SIGNAL("toggled(bool)"), + self.working_directory_toggled, + ) + QtCore.QObject.connect( + self.form.cb_simulation_type, + QtCore.SIGNAL("currentIndexChanged(int)"), + self.simulation_type_changed, + ) + QtCore.QObject.connect( + self.form.pb_write_input, QtCore.SIGNAL("clicked()"), self.write_input_clicked + ) + QtCore.QObject.connect( + self.form.pb_edit_input, QtCore.SIGNAL("clicked()"), self.edit_input_clicked + ) + QtCore.QObject.connect( + self.form.fc_working_directory, + QtCore.SIGNAL("fileNameSelected(QString)"), + self.working_directory_selected, + ) + QtCore.QObject.connect( + self.form.pb_solver_version, QtCore.SIGNAL("clicked()"), self.get_version + ) + + self.get_object_params() + self.set_widgets() + + def preparation_finished(self): + # override base class method to not auto compute + self.prepared = True + if not self.run_complete: + self.timer.stop() + self.form.pb_edit_input.setEnabled(True) + else: + super().preparation_finished() + + def apply(self): + self.text_log.clear() + self.elapsed.restart() + if self.prepared: + self.timer.start(100) + self.tool.compute() + else: + # run complete process if 'Apply' is pressed without + # previously write the input files + self.run_complete = True + super().apply() + + def get_object_params(self): + self.simulation_type = self.obj.SimulationType + + def set_object_params(self): + self.obj.SimulationType = self.simulation_type + + def set_widgets(self): + "fill the widgets" + self.simulation_type_enum = self.obj.getEnumerationsOfProperty("SimulationType") + index = self.simulation_type_enum.index(self.simulation_type) + self.form.cb_simulation_type.addItems(self.simulation_type_enum) + self.form.cb_simulation_type.setCurrentIndex(index) + + self.form.fc_working_directory.setProperty("fileName", self.obj.WorkingDirectory) + self.form.ckb_working_directory.setChecked(True) + self.form.gpb_working_directory.setVisible(True) + + def simulation_type_changed(self, index): + self.simulation_type = self.simulation_type_enum[index] + self.obj.SimulationType = self.simulation_type + + def working_directory_selected(self): + self.obj.WorkingDirectory = self.form.fc_working_directory.property("fileName") + + def write_input_clicked(self): + self.prepared = False + self.run_complete = False + self.run_process() + + def edit_input_clicked(self): + gen_param = self.tool.fem_param.GetGroup("General") + internal = gen_param.GetBool("UseInternalEditor", True) + ext_editor_path = gen_param.GetString("ExternalEditorPath", "") + if internal or not ext_editor_path: + FemGui.open(self.tool.model_file) + else: + ext_editor_process = QtCore.QProcess() + ext_editor_process.start(ext_editor_path, [self.tool.model_file]) + ext_editor_process.waitForFinished() + + def working_directory_toggled(self, bool_value): + self.form.gpb_working_directory.setVisible(bool_value) diff --git a/src/Mod/Fem/femviewprovider/view_solver_elmer.py b/src/Mod/Fem/femviewprovider/view_solver_elmer.py new file mode 100644 index 0000000000..55459e8f7b --- /dev/null +++ b/src/Mod/Fem/femviewprovider/view_solver_elmer.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2025 Mario Passaglia * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +__title__ = "FreeCAD FEM solver Elmer view provider" +__author__ = "Mario Passaglia" +__url__ = "https://www.freecad.org" + +## @package view_elmer +# \ingroup FEM +# \brief solver Elmer view provider + +import FreeCADGui + +from femtaskpanels import task_solver_elmer +from femviewprovider import view_base_femobject + + +class VPSolverElmer(view_base_femobject.VPBaseFemObject): + + def __init__(self, vobj): + super().__init__(vobj) + vobj.addExtension("Gui::ViewProviderGroupExtensionPython") + + def getIcon(self): + return ":/icons/FEM_SolverElmer.svg" + + def setEdit(self, vobj, mode=0): + task = task_solver_elmer._TaskPanel(vobj.Object) + FreeCADGui.Control.showDialog(task) + + return True From 6c583d82f2286e73925eede63c9745da78c7aa4c Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 01:47:38 -0300 Subject: [PATCH 5/7] Fem: Use FileChooser widget in CalculiX task panel --- .../Fem/Gui/Resources/ui/SolverCalculiX.ui | 12 ++++------ .../Fem/femsolver/calculix/calculixtools.py | 4 ++-- .../Fem/femtaskpanels/task_solver_calculix.py | 23 +++++-------------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/Mod/Fem/Gui/Resources/ui/SolverCalculiX.ui b/src/Mod/Fem/Gui/Resources/ui/SolverCalculiX.ui index cc4f0869b6..a3b311be81 100644 --- a/src/Mod/Fem/Gui/Resources/ui/SolverCalculiX.ui +++ b/src/Mod/Fem/Gui/Resources/ui/SolverCalculiX.ui @@ -51,19 +51,15 @@ - + Path to working directory - true + false - - - - - - + + Gui::FileChooser::Mode::Directory diff --git a/src/Mod/Fem/femsolver/calculix/calculixtools.py b/src/Mod/Fem/femsolver/calculix/calculixtools.py index 4a49d1f557..a9921ca122 100644 --- a/src/Mod/Fem/femsolver/calculix/calculixtools.py +++ b/src/Mod/Fem/femsolver/calculix/calculixtools.py @@ -26,7 +26,7 @@ __author__ = "Mario Passaglia" __url__ = "https://www.freecad.org" -from PySide.QtCore import QProcess, QThread +from PySide.QtCore import QProcess, QThread, QProcessEnvironment import tempfile import os import shutil @@ -111,7 +111,7 @@ class CalculiXTools: def compute(self): self._clear_results() ccx_bin = settings.get_binary("Calculix") - env = self.process.processEnvironment() + env = QProcessEnvironment.systemEnvironment() num_cpu = self.fem_param.GetGroup("Ccx").GetInt( "AnalysisNumCPUs", QThread.idealThreadCount() ) diff --git a/src/Mod/Fem/femtaskpanels/task_solver_calculix.py b/src/Mod/Fem/femtaskpanels/task_solver_calculix.py index 583286706e..3ef72ab39f 100644 --- a/src/Mod/Fem/femtaskpanels/task_solver_calculix.py +++ b/src/Mod/Fem/femtaskpanels/task_solver_calculix.py @@ -81,18 +81,13 @@ class _TaskPanel(base_femlogtaskpanel._BaseLogTaskPanel): self.form.pb_edit_input, QtCore.SIGNAL("clicked()"), self.edit_input_clicked ) QtCore.QObject.connect( - self.form.pb_working_directory, - QtCore.SIGNAL("clicked()"), - self.working_directory_clicked, + self.form.fc_working_directory, + QtCore.SIGNAL("fileNameSelected(QString)"), + self.working_directory_selected, ) QtCore.QObject.connect( self.form.pb_solver_version, QtCore.SIGNAL("clicked()"), self.get_version ) - QtCore.QObject.connect( - self.form.let_working_directory, - QtCore.SIGNAL("editingFinished()"), - self.working_directory_edited, - ) self.get_object_params() self.set_widgets() @@ -131,7 +126,7 @@ class _TaskPanel(base_femlogtaskpanel._BaseLogTaskPanel): self.form.cb_analysis_type.addItems(self.analysis_type_enum) self.form.cb_analysis_type.setCurrentIndex(index) - self.form.let_working_directory.setText(self.obj.WorkingDirectory) + self.form.fc_working_directory.setProperty("fileName", self.obj.WorkingDirectory) self.form.ckb_working_directory.setChecked(False) self.form.gpb_working_directory.setVisible(False) @@ -139,14 +134,8 @@ class _TaskPanel(base_femlogtaskpanel._BaseLogTaskPanel): self.analysis_type = self.analysis_type_enum[index] self.obj.AnalysisType = self.analysis_type - def working_directory_clicked(self): - directory = QtGui.QFileDialog.getExistingDirectory(dir=self.obj.WorkingDirectory) - if directory: - self.form.let_working_directory.setText(directory) - self.form.let_working_directory.editingFinished.emit() - - def working_directory_edited(self): - self.obj.WorkingDirectory = self.form.let_working_directory.text() + def working_directory_selected(self): + self.obj.WorkingDirectory = self.form.fc_working_directory.property("fileName") def write_input_clicked(self): self.prepared = False From c7e74a68a769bd2d6e7129b0e4f58f5598aa50a6 Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 02:24:19 -0300 Subject: [PATCH 6/7] Fem: Update Elmer test files --- src/Mod/Fem/femtest/app/test_open.py | 4 ++-- src/Mod/Fem/femtest/app/test_solver_elmer.py | 25 ++++---------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/Mod/Fem/femtest/app/test_open.py b/src/Mod/Fem/femtest/app/test_open.py index 3c6a84b6fb..f66da9b81b 100644 --- a/src/Mod/Fem/femtest/app/test_open.py +++ b/src/Mod/Fem/femtest/app/test_open.py @@ -259,9 +259,9 @@ class TestObjectOpen(unittest.TestCase): self.assertEqual(SolverCcxTools, doc.SolverCcxTools.Proxy.__class__) - from femsolver.elmer.solver import Proxy + from femobjects.solver_elmer import SolverElmer - self.assertEqual(Proxy, doc.SolverElmer.Proxy.__class__) + self.assertEqual(SolverElmer, doc.SolverElmer.Proxy.__class__) from femsolver.z88.solver import Proxy diff --git a/src/Mod/Fem/femtest/app/test_solver_elmer.py b/src/Mod/Fem/femtest/app/test_solver_elmer.py index 75ef099d92..1ede09fc50 100644 --- a/src/Mod/Fem/femtest/app/test_solver_elmer.py +++ b/src/Mod/Fem/femtest/app/test_solver_elmer.py @@ -30,7 +30,7 @@ from os.path import join import FreeCAD -import femsolver.run +from femsolver.elmer import writer from . import support_utils as testtools from .support_utils import fcc_print from .support_utils import get_namefromdef @@ -118,12 +118,8 @@ class TestSolverElmer(unittest.TestCase): # write input files # fcc_print("Checking FEM input file writing for Elmer solver framework solver ...") - machine_elmer = self.document.SolverElmer.Proxy.createMachine( - self.document.SolverElmer, analysis_dir, True - ) - machine_elmer.target = femsolver.run.PREPARE - machine_elmer.start() - machine_elmer.join() # wait for the machine to finish. + w = writer.Writer(self.document.SolverElmer, analysis_dir) + w.write_solver_input() fcc_print("Test writing STARTINFO file") startinfo_given = join(self.test_file_dir, "ELMERSOLVER_STARTINFO") @@ -139,13 +135,6 @@ class TestSolverElmer(unittest.TestCase): ret = testtools.compare_files(casefile_given, casefile_totest) self.assertFalse(ret, f"case write file test failed.\n{ret}") - fcc_print("Test writing GMSH geo file") - gmshgeofile_given = join(self.test_file_dir, "group_mesh.geo") - gmshgeofile_totest = join(analysis_dir, "group_mesh.geo") - # fcc_print("Comparing {} to {}".format(gmshgeofile_given, gmshgeofile_totest)) - ret = testtools.compare_files(gmshgeofile_given, gmshgeofile_totest) - self.assertFalse(ret, f"GMSH geo write file test failed.\n{ret}") - # ******************************************************************************************** def test_ccxcantilever_faceload_0_mm(self): fcc_print("") @@ -193,12 +182,8 @@ class TestSolverElmer(unittest.TestCase): self.document.saveAs(save_fc_file) # write input file - machine = self.document.SolverElmer.Proxy.createMachine( - self.document.SolverElmer, working_dir, True # set testmode to True - ) - machine.target = femsolver.run.PREPARE - machine.start() - machine.join() # wait for the machine to finish + w = writer.Writer(self.document.SolverElmer, working_dir) + w.write_solver_input() # compare input file with the given one inpfile_given = join(self.test_file_dir, base_name + self.ending) From 428340a97425b598d31d60686d2226451d067f70 Mon Sep 17 00:00:00 2001 From: marioalexis Date: Wed, 29 Oct 2025 02:25:12 -0300 Subject: [PATCH 7/7] Fem: Migrate old Elmer solver object --- src/Mod/Fem/femtools/migrate_app.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Mod/Fem/femtools/migrate_app.py b/src/Mod/Fem/femtools/migrate_app.py index 8b97e5b1ce..e1d941270c 100644 --- a/src/Mod/Fem/femtools/migrate_app.py +++ b/src/Mod/Fem/femtools/migrate_app.py @@ -41,6 +41,7 @@ class FemMigrateApp: if fullname in { "femsolver.elmer.equations", "femsolver.elmer.equations.fluxsolver", + "femsolver.elmer.solver", "femobjects", "femobjects._FemConstraintBodyHeatSource", "femobjects._FemConstraintElectrostaticPotential", @@ -114,7 +115,6 @@ class FemMigrateApp: return self.load_module(module) def load_module(self, module): - if module.__name__ == "femsolver.elmer.equations": return self if module.__name__ == "femsolver.elmer.equations.fluxsolver": @@ -124,6 +124,15 @@ class FemMigrateApp: if FreeCAD.GuiUp: module.ViewProxy = femsolver.elmer.equations.flux.ViewProxy + if module.__name__ == "femsolver.elmer.solver": + from femobjects.solver_elmer import SolverElmer + + module.Proxy = SolverElmer + if FreeCAD.GuiUp: + from femviewprovider.view_solver_elmer import VPSolverElmer + + module.ViewProxy = VPSolverElmer + if module.__name__ == "femobjects": module.__path__ = "femobjects" if module.__name__ == "femobjects._FemConstraintBodyHeatSource":