├── .github └── workflows │ └── pythonpublish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── conf.py ├── index.rst ├── modules.rst └── requirements.txt ├── examples └── example.py ├── models └── cornerCube.step ├── pyccx ├── __init__.py ├── analysis │ ├── __init__.py │ └── analysis.py ├── bc │ ├── __init__.py │ └── boundarycondition.py ├── core.py ├── loadcase │ ├── __init__.py │ └── loadcase.py ├── material │ ├── __init__.py │ └── material.py ├── mesh │ ├── __init__.py │ ├── mesh.py │ └── mesher.py ├── results │ ├── __init__.py │ └── results.py └── version.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── context.py ├── test_advanced.py └── test_basic.py /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package to PyPI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Calculix input, log and output files 10 | *.cvg 11 | *.inp 12 | *.frd 13 | *.out 14 | *.spool 15 | *.sta 16 | 17 | # Inspection profiles 18 | .idea/ 19 | 20 | # Distribution / packaging 21 | .Python 22 | env/ 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *,cover 57 | .hypothesis/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # IPython Notebook 81 | .ipynb_checkpoints 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # dotenv 90 | .env 91 | 92 | # virtualenv 93 | .venv/ 94 | venv/ 95 | ENV/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, Luke Parry 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install -r requirements.txt 3 | 4 | test: 5 | nosetests tests 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyCCX - Python Library for Calculix 2 | ======================================= 3 | 4 | .. image:: https://github.com/drlukeparry/pyccx/workflows/Python%20application/badge.svg 5 | :target: https://github.com/drlukeparry/pyccx/actions 6 | .. image:: https://readthedocs.org/projects/pyccx/badge/?version=latest 7 | :target: https://pyccx.readthedocs.io/en/latest/?badge=latest 8 | :alt: Documentation Status 9 | .. image:: https://badge.fury.io/py/PyCCX.svg 10 | :target: https://badge.fury.io 11 | .. image:: https://badges.gitter.im/pyccx/community.svg 12 | :target: https://gitter.im/pyccx/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge 13 | :alt: Chat on Gitter 14 | 15 | Provides a library for creating and running 3D FEA simulations using the opensource Calculix FEA Package. 16 | 17 | The aims of this project was to provide a simple framework for implemented 3D FEA Analysis using the opensource `Calculix `_ solver. 18 | The analysis is complimented by use of the recent introduction of the 19 | `GMSH-SDK `_ , an extension to `GMSH `_ to provide API bindings for different programming languages 20 | by the project authors to provide sophisticated 3D FEA mesh generation outside of the GUI implementation. This project aims to provide an integrated approach for generating full 3D FEA analysis 21 | for use in research, development and prototyping in a Python environment. Along with setting up and processing the analysis, 22 | convenience functions are included. 23 | 24 | The inception of this project was a result of finding no native Python/Matlab package available to perfom full non-linear FEA analysis 25 | of 3D CAD models in order to prototype a concept related to 3D printing. The project aims to compliment the work of 26 | the `PyCalculix project `_, which currently is limited to providing capabilities 27 | to generate 2D Meshes and FEA analysis for 2D planar structures. The potential in the future is to provide 28 | a more generic extensible framework compatible with different opensource and commercial FEA solvers (e.g. Abaqus, Marc, Z88, Elmer). 29 | 30 | An interface that built upon GMSH was required to avoid the use of the GUI, and the domain specific .geo scripts. 31 | `Learn more `_. 32 | 33 | Structure 34 | ############## 35 | 36 | PyCCX framework consists of classes for specifying common components on the pre-processing phase, including the following 37 | common operations: 38 | 39 | * Mesh generation 40 | * Creating and applying boundary conditions 41 | * Creating load cases 42 | * Creating and assigning material models 43 | * Performing the simulation 44 | 45 | In addition, a meshing class provides an interface with GMSH for performing the meshing routines and for associating 46 | boundary conditions with the elements/faces generated from geometrical CAD entities. The Simulation class assembles the 47 | analysis and performs the execution to the Calculix Solver. Results obtained upon completion of the analysis can be processed. 48 | Currently the analysis is unit-less, therefore the user should ensure that all constant, material paramters, and geometric 49 | lengths are consistent - by default GMSH assumes 'mm' units. 50 | 51 | Current Features 52 | ****************** 53 | 54 | **Meshing:** 55 | 56 | * Integration with GMSH for generation 3D FEA Meshes (Tet4, Tet10 currently supported) 57 | * Merging CAD assemblies using GMSH 58 | * Attaching boundary conditions to Geometrical CAD entities 59 | 60 | **FEA Capabilities:** 61 | 62 | * **Boundary Conditions** (Acceleration, Convection, Fixed Displacements, Forces, Fluxes, Pressure, Radiation) 63 | * **Loadcase Types** (Structural Static, Thermal, Coupled Thermo-Mechanical) 64 | * **Materials** (Non-linear Elasto-Plastic Material) 65 | 66 | **Results Processing:** 67 | 68 | * Element and Nodal Results can be obtained across timesteps 69 | 70 | 71 | Installation 72 | ************* 73 | Installation is currently supported on Windows, all this further support will be added for 74 | Linux environments. PyCCX can be installed along with dependencies for GMSH automatically using. 75 | 76 | .. code:: bash 77 | 78 | pip install pyccx 79 | 80 | 81 | Depending on your environment, you will need to install the latest version of Calculix. This can be done through 82 | the conda-forge `calculix package `_ in the Anaconda distribution, 83 | 84 | .. code:: bash 85 | 86 | conda install -c conda-forge calculix 87 | 88 | 89 | or alternatively downloading the package directly. On Windows platforms the path of the executable needs to be initialised before use. 90 | 91 | .. code:: python 92 | 93 | from pyccx.core import Simulation 94 | 95 | # Set the path for Calculix in Windows 96 | Simulation.setCalculixPath('Path') 97 | 98 | 99 | Usage 100 | ****** 101 | 102 | The following code excerpt shows an example for creating and running a steady state thermal analysis of model using PyCCX 103 | of an existing mesh generated using the pyccx.mesh.mesher class. 104 | 105 | .. code:: python 106 | 107 | from pyccx.core import DOF, ElementSet, NodeSet, SurfaceSet, Simulation 108 | from pyccx.results import ElementResult, NodalResult, ResultProcessor 109 | from pyccx.loadcase import LoadCase, LoadCaseType 110 | from pyccx.material import ElastoPlasticMaterial 111 | 112 | # Set the path for Calculix in Windows 113 | Simulation.setCalculixPath('Path') 114 | 115 | # Create a thermal load case and set the timesettings 116 | thermalLoadCase = LoadCase('Thermal Load Case') 117 | 118 | # Set the loadcase type to thermal - eventually this will be individual analysis classes with defaults 119 | thermalLoadCase.setLoadCaseType(LoadCaseType.THERMAL) 120 | 121 | # Set the thermal analysis to be a steady state simulation 122 | thermalLoadCase.isSteadyState = True 123 | 124 | # Attach the nodal and element result options to each loadcase 125 | # Set the nodal and element variables to record in the results (.frd) file 126 | nodeThermalPostResult = NodalResult('VolumeNodeSet') 127 | nodeThermalPostResult.useNodalTemperatures = True 128 | 129 | elThermalPostResult = ElementResult('Volume1') 130 | elThermalPostResult.useHeatFlux = True 131 | 132 | # Add the result configurations to the loadcase 133 | thermalLoadCase.resultSet = [nodeThermalPostResult, elThermalPostResult] 134 | 135 | # Set thermal boundary conditions for the loadcase using specific NodeSets 136 | thermalLoadCase.boundaryConditions.append( 137 | {'type': 'fixed', 'nodes': 'surface6Nodes', 'dof': [DOF.T], 'value': [60]}) 138 | 139 | thermalLoadCase.boundaryConditions.append( 140 | {'type': 'fixed', 'nodes': 'surface1Nodes', 'dof': [DOF.T], 'value': [20]}) 141 | 142 | # Material 143 | # Add a elastic material and assign it to the volume. 144 | # Note ensure that the units correctly correspond with the geometry length scales 145 | steelMat = ElastoPlasticMaterial('Steel') 146 | steelMat.density = 1.0 # Density 147 | steelMat.cp = 1.0 # Specific Heat 148 | steelMat.k = 1.0 # Thermal Conductivity 149 | 150 | analysis.materials.append(steelMat) 151 | 152 | # Assign the material the volume (use the part name set for geometry) 153 | analysis.materialAssignments = [('PartA', 'Steel')] 154 | 155 | # Set the loadcases used in sequential order 156 | analysis.loadCases = [thermalLoadCase] 157 | 158 | # Analysis Run # 159 | # Run the analysis 160 | analysis.run() 161 | 162 | # Open the results file ('input') is currently the file that is generated by PyCCX 163 | results = analysis.results() 164 | results.load() 165 | 166 | 167 | The basic usage is split between the meshing facilities provided by GMSH and analysing a problem using the Calculix Solver. Documented 168 | examples are provided in `examples `_ . 169 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # sample documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Apr 16 21:22:43 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | import mock 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath('..')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc', 30 | 'sphinx_automodapi.automodapi', 31 | 'sphinx.ext.autosummary', 32 | 'autodocsumm', 33 | 'sphinx_autodoc_typehints', 34 | 'sphinx.ext.coverage'] 35 | 36 | # See options here for audodocsumm https://readthedocs.org/projects/sphinx-automodapi/downloads/pdf/latest/ 37 | 38 | autodoc_default_options = { 39 | 'autosummary': True, 40 | 'automodapi_inheritance_diagram': False 41 | } 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix of source filenames. 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = u'pyccx' 57 | copyright = u'2020, Luke Parry' 58 | author = 'Luke Parry' 59 | 60 | autodoc_default_options = { 61 | 'autosummary': True, 62 | 'automodapi_inheritance_diagram': False 63 | } 64 | 65 | 66 | # The version info for the project you're documenting, acts as replacement for 67 | # |version| and |release|, also used in various other places throughout the 68 | # built documents. 69 | # 70 | # The short X.Y version. 71 | version = 'v0.1.1' 72 | # The full version, including alpha/beta/rc tags. 73 | release = 'v0.1.1' 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | #language = None 78 | 79 | # There are two options for replacing |today|: either, you set today to some 80 | # non-false value, then it is used: 81 | #today = '' 82 | # Else, today_fmt is used as the format for a strftime call. 83 | #today_fmt = '%B %d, %Y' 84 | 85 | # List of patterns, relative to source directory, that match files and 86 | # directories to ignore when looking for source files. 87 | exclude_patterns = ['_build'] 88 | 89 | # The reST default role (used for this markup: `text`) to use for all documents. 90 | #default_role = None 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | #add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | #add_module_names = True 98 | 99 | # If true, sectionauthor and moduleauthor directives will be shown in the 100 | # output. They are ignored by default. 101 | #show_authors = False 102 | 103 | # The name of the Pygments (syntax highlighting) style to use. 104 | pygments_style = 'sphinx' 105 | 106 | # A list of ignored prefixes for module index sorting. 107 | #modindex_common_prefix = [] 108 | 109 | 110 | # -- Options for HTML output --------------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | html_theme = 'default' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 146 | # using the given strftime format. 147 | #html_last_updated_fmt = '%b %d, %Y' 148 | 149 | # If true, SmartyPants will be used to convert quotes and dashes to 150 | # typographically correct entities. 151 | #html_use_smartypants = True 152 | 153 | # Custom sidebar templates, maps document names to template names. 154 | #html_sidebars = {} 155 | 156 | # Additional templates that should be rendered to pages, maps page names to 157 | # template names. 158 | #html_additional_pages = {} 159 | 160 | # If false, no module index is generated. 161 | #html_domain_indices = True 162 | 163 | # If false, no index is generated. 164 | #html_use_index = True 165 | 166 | # If true, the index is split into individual pages for each letter. 167 | #html_split_index = False 168 | 169 | # If true, links to the reST sources are added to the pages. 170 | #html_show_sourcelink = True 171 | 172 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 173 | #html_show_sphinx = True 174 | 175 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 176 | #html_show_copyright = True 177 | 178 | # If true, an OpenSearch description file will be output, and all pages will 179 | # contain a tag referring to it. The value of this option must be the 180 | # base URL from which the finished HTML is served. 181 | #html_use_opensearch = '' 182 | 183 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 184 | #html_file_suffix = None 185 | 186 | # Output file base name for HTML help builder. 187 | htmlhelp_basename = 'sampledoc' 188 | 189 | 190 | # -- Options for LaTeX output -------------------------------------------------- 191 | 192 | latex_elements = { 193 | # The paper size ('letterpaper' or 'a4paper'). 194 | #'papersize': 'letterpaper', 195 | 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | #'pointsize': '10pt', 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #'preamble': '', 201 | } 202 | 203 | # Grouping the document tree into LaTeX files. List of tuples 204 | # (source start file, target name, title, author, documentclass [howto/manual]). 205 | latex_documents = [ 206 | ('index', 'sample.tex', u'PyCCX Documentation', 207 | u'Luke Parry', 'manual'), 208 | ] 209 | 210 | # The name of an image file (relative to this directory) to place at the top of 211 | # the title page. 212 | #latex_logo = None 213 | 214 | # For "manual" documents, if this is true, then toplevel headings are parts, 215 | # not chapters. 216 | #latex_use_parts = False 217 | 218 | # If true, show page references after internal links. 219 | #latex_show_pagerefs = False 220 | 221 | # If true, show URL addresses after external links. 222 | #latex_show_urls = False 223 | 224 | # Documents to append as an appendix to all manuals. 225 | #latex_appendices = [] 226 | 227 | # If false, no module index is generated. 228 | #latex_domain_indices = True 229 | 230 | 231 | # -- Options for manual page output -------------------------------------------- 232 | 233 | # One entry per manual page. List of tuples 234 | # (source start file, name, description, authors, manual section). 235 | man_pages = [ 236 | ('index', 'pyccx', u'PyCCX Documentation', 237 | [u'Luke Parry'], 1) 238 | ] 239 | 240 | # If true, show URL addresses after external links. 241 | #man_show_urls = False 242 | 243 | 244 | # -- Options for Texinfo output ------------------------------------------------ 245 | 246 | # Grouping the document tree into Texinfo files. List of tuples 247 | # (source start file, target name, title, author, 248 | # dir menu entry, description, category) 249 | texinfo_documents = [ 250 | ('index', 'project', u'PyCCX Documentation', 251 | u'Luke Parry', 'project', 'One line description of project.', 252 | 'Miscellaneous'), 253 | ] 254 | 255 | # Documents to append as an appendix to all manuals. 256 | #texinfo_appendices = [] 257 | 258 | # If false, no module index is generated. 259 | #texinfo_domain_indices = True 260 | 261 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 262 | #texinfo_show_urls = 'footnote' 263 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. sample documentation master file, created by 2 | sphinx-quickstart on Mon Apr 16 21:22:43 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PyCCX's documentation! 7 | ================================== 8 | 9 | Links 10 | ========== 11 | .. toctree:: 12 | PyCCX On Github 13 | 14 | Install 15 | ========== 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | Module Reference 20 | ================== 21 | .. toctree:: 22 | :maxdepth: 2 23 | 24 | modules 25 | 26 | 27 | ===================== 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | 32 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | .. automodapi:: pyccx.analysis 2 | :no-inheritance-diagram: 3 | :no-inherited-members: 4 | :toctree: api 5 | 6 | .. automodapi:: pyccx.bc 7 | :toctree: api 8 | :no-inheritance-diagram: 9 | :no-inherited-members: 10 | 11 | .. automodapi:: pyccx.core 12 | :toctree: api 13 | :no-inheritance-diagram: 14 | :no-inherited-members: 15 | 16 | .. automodapi:: pyccx.loadcase 17 | :no-inheritance-diagram: 18 | :no-inherited-members: 19 | :toctree: api 20 | 21 | .. automodapi:: pyccx.material 22 | :no-inheritance-diagram: 23 | :no-inherited-members: 24 | :toctree: api 25 | 26 | .. automodapi:: pyccx.results 27 | :no-inheritance-diagram: 28 | :no-inherited-members: 29 | :toctree: api 30 | 31 | .. automodapi:: pyccx.mesh 32 | :no-inheritance-diagram: 33 | :no-inherited-members: 34 | :toctree: api 35 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | numpy 3 | gmsh>=4.7 4 | autodocsumm 5 | pypandoc 6 | sphinx-automodapi 7 | sphinx-autodoc-typehints 8 | sphinx-autodoc-typehints==1.10.3 9 | sphinx-paramlinks -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | 2 | import pyccx 3 | 4 | from pyccx.mesh import ElementType, Mesher 5 | 6 | from pyccx.bc import Fixed, HeatFlux 7 | from pyccx.analysis import Simulation 8 | from pyccx.core import DOF, ElementSet, NodeSet, SurfaceSet 9 | from pyccx.results import ElementResult, NodalResult, ResultProcessor 10 | from pyccx.loadcase import LoadCase, LoadCaseType 11 | from pyccx.material import ElastoPlasticMaterial 12 | 13 | # Create a Mesher object to interface with GMSH. Provide a unique name. Multiple instance of this can be created. 14 | myMeshModel = Mesher('myModel') 15 | 16 | # Set the number of threads to use for any multi-threaded meshing algorithms e.g. HXT 17 | myMeshModel.setNumThreads(4) 18 | myMeshModel.setOptimiseNetgen(True) 19 | 20 | # Set the meshing algorithm (optional) to use globally. 21 | myMeshModel.setMeshingAlgorithm(pyccx.mesh.MeshingAlgorithm.FRONTAL_DELAUNAY) 22 | 23 | # Add the geometry and assign a physical name 'PartA' which can reference the elements generated for the volume 24 | myMeshModel.addGeometry('../models/cornerCube.step', 'PartA') 25 | 26 | """ 27 | Merges an assembly together. This is necessary there multiple bodies or volumes which share coincident faces. GMSH 28 | will automatically stitch these surfaces together and create a shared boundary which is often useful performing 29 | analyses where fields overlap (e.g. heat transfer). This should not be done if contact analysis is performed. 30 | """ 31 | 32 | myMeshModel.mergeGeometry() 33 | 34 | # Optionally set hte name of boundary name using the GMSH geometry identities 35 | myMeshModel.setEntityName((2,1), 'MySurface1') 36 | myMeshModel.setEntityName((2,2), 'MySurface2') 37 | myMeshModel.setEntityName((2,3), 'Bottom_Face') 38 | myMeshModel.setEntityName((2,4), 'MySurface4') 39 | myMeshModel.setEntityName((2,5), 'MySurface5') 40 | myMeshModel.setEntityName((3,1), 'PartA') 41 | 42 | # Set the size of the mesh 43 | geomPoints = myMeshModel.getPointsFromVolume(1) 44 | myMeshModel.setMeshSize(geomPoints, 0.5) # MM 45 | 46 | # Generate the mesh 47 | myMeshModel.generateMesh() 48 | 49 | # Obtain the surface faces (normals facing outwards) for surface 50 | surfFaces2 = myMeshModel.getSurfaceFacesFromSurfId(1) # MySurface1 51 | bottomFaces = myMeshModel.getSurfaceFacesFromSurfId(3) # ('Bottom_Face 52 | 53 | # Obtain all nodes associated with each surface 54 | surface1Nodes = myMeshModel.getNodesFromEntity((2,1)) # MySurface 55 | surface2Nodes = myMeshModel.getNodesFromEntity((2,2)) # MySurface2 56 | surface4Nodes = myMeshModel.getNodesFromEntity((2,4)) # MySurface4 57 | surface6Nodes = myMeshModel.getNodesFromEntity((2,6)) # MySurface6 58 | 59 | # An alternative method is to get nodes via the surface 60 | bottomFaceNodes = myMeshModel.getNodesFromSurfaceByName('Bottom_Face') 61 | 62 | # or via general query 63 | surface5Nodes = myMeshModel.getNodesByEntityName('MySurface5') # MySurface5 64 | 65 | # Obtain nodes from the volume 66 | volumeNodes = myMeshModel.getNodesFromVolumeByName('PartA') 67 | 68 | # The generated mesh can be interactively viewed natively within gmsh by calling the following 69 | #myMeshModel.showGui() 70 | 71 | """ Create the analysis""" 72 | # Set the number of simulation threads to be used by Calculix Solver across all analyses 73 | 74 | Simulation.setNumThreads(4) 75 | analysis = Simulation(myMeshModel) 76 | 77 | # Optionally set the working the base working directory 78 | analysis.setWorkingDirectory('.') 79 | 80 | print('Calculix version: {:d}. {:d}'.format(*analysis.version())) 81 | 82 | # Add the Node Sets For Attaching Boundary Conditions # 83 | # Note a unique name must be provided 84 | surface1NodeSet = NodeSet('surface1Nodes', surface1Nodes) 85 | surface2NodeSet = NodeSet('surface2Nodes', surface2Nodes) 86 | surface4NodeSet = NodeSet('surface4Nodes', surface4Nodes) 87 | surface5NodeSet = NodeSet('surface5Nodes', surface5Nodes) 88 | surface6NodeSet = NodeSet('surface6Nodes', surface6Nodes) 89 | bottomFaceNodeSet = NodeSet('bottomFaceNodes', bottomFaceNodes) 90 | volNodeSet = NodeSet('VolumeNodeSet', volumeNodes) 91 | 92 | # Create an element set using a concise approach 93 | partElSet = ElementSet('PartAElSet', myMeshModel.getElements((3,1))) 94 | 95 | # Create a surface set 96 | bottomFaceSet = SurfaceSet('bottomSurface', bottomFaces) 97 | 98 | # Add the userdefined nodesets to the analysis 99 | analysis.nodeSets = [surface1NodeSet, surface2NodeSet,surface4NodeSet, surface5NodeSet, surface6NodeSet, 100 | bottomFaceNodeSet, volNodeSet] 101 | 102 | 103 | # =============== Initial Conditions =============== # 104 | 105 | analysis.initialConditions.append({'type': 'temperature', 'set': 'VolumeNodeSet', 'value': 0.0}) 106 | 107 | # =============== Thermal Load Cases =============== # 108 | 109 | # Create a thermal load case and set the timesettings 110 | thermalLoadCase = LoadCase('Thermal Load Case') 111 | 112 | # Set the loadcase type to thermal - eventually this will be individual classes 113 | thermalLoadCase.setLoadCaseType(LoadCaseType.THERMAL) 114 | 115 | # Set the thermal analysis to be a steadystate simulation 116 | thermalLoadCase.isSteadyState = True 117 | thermalLoadCase.setTimeStep(5.0, 5.0, 5.0) 118 | 119 | # Attach the nodal and element result options to each loadcase 120 | # Set the nodal and element variables to record in the results (.frd) file 121 | nodeThermalPostResult = NodalResult(volNodeSet) 122 | nodeThermalPostResult.useNodalTemperatures = True 123 | 124 | elThermalPostResult = ElementResult(partElSet) 125 | elThermalPostResult.useHeatFlux = True 126 | 127 | thermalLoadCase.resultSet = [nodeThermalPostResult, elThermalPostResult] 128 | 129 | 130 | # Set thermal boundary conditions for the loadcase 131 | thermalLoadCase.boundaryConditions = [Fixed(surface6NodeSet, [DOF.T], [60.0]), 132 | Fixed(surface1NodeSet, dof=[DOF.T], values = [20.0]), 133 | HeatFlux(bottomFaceSet,flux=50.0)] 134 | 135 | # ====================== Material ====================== # 136 | # Add a elastic material and assign it to the volume. 137 | # Note ensure that the units correctly correspond with the geometry length scales 138 | 139 | steelMat = ElastoPlasticMaterial('Steel') 140 | steelMat.E = 210000. 141 | steelMat.alpha_CTE = [25e-6, 23e-6, 24e-6] # Thermal Expansion Coefficient 142 | steelMat.density = 1.0 # Density 143 | steelMat.cp = 1.0 # Specific Heat 144 | steelMat.k = 1.0 # Thermal Conductivity 145 | 146 | analysis.materials.append(steelMat) 147 | 148 | # Assign the material the volume (use the part name set for geometry) 149 | analysis.materialAssignments = [('PartA', 'Steel')] 150 | 151 | # Set the loadcases used in sequential order 152 | analysis.loadCases = [thermalLoadCase] 153 | 154 | # ====================== Analysis Run ====================== # 155 | 156 | # Run the analysis 157 | analysis.run() 158 | 159 | # Open the results file ('input') is currently the file that is generated by PyCCX 160 | results = analysis.results() 161 | 162 | # The call to read must be done to load all loadcases and timesteps from the results file 163 | results.read() 164 | 165 | # Obtain the nodal temperatures 166 | nodalTemp = results.lastIncrement()['temp'][:, 1] 167 | 168 | # Obtain the nodal coordinates and elements for further p 169 | tetEls = myMeshModel.getElementsByType(ElementType.TET4) -------------------------------------------------------------------------------- /models/cornerCube.step: -------------------------------------------------------------------------------- 1 | ISO-10303-21; 2 | HEADER; 3 | FILE_DESCRIPTION (( 'STEP AP203' ), 4 | '1' ); 5 | FILE_NAME ('Web Model 43296_43296.step', 6 | '2018-10-11T14:58:59', 7 | ( '' ), 8 | ( '' ), 9 | 'SwSTEP 2.0', 10 | 'SolidWorks 2016', 11 | '' ); 12 | FILE_SCHEMA (( 'CONFIG_CONTROL_DESIGN' )); 13 | ENDSEC; 14 | 15 | DATA; 16 | #1 = ORGANIZATION ( 'UNSPECIFIED', 'UNSPECIFIED', '' ) ; 17 | #2 = ORIENTED_EDGE ( 'NONE', *, *, #56, .F. ) ; 18 | #3 = AXIS2_PLACEMENT_3D ( 'NONE', #118, #63, #230 ) ; 19 | #4 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 30.00000000000000000, 0.0000000000000000000 ) ) ; 20 | #5 =( LENGTH_UNIT ( ) NAMED_UNIT ( * ) SI_UNIT ( .MILLI., .METRE. ) ); 21 | #6 = APPROVAL ( #134, 'UNSPECIFIED' ) ; 22 | #7 = PERSON_AND_ORGANIZATION_ROLE ( 'design_owner' ) ; 23 | #8 =( BOUNDED_CURVE ( ) B_SPLINE_CURVE ( 3, ( #74, #19, #95, #127 ), 24 | .UNSPECIFIED., .F., .T. ) 25 | B_SPLINE_CURVE_WITH_KNOTS ( ( 4, 4 ), 26 | ( 5.235987755982993300, 7.330382858376186300 ), 27 | .UNSPECIFIED. ) 28 | CURVE ( ) GEOMETRIC_REPRESENTATION_ITEM ( ) RATIONAL_B_SPLINE_CURVE ( ( 1.000000000000000000, 0.6666666666666674100, 0.6666666666666674100, 1.000000000000000000 ) ) 29 | REPRESENTATION_ITEM ( '' ) ); 30 | #9 = CALENDAR_DATE ( 2018, 11, 10 ) ; 31 | #10 = LOCAL_TIME ( 10, 58, 59.00000000000000000, #181 ) ; 32 | #11 = SECURITY_CLASSIFICATION ( '', '', #104 ) ; 33 | #12 = SHAPE_DEFINITION_REPRESENTATION ( #177, #195 ) ; 34 | #13 = ORIENTED_EDGE ( 'NONE', *, *, #122, .T. ) ; 35 | #14 = UNCERTAINTY_MEASURE_WITH_UNIT (LENGTH_MEASURE( 1.000000000000000100E-005 ), #5, 'distance_accuracy_value', 'NONE'); 36 | #15 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 2.383525667003057800, -6.349999999999999600 ) ) ; 37 | #16 = ORIENTED_EDGE ( 'NONE', *, *, #220, .F. ) ; 38 | #17 = EDGE_CURVE ( 'NONE', #131, #143, #30, .T. ) ; 39 | #18 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 2.383525667003057800, -6.349999999999999600 ) ) ; 40 | #19 = CARTESIAN_POINT ( 'NONE', ( -7.937500000000003600, -1.064373873755038600, 2.749630657015562600 ) ) ; 41 | #20 = CIRCLE ( 'NONE', #129, 6.349999999999999600 ) ; 42 | #21 = DIRECTION ( 'NONE', ( -0.0000000000000000000, -1.000000000000000000, -0.0000000000000000000 ) ) ; 43 | #22 = PRODUCT_RELATED_PRODUCT_CATEGORY ( 'detail', '', ( #68 ) ) ; 44 | #23 = APPROVAL_PERSON_ORGANIZATION ( #77, #85, #232 ) ; 45 | #24 = CARTESIAN_POINT ( 'NONE', ( 6.349999999999975700, 1.114897253674673300, 3.719743878930865700 ) ) ; 46 | #25 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, -1.000000000000000000 ) ) ; 47 | #26 = APPLICATION_CONTEXT ( 'configuration controlled 3d designs of mechanical parts and assemblies' ) ; 48 | #27 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 49 | #28 = DIRECTION ( 'NONE', ( 1.000000000000000000, 0.0000000000000000000, 0.0000000000000000000 ) ) ; 50 | #29 = EDGE_LOOP ( 'NONE', ( #227, #237, #150, #112 ) ) ; 51 | #30 = LINE ( 'NONE', #90, #41 ) ; 52 | #31 = DIRECTION ( 'NONE', ( -0.0000000000000000000, -1.000000000000000000, -0.0000000000000000000 ) ) ; 53 | #32 = EDGE_LOOP ( 'NONE', ( #48, #213, #88 ) ) ; 54 | #33 = PLANE ( 'NONE', #76 ) ; 55 | #34 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 56 | #35 = ORIENTED_EDGE ( 'NONE', *, *, #36, .T. ) ; 57 | #36 = EDGE_CURVE ( 'NONE', #178, #182, #184, .T. ) ; 58 | #37 = CARTESIAN_POINT ( 'NONE', ( 2.602085213965210600E-015, 10.16000000000000000, 0.0000000000000000000 ) ) ; 59 | #38 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 60 | #39 = VERTEX_POINT ( 'NONE', #15 ) ; 61 | #40 = COORDINATED_UNIVERSAL_TIME_OFFSET ( 5, 0, .BEHIND. ) ; 62 | #41 = VECTOR ( 'NONE', #198, 999.9999999999998900 ) ; 63 | #42 = CARTESIAN_POINT ( 'NONE', ( 7.776507174585692200E-016, 2.383525667002992100, 6.349999999999999600 ) ) ; 64 | #43 = LOCAL_TIME ( 10, 58, 59.00000000000000000, #61 ) ; 65 | #44 = DESIGN_CONTEXT ( 'detailed design', #26, 'design' ) ; 66 | #45 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 0.0000000000000000000 ) ) ; 67 | #46 = AXIS2_PLACEMENT_3D ( 'NONE', #57, #221, #98 ) ; 68 | #47 = DATE_AND_TIME ( #105, #10 ) ; 69 | #48 = ORIENTED_EDGE ( 'NONE', *, *, #71, .T. ) ; 70 | #49 = EDGE_CURVE ( 'NONE', #39, #143, #59, .T. ) ; 71 | #50 = CC_DESIGN_PERSON_AND_ORGANIZATION_ASSIGNMENT ( #34, #191, ( #11 ) ) ; 72 | #51 = CARTESIAN_POINT ( 'NONE', ( 1.833190123778916500, 12.75230378972847800, -3.175178434318528500 ) ) ; 73 | #52 = ORIENTED_EDGE ( 'NONE', *, *, #201, .T. ) ; 74 | #53 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 2.383525667003057800, -6.349999999999999600 ) ) ; 75 | #54 = CARTESIAN_POINT ( 'NONE', ( -1.833190123778914100, 7.567696210271519100, -3.175178434318574700 ) ) ; 76 | #55 = EDGE_LOOP ( 'NONE', ( #165, #2, #194, #132 ) ) ; 77 | #56 = EDGE_CURVE ( 'NONE', #131, #133, #117, .T. ) ; 78 | #57 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 0.0000000000000000000 ) ) ; 79 | #58 = DIRECTION ( 'NONE', ( 0.5773827170626467700, 0.8164736358495328100, 0.0000000000000000000 ) ) ; 80 | #59 =( BOUNDED_CURVE ( ) B_SPLINE_CURVE ( 3, ( #53, #107, #164, #162 ), 81 | .UNSPECIFIED., .F., .T. ) 82 | B_SPLINE_CURVE_WITH_KNOTS ( ( 4, 4 ), 83 | ( 5.759586531581282600, 7.330382858376186300 ), 84 | .UNSPECIFIED. ) 85 | CURVE ( ) GEOMETRIC_REPRESENTATION_ITEM ( ) RATIONAL_B_SPLINE_CURVE ( ( 1.000000000000000000, 0.8047378541243632700, 0.8047378541243632700, 1.000000000000000000 ) ) 86 | REPRESENTATION_ITEM ( '' ) ); 87 | #60 = ADVANCED_FACE ( 'NONE', ( #126 ), #203, .F. ) ; 88 | #61 = COORDINATED_UNIVERSAL_TIME_OFFSET ( 5, 0, .BEHIND. ) ; 89 | #62 = APPROVAL_ROLE ( '' ) ; 90 | #63 = DIRECTION ( 'NONE', ( -0.4082368179247598500, -0.5773827170626467700, -0.7070869101659441600 ) ) ; 91 | #64 = LOCAL_TIME ( 10, 58, 59.00000000000000000, #211 ) ; 92 | #65 = LINE ( 'NONE', #185, #170 ) ; 93 | #66 = DATE_TIME_ROLE ( 'creation_date' ) ; 94 | #67 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 1.000000000000000000, 0.0000000000000000000 ) ) ; 95 | #68 = PRODUCT ( 'Web Model 43296_43296', 'Web Model 43296_43296', '', ( #103 ) ) ; 96 | #69 = DATE_AND_TIME ( #115, #163 ) ; 97 | #70 = ORIENTED_EDGE ( 'NONE', *, *, #220, .T. ) ; 98 | #71 = EDGE_CURVE ( 'NONE', #93, #131, #229, .T. ) ; 99 | #72 = CIRCLE ( 'NONE', #46, 6.349999999999999600 ) ; 100 | #73 = DIRECTION ( 'NONE', ( -0.4082597620358407500, -0.5773178213166031400, -0.7071266505320594800 ) ) ; 101 | #74 = CARTESIAN_POINT ( 'NONE', ( -3.175000000000025600, 5.670250450497947600, 5.499261314031170600 ) ) ; 102 | #75 = CARTESIAN_POINT ( 'NONE', ( -3.175000000000025600, 5.670250450497947600, 5.499261314031170600 ) ) ; 103 | #76 = AXIS2_PLACEMENT_3D ( 'NONE', #78, #225, #58 ) ; 104 | #77 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 105 | #78 = CARTESIAN_POINT ( 'NONE', ( 2.602085213965210600E-015, 10.16000000000000000, -6.349999999999999600 ) ) ; 106 | #79 = CARTESIAN_POINT ( 'NONE', ( -1.121135126298618700, 3.176221088771583400, -6.349999999999999600 ) ) ; 107 | #80 = CC_DESIGN_DATE_AND_TIME_ASSIGNMENT ( #149, #192, ( #11 ) ) ; 108 | #81 = PERSON_AND_ORGANIZATION_ROLE ( 'creator' ) ; 109 | #82 = APPROVAL_STATUS ( 'not_yet_approved' ) ; 110 | #83 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 0.0000000000000000000 ) ) ; 111 | #84 = CALENDAR_DATE ( 2018, 11, 10 ) ; 112 | #85 = APPROVAL ( #82, 'UNSPECIFIED' ) ; 113 | #86 = PERSON_AND_ORGANIZATION_ROLE ( 'creator' ) ; 114 | #87 = CLOSED_SHELL ( 'NONE', ( #223, #205, #60, #146, #111, #240 ) ) ; 115 | #88 = ORIENTED_EDGE ( 'NONE', *, *, #122, .F. ) ; 116 | #89 = APPROVAL_DATE_TIME ( #188, #148 ) ; 117 | #90 = CARTESIAN_POINT ( 'NONE', ( 5.008368558097497900, 6.618847168904216500, 3.469446951953614200E-014 ) ) ; 118 | #91 = APPROVAL_PERSON_ORGANIZATION ( #242, #6, #62 ) ; 119 | #92 = VECTOR ( 'NONE', #73, 1000.000000000000000 ) ; 120 | #93 = VERTEX_POINT ( 'NONE', #200 ) ; 121 | #94 =( BOUNDED_CURVE ( ) B_SPLINE_CURVE ( 3, ( #147, #125, #144, #75 ), 122 | .UNSPECIFIED., .F., .T. ) 123 | B_SPLINE_CURVE_WITH_KNOTS ( ( 4, 4 ), 124 | ( 0.5235987755982894900, 1.047197551196593000 ), 125 | .UNSPECIFIED. ) 126 | CURVE ( ) GEOMETRIC_REPRESENTATION_ITEM ( ) RATIONAL_B_SPLINE_CURVE ( ( 1.000000000000000000, 0.9772838841927118400, 0.9772838841927118400, 1.000000000000000000 ) ) 127 | REPRESENTATION_ITEM ( '' ) ); 128 | #95 = CARTESIAN_POINT ( 'NONE', ( -7.937499999999986700, -1.064373873755012600, -2.749630657015616400 ) ) ; 129 | #96 = CARTESIAN_POINT ( 'NONE', ( 7.776507174585692200E-016, 2.383525667002992100, 6.349999999999999600 ) ) ; 130 | #97 = APPROVAL_PERSON_ORGANIZATION ( #142, #148, #139 ) ; 131 | #98 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 1.000000000000000000 ) ) ; 132 | #99 = ORIENTED_EDGE ( 'NONE', *, *, #175, .T. ) ; 133 | #100 = VECTOR ( 'NONE', #31, 1000.000000000000000 ) ; 134 | #101 = DIRECTION ( 'NONE', ( -0.0000000000000000000, -1.000000000000000000, -0.0000000000000000000 ) ) ; 135 | #102 = ORIENTED_EDGE ( 'NONE', *, *, #175, .F. ) ; 136 | #103 = MECHANICAL_CONTEXT ( 'NONE', #215, 'mechanical' ) ; 137 | #104 = SECURITY_CLASSIFICATION_LEVEL ( 'unclassified' ) ; 138 | #105 = CALENDAR_DATE ( 2018, 11, 10 ) ; 139 | #106 = FACE_OUTER_BOUND ( 'NONE', #29, .T. ) ; 140 | #107 = CARTESIAN_POINT ( 'NONE', ( 3.719743878930865700, -0.2465087274367647500, -6.350000000000000500 ) ) ; 141 | #108 = CC_DESIGN_APPROVAL ( #148, ( #138 ) ) ; 142 | #109 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 30.00000000000000000, 0.0000000000000000000 ) ) ; 143 | #110 = APPROVAL_DATE_TIME ( #69, #85 ) ; 144 | #111 = ADVANCED_FACE ( 'NONE', ( #207 ), #183, .F. ) ; 145 | #112 = ORIENTED_EDGE ( 'NONE', *, *, #201, .F. ) ; 146 | #113 =( BOUNDED_CURVE ( ) B_SPLINE_CURVE ( 3, ( #168, #186, #79, #18 ), 147 | .UNSPECIFIED., .F., .T. ) 148 | B_SPLINE_CURVE_WITH_KNOTS ( ( 4, 4 ), 149 | ( 5.235987755982986200, 5.759586531581282600 ), 150 | .UNSPECIFIED. ) 151 | CURVE ( ) GEOMETRIC_REPRESENTATION_ITEM ( ) RATIONAL_B_SPLINE_CURVE ( ( 1.000000000000000000, 0.9772838841927123900, 0.9772838841927123900, 1.000000000000000000 ) ) 152 | REPRESENTATION_ITEM ( '' ) ); 153 | #114 = AXIS2_PLACEMENT_3D ( 'NONE', #45, #171, #28 ) ; 154 | #115 = CALENDAR_DATE ( 2018, 11, 10 ) ; 155 | #116 = ORIENTED_EDGE ( 'NONE', *, *, #49, .T. ) ; 156 | #117 = LINE ( 'NONE', #54, #92 ) ; 157 | #118 = CARTESIAN_POINT ( 'NONE', ( -7.512130657015614300, 10.16000000000000000, 4.337130657015509700 ) ) ; 158 | #119 = LOCAL_TIME ( 10, 58, 59.00000000000000000, #40 ) ; 159 | #120 = PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE ( 'ANY', '', #68, .NOT_KNOWN. ) ; 160 | #121 = FACE_OUTER_BOUND ( 'NONE', #196, .T. ) ; 161 | #122 = EDGE_CURVE ( 'NONE', #93, #133, #8, .T. ) ; 162 | #123 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 0.0000000000000000000 ) ) ; 163 | #124 = CARTESIAN_POINT ( 'NONE', ( 7.776507174585692200E-016, 30.00000000000000000, 6.349999999999999600 ) ) ; 164 | #125 = CARTESIAN_POINT ( 'NONE', ( -1.121135126298632000, 3.176221088771511900, 6.349999999999998800 ) ) ; 165 | #126 = FACE_OUTER_BOUND ( 'NONE', #244, .T. ) ; 166 | #127 = CARTESIAN_POINT ( 'NONE', ( -3.174999999999987800, 5.670250450498002700, -5.499261314031193700 ) ) ; 167 | #128 = CARTESIAN_POINT ( 'NONE', ( 7.776507174585692200E-016, 0.0000000000000000000, 6.349999999999999600 ) ) ; 168 | #129 = AXIS2_PLACEMENT_3D ( 'NONE', #83, #67, #136 ) ; 169 | #130 = EDGE_LOOP ( 'NONE', ( #52, #35, #218, #102, #116 ) ) ; 170 | #131 = VERTEX_POINT ( 'NONE', #37 ) ; 171 | #132 = ORIENTED_EDGE ( 'NONE', *, *, #49, .F. ) ; 172 | #133 = VERTEX_POINT ( 'NONE', #145 ) ; 173 | #134 = APPROVAL_STATUS ( 'not_yet_approved' ) ; 174 | #135 =( NAMED_UNIT ( * ) SI_UNIT ( $, .STERADIAN. ) SOLID_ANGLE_UNIT ( ) ); 175 | #136 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 1.000000000000000000 ) ) ; 176 | #137 = DIRECTION ( 'NONE', ( -0.0000000000000000000, -1.000000000000000000, -0.0000000000000000000 ) ) ; 177 | #138 = PRODUCT_DEFINITION ( 'UNKNOWN', '', #120, #44 ) ; 178 | #139 = APPROVAL_ROLE ( '' ) ; 179 | #140 = FACE_OUTER_BOUND ( 'NONE', #32, .T. ) ; 180 | #141 = CC_DESIGN_PERSON_AND_ORGANIZATION_ASSIGNMENT ( #38, #86, ( #138 ) ) ; 181 | #142 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 182 | #143 = VERTEX_POINT ( 'NONE', #166 ) ; 183 | #144 = CARTESIAN_POINT ( 'NONE', ( -2.204068499550337000, 4.297261705067620100, 6.059828877180491300 ) ) ; 184 | #145 = CARTESIAN_POINT ( 'NONE', ( -3.174999999999987800, 5.670250450498002700, -5.499261314031193700 ) ) ; 185 | #146 = ADVANCED_FACE ( 'NONE', ( #140 ), #33, .F. ) ; 186 | #147 = CARTESIAN_POINT ( 'NONE', ( 7.776507174585692200E-016, 2.383525667002992100, 6.349999999999999600 ) ) ; 187 | #148 = APPROVAL ( #156, 'UNSPECIFIED' ) ; 188 | #149 = DATE_AND_TIME ( #9, #119 ) ; 189 | #150 = ORIENTED_EDGE ( 'NONE', *, *, #187, .F. ) ; 190 | #151 = CC_DESIGN_DATE_AND_TIME_ASSIGNMENT ( #47, #66, ( #138 ) ) ; 191 | #152 = APPLICATION_PROTOCOL_DEFINITION ( 'international standard', 'config_control_design', 1994, #26 ) ; 192 | #153 = APPLICATION_PROTOCOL_DEFINITION ( 'international standard', 'config_control_design', 1994, #215 ) ; 193 | #154 =( GEOMETRIC_REPRESENTATION_CONTEXT ( 3 ) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT ( ( #14 ) ) GLOBAL_UNIT_ASSIGNED_CONTEXT ( ( #5, #173, #135 ) ) REPRESENTATION_CONTEXT ( 'NONE', 'WORKASPACE' ) ); 194 | #155 = PLANE ( 'NONE', #3 ) ; 195 | #156 = APPROVAL_STATUS ( 'not_yet_approved' ) ; 196 | #157 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, -6.349999999999999600 ) ) ; 197 | #158 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 198 | #159 = CYLINDRICAL_SURFACE ( 'NONE', #210, 6.349999999999999600 ) ; 199 | #160 = EDGE_CURVE ( 'NONE', #182, #208, #72, .T. ) ; 200 | #161 = DIRECTION ( 'NONE', ( 0.0000000000000000000, -0.0000000000000000000, 1.000000000000000000 ) ) ; 201 | #162 = CARTESIAN_POINT ( 'NONE', ( 6.349999999999994300, 5.670250450498004500, 4.396328926264539200E-014 ) ) ; 202 | #163 = LOCAL_TIME ( 10, 58, 59.00000000000000000, #233 ) ; 203 | #164 = CARTESIAN_POINT ( 'NONE', ( 6.350000000000026300, 1.114897253674619600, -3.719743878930823000 ) ) ; 204 | #165 = ORIENTED_EDGE ( 'NONE', *, *, #172, .F. ) ; 205 | #166 = CARTESIAN_POINT ( 'NONE', ( 6.349999999999994300, 5.670250450498004500, 4.396328926264539200E-014 ) ) ; 206 | #167 = CYLINDRICAL_SURFACE ( 'NONE', #180, 6.349999999999999600 ) ; 207 | #168 = CARTESIAN_POINT ( 'NONE', ( -3.174999999999987800, 5.670250450498002700, -5.499261314031193700 ) ) ; 208 | #169 =( BOUNDED_CURVE ( ) B_SPLINE_CURVE ( 3, ( #209, #24, #174, #42 ), 209 | .UNSPECIFIED., .F., .T. ) 210 | B_SPLINE_CURVE_WITH_KNOTS ( ( 4, 4 ), 211 | ( 5.235987755982986200, 6.806784082777875600 ), 212 | .UNSPECIFIED. ) 213 | CURVE ( ) GEOMETRIC_REPRESENTATION_ITEM ( ) RATIONAL_B_SPLINE_CURVE ( ( 1.000000000000000000, 0.8047378541243667100, 0.8047378541243667100, 1.000000000000000000 ) ) 214 | REPRESENTATION_ITEM ( '' ) ); 215 | #170 = VECTOR ( 'NONE', #21, 1000.000000000000000 ) ; 216 | #171 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, 1.000000000000000000 ) ) ; 217 | #172 = EDGE_CURVE ( 'NONE', #133, #39, #113, .T. ) ; 218 | #173 =( NAMED_UNIT ( * ) PLANE_ANGLE_UNIT ( ) SI_UNIT ( $, .RADIAN. ) ); 219 | #174 = CARTESIAN_POINT ( 'NONE', ( 3.719743878930825700, -0.2465087274367322700, 6.349999999999997000 ) ) ; 220 | #175 = EDGE_CURVE ( 'NONE', #39, #208, #65, .T. ) ; 221 | #176 = CC_DESIGN_PERSON_AND_ORGANIZATION_ASSIGNMENT ( #193, #7, ( #68 ) ) ; 222 | #177 = PRODUCT_DEFINITION_SHAPE ( 'NONE', 'NONE', #138 ) ; 223 | #178 = VERTEX_POINT ( 'NONE', #96 ) ; 224 | #179 = DIRECTION ( 'NONE', ( 0.4082597620358440800, 0.5773178213166078000, -0.7071266505320538100 ) ) ; 225 | #180 = AXIS2_PLACEMENT_3D ( 'NONE', #4, #137, #25 ) ; 226 | #181 = COORDINATED_UNIVERSAL_TIME_OFFSET ( 5, 0, .BEHIND. ) ; 227 | #182 = VERTEX_POINT ( 'NONE', #128 ) ; 228 | #183 = PLANE ( 'NONE', #239 ) ; 229 | #184 = LINE ( 'NONE', #124, #100 ) ; 230 | #185 = CARTESIAN_POINT ( 'NONE', ( 0.0000000000000000000, 30.00000000000000000, -6.349999999999999600 ) ) ; 231 | #186 = CARTESIAN_POINT ( 'NONE', ( -2.204068499550309900, 4.297261705067688500, -6.059828877180497600 ) ) ; 232 | #187 = EDGE_CURVE ( 'NONE', #178, #93, #94, .T. ) ; 233 | #188 = DATE_AND_TIME ( #236, #43 ) ; 234 | #189 = ORIENTED_EDGE ( 'NONE', *, *, #187, .T. ) ; 235 | #190 = CC_DESIGN_APPROVAL ( #6, ( #120 ) ) ; 236 | #191 = PERSON_AND_ORGANIZATION_ROLE ( 'classification_officer' ) ; 237 | #192 = DATE_TIME_ROLE ( 'classification_date' ) ; 238 | #193 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 239 | #194 = ORIENTED_EDGE ( 'NONE', *, *, #17, .T. ) ; 240 | #195 = ADVANCED_BREP_SHAPE_REPRESENTATION ( 'Web Model 43296_43296', ( #197, #114 ), #154 ) ; 241 | #196 = EDGE_LOOP ( 'NONE', ( #217, #189, #13, #228, #99, #70 ) ) ; 242 | #197 = MANIFOLD_SOLID_BREP ( 'CirPattern1', #87 ) ; 243 | #198 = DIRECTION ( 'NONE', ( 0.8165195240716848200, -0.5773178213166032500, 5.641021855165955800E-015 ) ) ; 244 | #199 = VECTOR ( 'NONE', #179, 1000.000000000000000 ) ; 245 | #200 = CARTESIAN_POINT ( 'NONE', ( -3.175000000000025600, 5.670250450497947600, 5.499261314031170600 ) ) ; 246 | #201 = EDGE_CURVE ( 'NONE', #143, #178, #169, .T. ) ; 247 | #202 = CARTESIAN_POINT ( 'NONE', ( 7.512130657015579700, 10.16000000000000000, 4.337130657015634900 ) ) ; 248 | #203 = PLANE ( 'NONE', #226 ) ; 249 | #204 = CC_DESIGN_APPROVAL ( #85, ( #11 ) ) ; 250 | #205 = ADVANCED_FACE ( 'NONE', ( #121 ), #167, .T. ) ; 251 | #206 = DATE_AND_TIME ( #84, #64 ) ; 252 | #207 = FACE_OUTER_BOUND ( 'NONE', #55, .T. ) ; 253 | #208 = VERTEX_POINT ( 'NONE', #157 ) ; 254 | #209 = CARTESIAN_POINT ( 'NONE', ( 6.349999999999994300, 5.670250450498004500, 4.396328926264539200E-014 ) ) ; 255 | #210 = AXIS2_PLACEMENT_3D ( 'NONE', #109, #101, #212 ) ; 256 | #211 = COORDINATED_UNIVERSAL_TIME_OFFSET ( 5, 0, .BEHIND. ) ; 257 | #212 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.0000000000000000000, -1.000000000000000000 ) ) ; 258 | #213 = ORIENTED_EDGE ( 'NONE', *, *, #56, .T. ) ; 259 | #214 = CC_DESIGN_PERSON_AND_ORGANIZATION_ASSIGNMENT ( #27, #216, ( #120 ) ) ; 260 | #215 = APPLICATION_CONTEXT ( 'configuration controlled 3d designs of mechanical parts and assemblies' ) ; 261 | #216 = PERSON_AND_ORGANIZATION_ROLE ( 'design_supplier' ) ; 262 | #217 = ORIENTED_EDGE ( 'NONE', *, *, #36, .F. ) ; 263 | #218 = ORIENTED_EDGE ( 'NONE', *, *, #160, .T. ) ; 264 | #219 = FACE_OUTER_BOUND ( 'NONE', #130, .T. ) ; 265 | #220 = EDGE_CURVE ( 'NONE', #208, #182, #20, .T. ) ; 266 | #221 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 1.000000000000000000, 0.0000000000000000000 ) ) ; 267 | #222 = CC_DESIGN_PERSON_AND_ORGANIZATION_ASSIGNMENT ( #158, #81, ( #120 ) ) ; 268 | #223 = ADVANCED_FACE ( 'NONE', ( #219 ), #159, .T. ) ; 269 | #224 = DIRECTION ( 'NONE', ( 0.0000000000000000000, -0.7745705483267489900, -0.6324875221415829900 ) ) ; 270 | #225 = DIRECTION ( 'NONE', ( 0.8164736358495328100, -0.5773827170626467700, 0.0000000000000000000 ) ) ; 271 | #226 = AXIS2_PLACEMENT_3D ( 'NONE', #123, #238, #161 ) ; 272 | #227 = ORIENTED_EDGE ( 'NONE', *, *, #17, .F. ) ; 273 | #228 = ORIENTED_EDGE ( 'NONE', *, *, #172, .T. ) ; 274 | #229 = LINE ( 'NONE', #51, #199 ) ; 275 | #230 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 0.7745705483267515400, -0.6324875221415798800 ) ) ; 276 | #231 = CC_DESIGN_SECURITY_CLASSIFICATION ( #11, ( #120 ) ) ; 277 | #232 = APPROVAL_ROLE ( '' ) ; 278 | #233 = COORDINATED_UNIVERSAL_TIME_OFFSET ( 5, 0, .BEHIND. ) ; 279 | #234 = ORIENTED_EDGE ( 'NONE', *, *, #160, .F. ) ; 280 | #235 = PERSON ( 'UNSPECIFIED', 'UNSPECIFIED', 'UNSPECIFIED', ('UNSPECIFIED'), ('UNSPECIFIED'), ('UNSPECIFIED') ) ; 281 | #236 = CALENDAR_DATE ( 2018, 11, 10 ) ; 282 | #237 = ORIENTED_EDGE ( 'NONE', *, *, #71, .F. ) ; 283 | #238 = DIRECTION ( 'NONE', ( 0.0000000000000000000, 1.000000000000000000, 0.0000000000000000000 ) ) ; 284 | #239 = AXIS2_PLACEMENT_3D ( 'NONE', #202, #243, #224 ) ; 285 | #240 = ADVANCED_FACE ( 'NONE', ( #106 ), #155, .F. ) ; 286 | #241 = APPROVAL_DATE_TIME ( #206, #6 ) ; 287 | #242 = PERSON_AND_ORGANIZATION ( #235, #1 ) ; 288 | #243 = DIRECTION ( 'NONE', ( -0.4082368179247697300, -0.5773827170626467700, 0.7070869101659385000 ) ) ; 289 | #244 = EDGE_LOOP ( 'NONE', ( #16, #234 ) ) ; 290 | ENDSEC; 291 | END-ISO-10303-21; 292 | -------------------------------------------------------------------------------- /pyccx/__init__.py: -------------------------------------------------------------------------------- 1 | from . import material 2 | from . import mesh 3 | from . import analysis 4 | 5 | from .core import ElementSet, SurfaceSet, MeshSet, NodeSet, DOF, Connector 6 | 7 | from .bc import BoundaryConditionType, BoundaryCondition, Acceleration, Film, Fixed, HeatFlux, Pressure, Radiation 8 | from .loadcase import LoadCaseType, LoadCase 9 | #from .model import Model 10 | from .results import ElementResult, NodalResult, ResultProcessor 11 | -------------------------------------------------------------------------------- /pyccx/analysis/__init__.py: -------------------------------------------------------------------------------- 1 | from .analysis import Simulation, AnalysisType, AnalysisError 2 | -------------------------------------------------------------------------------- /pyccx/analysis/analysis.py: -------------------------------------------------------------------------------- 1 | import re # used to get info from frd file 2 | import os 3 | import sys 4 | import subprocess # used to check ccx version 5 | from enum import Enum, auto 6 | from typing import List, Tuple, Type 7 | import logging 8 | 9 | from ..bc import BoundaryCondition 10 | from ..core import MeshSet, ElementSet, SurfaceSet, NodeSet, Connector 11 | from ..loadcase import LoadCase 12 | from ..material import Material 13 | from ..mesh import Mesher 14 | from ..results import ElementResult, NodalResult, ResultProcessor 15 | 16 | 17 | class AnalysisError(Exception): 18 | """Exception raised for errors generated during the analysis 19 | 20 | Attributes: 21 | expression -- input expression in which the error occurred 22 | message -- explanation of the error 23 | """ 24 | 25 | def __init__(self, expression, message): 26 | self.expression = expression 27 | self.message = message 28 | 29 | 30 | class AnalysisType(Enum): 31 | """ 32 | The analysis types available for use. 33 | """ 34 | STRUCTURAL = auto() 35 | THERMAL = auto() 36 | FLUID = auto() 37 | 38 | 39 | class Simulation: 40 | """ 41 | Provides the base class for running a Calculix simulation 42 | """ 43 | 44 | NUMTHREADS = 1 45 | """ Number of Threads used by the Calculix Solver """ 46 | 47 | CALCULIX_PATH = '' 48 | """ The Calculix directory path used for Windows platforms""" 49 | 50 | VERBOSE_OUTPUT = True 51 | """ When enabled, the output during the analysis is redirected to the console""" 52 | 53 | def __init__(self, meshModel: Mesher): 54 | 55 | self._input = '' 56 | self._workingDirectory = '' 57 | self._analysisCompleted = False 58 | 59 | self._name = '' 60 | self.initialConditions = [] # 'dict of node set names, 61 | self._loadCases = [] 62 | self._mpcSets = [] 63 | self._connectors = [] 64 | self._materials = [] 65 | self._materialAssignments = [] 66 | self.model = meshModel 67 | 68 | self.initialTimeStep = 0.1 69 | self.defaultTimeStep = 0.1 70 | self.totalTime = 1.0 71 | self.useSteadyStateAnalysis = True 72 | 73 | self.TZERO = -273.15 74 | self.SIGMAB = 5.669E-8 75 | self._numThreads = 1 76 | 77 | # Private sets are used for the storage of additional user defined sets 78 | self._surfaceSets = [] 79 | self._nodeSets = [] 80 | self._elementSets = [] 81 | 82 | self.includes = [] 83 | 84 | def init(self): 85 | 86 | self._input = '' 87 | 88 | @classmethod 89 | def setNumThreads(cls, numThreads: int): 90 | """ 91 | Sets the number of simulation threads to use in Calculix 92 | 93 | :param numThreads: 94 | :return: 95 | """ 96 | cls.NUMTHREADS = numThreads 97 | 98 | @classmethod 99 | def getNumThreads(cls) -> int: 100 | """ 101 | Returns the number of threads used 102 | 103 | :return: int: 104 | """ 105 | return cls.NUMTHREADS 106 | 107 | @classmethod 108 | def setCalculixPath(cls, calculixPath: str) -> None: 109 | """ 110 | Sets the path for the Calculix executable. Necessary when using Windows where there is not a default 111 | installation proceedure for Calculix 112 | 113 | :param calculixPath: Directory containing the Calculix Executable 114 | """ 115 | 116 | if os.path.isdir(calculixPath) : 117 | cls.CALCULIX_PATH = calculixPath 118 | 119 | @classmethod 120 | def setVerboseOuput(cls, state: bool) -> None: 121 | """ 122 | Sets if the output from Calculix should be verbose i.e. printed to the console 123 | 124 | :param state: 125 | """ 126 | 127 | cls.VERBOSE_OUTPUT = state 128 | 129 | def setWorkingDirectory(self, workDir) -> None: 130 | """ 131 | Sets the working directory used during the analysis. 132 | 133 | :param workDir: An accessible working directory path 134 | 135 | """ 136 | if os.path.isdir(workDir) and os.access(workDir, os.W_OK): 137 | self._workingDirectory = workDir 138 | else: 139 | raise ValueError('Working directory ({:s}) is not accessible or writable'.format(workDir)) 140 | 141 | @property 142 | def name(self) -> str: 143 | return self._name 144 | 145 | def getBoundaryConditions(self) -> List[BoundaryCondition]: 146 | """ 147 | Collects all :class:`~pyccx.boundarycondition.BoundaryCondition` which are attached to :class:`LoadCase` in 148 | the analysis 149 | 150 | :return: All the boundary conditions in the analysis 151 | """ 152 | bcs = [] 153 | for loadcase in self._loadCases: 154 | bcs += loadcase.boundaryConditions 155 | 156 | return bcs 157 | 158 | @property 159 | def loadCases(self) -> List[LoadCase]: 160 | """ 161 | List of :class:`~pyccx.loadcase.LoadCase` used in the analysis 162 | """ 163 | return self._loadCases 164 | 165 | @loadCases.setter 166 | def loadCases(self, loadCases: List[LoadCase]): 167 | self._loadCases = loadCases 168 | 169 | @property 170 | def connectors(self) -> List[Connector]: 171 | """ 172 | List of :class:`~pyccx.core.Connector` used in the analysis 173 | """ 174 | return self._connectors 175 | 176 | @connectors.setter 177 | def connectors(self, connectors: List[Connector]): 178 | self._connectors = connectors 179 | 180 | @property 181 | def mpcSets(self): 182 | return self._mpcSets 183 | 184 | @mpcSets.setter 185 | def mpcSets(self, value): 186 | self._mpcSets = value 187 | 188 | @property 189 | def materials(self) -> List[Material]: 190 | """ 191 | User defined :class:`~pyccx.material.Material` used in the analysis 192 | """ 193 | return self._materials 194 | 195 | @materials.setter 196 | def materials(self, materials): 197 | self._materials = materials 198 | 199 | @property 200 | def materialAssignments(self): 201 | """ 202 | Material Assignment applied to a set of elements 203 | """ 204 | return self._materialAssignments 205 | 206 | @materialAssignments.setter 207 | def materialAssignments(self, matAssignments): 208 | self._materialAssignments = matAssignments 209 | 210 | def _collectSets(self, setType: Type[MeshSet] = None): 211 | """ 212 | Private function returns a unique set of Element, Nodal, Surface sets which are used by the analysis during writing. 213 | This reduces the need to explicitly attach them to an analysis. 214 | """ 215 | elementSets = {} 216 | nodeSets = {} 217 | surfaceSets = {} 218 | 219 | # Iterate through all user defined sets 220 | for elSet in self._elementSets: 221 | elementSets[elSet.name] = elSet 222 | 223 | for nodeSet in self._nodeSets: 224 | nodeSets[nodeSet.name] = nodeSet 225 | 226 | for surfSet in self._surfaceSets: 227 | surfaceSets[surfSet.name] = surfSet 228 | 229 | # Iterate through all loadcases and boundary conditions.and find unique values. This is greedy so will override 230 | # any with same name. 231 | for loadcase in self.loadCases: 232 | 233 | # Collect result sets node and element sets automatically 234 | for resultSet in loadcase.resultSet: 235 | if isinstance(resultSet, ElementResult): 236 | elementSets[resultSet.elementSet.name] = resultSet.elementSet 237 | elif isinstance(resultSet, NodalResult): 238 | nodeSets[resultSet.nodeSet.name] = resultSet.nodeSet 239 | 240 | for bc in loadcase.boundaryConditions: 241 | if isinstance(bc.target, ElementSet): 242 | elementSets[bc.target.name] = bc.target 243 | 244 | if isinstance(bc.target, NodeSet): 245 | nodeSets[bc.target.name] = bc.target 246 | 247 | if isinstance(bc.target, SurfaceSet): 248 | surfaceSets[bc.target.name] = bc.target 249 | 250 | for con in self.connectors: 251 | nodeSets[con.nodeset.name] = con.nodeset 252 | 253 | if setType is ElementSet: 254 | return list(elementSets.values()) 255 | elif setType is NodeSet: 256 | return list(nodeSets.values()) 257 | elif setType is SurfaceSet: 258 | return list(surfaceSets.values()) 259 | else: 260 | return list(elementSets.values()), list(nodeSets.values()), list(surfaceSets.values()) 261 | 262 | @property 263 | def elementSets(self) -> List[ElementSet]: 264 | """ 265 | User-defined :class:`~pyccx.core.ElementSet` manually added to the analysis 266 | """ 267 | return self._elementSets 268 | 269 | @elementSets.setter 270 | def elementSets(self, val: List[ElementSet]): 271 | self._elementSets = val 272 | 273 | @property 274 | def nodeSets(self) -> List[NodeSet]: 275 | """ 276 | User-defined :class:`~pyccx.core.NodeSet` manually added to the analysis 277 | """ 278 | return self._nodeSets 279 | 280 | @nodeSets.setter 281 | def nodeSets(self, val: List[NodeSet]): 282 | nodeSets = val 283 | 284 | @property 285 | def surfaceSets(self) -> List[SurfaceSet]: 286 | """ 287 | User-defined :class:`pyccx.core.SurfaceSet` manually added to the analysis 288 | """ 289 | return self._nodeSets 290 | 291 | @surfaceSets.setter 292 | def surfaceSets(self, val=List[SurfaceSet]): 293 | surfaceSets = val 294 | 295 | def getElementSets(self) -> List[ElementSet]: 296 | """ 297 | Returns **all** the :class:`~pyccx.core.ElementSet` used and generated in the analysis 298 | """ 299 | return self._collectSets(setType = ElementSet) 300 | 301 | def getNodeSets(self) -> List[NodeSet]: 302 | """ 303 | Returns **all** the :class:`pyccx.core.NodeSet` used and generated in the analysis 304 | """ 305 | return self._collectSets(setType = NodeSet) 306 | 307 | def getSurfaceSets(self) -> List[SurfaceSet]: 308 | """ 309 | Returns **all** the :class:`pyccx.core.SurfaceSet` used and generated in the analysis 310 | """ 311 | return self._collectSets(setType=SurfaceSet) 312 | 313 | def writeInput(self) -> str: 314 | """ 315 | Writes the input deck for the simulation 316 | """ 317 | 318 | self.init() 319 | 320 | self._writeHeaders() 321 | self._writeMesh() 322 | self._writeNodeSets() 323 | self._writeElementSets() 324 | self._writeKinematicConnectors() 325 | self._writeMPCs() 326 | self._writeMaterials() 327 | self._writeMaterialAssignments() 328 | self._writeInitialConditions() 329 | self._writeAnalysisConditions() 330 | self._writeLoadSteps() 331 | 332 | return self._input 333 | 334 | def _writeHeaders(self): 335 | 336 | self._input += os.linesep 337 | self._input += '{:*^125}\n'.format(' INCLUDES ') 338 | 339 | for filename in self.includes: 340 | self._input += '*include,input={:s}'.format(filename) 341 | 342 | def _writeElementSets(self): 343 | 344 | # Collect all sets 345 | elementSets = self._collectSets(setType = ElementSet) 346 | 347 | if len(elementSets) == 0: 348 | return 349 | 350 | self._input += os.linesep 351 | self._input += '{:*^125}\n'.format(' ELEMENT SETS ') 352 | 353 | for elSet in elementSets: 354 | self._input += os.linesep 355 | self._input += elSet.writeInput() 356 | 357 | def _writeNodeSets(self): 358 | 359 | # Collect all sets 360 | nodeSets = self._collectSets(setType=NodeSet) 361 | 362 | if len(nodeSets) == 0: 363 | return 364 | 365 | self._input += os.linesep 366 | self._input += '{:*^125}\n'.format(' NODE SETS ') 367 | 368 | for nodeSet in nodeSets: 369 | self._input += os.linesep 370 | self._input += nodeSet.writeInput() 371 | #self._input += '*NSET,NSET={:s}\n'.format(nodeSet['name']) 372 | #self._input += '*NSET,NSET={:s}\n'.format(nodeSet['name']) 373 | #self._input += np.array2string(nodeSet['nodes'], precision=2, separator=', ', threshold=9999999999)[1:-1] 374 | 375 | def _writeKinematicConnectors(self): 376 | 377 | if len(self.connectors) < 1: 378 | return 379 | 380 | self._input += os.linesep 381 | self._input += '{:*^125}\n'.format(' KINEMATIC CONNECTORS ') 382 | 383 | for connector in self.connectors: 384 | 385 | # A nodeset is automatically created from the name of the connector 386 | self._input += connector.writeInput() 387 | 388 | def _writeMPCs(self): 389 | 390 | if len(self.mpcSets) < 1: 391 | return 392 | 393 | self._input += os.linesep 394 | self._input += '{:*^125}\n'.format(' MPCS ') 395 | 396 | for mpcSet in self.mpcSets: 397 | self._input += '*EQUATION\n' 398 | self._input += '{:d}\n'.format(len(mpcSet['numTerms'])) # Assume each line constrains two nodes and one dof 399 | for mpc in mpcSet['equations']: 400 | for i in range(len(mpc['eqn'])): 401 | self._input += '{:d},{:d},{:d}'.format(mpc['node'][i], mpc['dof'][i], mpc['eqn'][i]) 402 | 403 | self._input += os.linesep 404 | 405 | # *EQUATION 406 | # 2 # number of terms in equation # typically two 407 | # 28,2,1.,22,2,-1. # node a id, dof, node b id, dof b 408 | 409 | def _writeMaterialAssignments(self): 410 | self._input += os.linesep 411 | self._input += '{:*^125}\n'.format(' MATERIAL ASSIGNMENTS ') 412 | 413 | for matAssignment in self.materialAssignments: 414 | self._input += '*solid section, elset={:s}, material={:s}\n'.format(matAssignment[0], matAssignment[1]) 415 | 416 | def _writeMaterials(self): 417 | self._input += os.linesep 418 | self._input += '{:*^125}\n'.format(' MATERIALS ') 419 | for material in self.materials: 420 | self._input += material.writeInput() 421 | 422 | def _writeInitialConditions(self): 423 | self._input += os.linesep 424 | self._input += '{:*^125}\n'.format(' INITIAL CONDITIONS ') 425 | 426 | for initCond in self.initialConditions: 427 | self._input += '*INITIAL CONDITIONS,TYPE={:s}\n'.format(initCond['type'].upper()) 428 | self._input += '{:s},{:e}\n'.format(initCond['set'], initCond['value']) 429 | self._input += os.linesep 430 | 431 | # Write the Physical Constants 432 | self._input += '*PHYSICAL CONSTANTS,ABSOLUTE ZERO={:e},STEFAN BOLTZMANN={:e}\n'.format(self.TZERO, self.SIGMAB) 433 | 434 | def _writeAnalysisConditions(self): 435 | 436 | self._input += os.linesep 437 | self._input += '{:*^125}\n'.format(' ANALYSIS CONDITIONS ') 438 | 439 | # Write the Initial Timestep 440 | self._input += '{:.3f}, {:.3f}\n'.format(self.initialTimeStep, self.defaultTimeStep) 441 | 442 | def _writeLoadSteps(self): 443 | 444 | self._input += os.linesep 445 | self._input += '{:*^125}\n'.format(' LOAD STEPS ') 446 | 447 | for loadCase in self.loadCases: 448 | self._input += loadCase.writeInput() 449 | 450 | def _writeMesh(self): 451 | 452 | # TODO make a unique auto-generated name for the mesh 453 | meshFilename = 'mesh.inp' 454 | meshPath= os.path.join(self._workingDirectory, meshFilename) 455 | 456 | self.model.writeMesh(meshPath) 457 | self._input += '*include,input={:s}'.format(meshFilename) 458 | 459 | def checkAnalysis(self) -> bool: 460 | """ 461 | Routine checks that the analysis has been correctly generated 462 | 463 | :return: bool: True if no analysis error occur 464 | :raise: AnalysisError: Analysis error that occured 465 | """ 466 | 467 | if len(self.materials) == 0: 468 | raise AnalysisError('No material models have been assigned to the analysis') 469 | 470 | for material in self.materials: 471 | if not material.isValid(): 472 | raise AnalysisError('Material ({:s}) is not valid'.format(material.name)) 473 | 474 | 475 | return True 476 | 477 | def version(self): 478 | 479 | if sys.platform == 'win32': 480 | cmdPath = os.path.join(self.CALCULIX_PATH, 'ccx.exe ') 481 | p = subprocess.Popen([cmdPath, '-v'], stdout=subprocess.PIPE, universal_newlines=True ) 482 | stdout, stderr = p.communicate() 483 | version = re.search(r"(\d+).(\d+)", stdout) 484 | return int(version.group(1)), int(version.group(2)) 485 | 486 | elif sys.platform == 'linux': 487 | p = subprocess.Popen(['ccx', '-v'], stdout=subprocess.PIPE, universal_newlines=True ) 488 | stdout, stderr = p.communicate() 489 | version = re.search(r"(\d+).(\d+)", stdout) 490 | return int(version.group(1)), int(version.group(2)) 491 | 492 | else: 493 | raise NotImplemented(' Platform is not currently supported') 494 | 495 | def results(self) -> ResultProcessor: 496 | """ 497 | The results obtained after running an analysis 498 | """ 499 | if self.isAnalysisCompleted(): 500 | return ResultProcessor('input') 501 | else: 502 | raise ValueError('Results were not available') 503 | 504 | def isAnalysisCompleted(self) -> bool: 505 | """ Returns if the analysis was completed successfully """ 506 | return self._analysisCompleted 507 | 508 | def clearAnalysis(self, includeResults: bool = False) -> None: 509 | """ 510 | Clears any previous files generated from the analysis 511 | 512 | :param includeResults: If set `True` will also delete the result files generated from the analysis 513 | """ 514 | 515 | filename = 'input' # Base filename for the analysis 516 | 517 | files = [filename + '.inp', 518 | filename + '.cvg', 519 | filename + '.sta'] 520 | 521 | if includeResults: 522 | files.append(filename + '.frd') 523 | files.append(filename + '.dat') 524 | 525 | try: 526 | for file in files: 527 | filePath = os.path.join(self._workingDirectory,file) 528 | os.remove(filePath) 529 | except: 530 | pass 531 | 532 | def run(self): 533 | """ 534 | Performs pre-analysis checks on the model and submits the job for Calculix to perform. 535 | """ 536 | 537 | # Reset analysis status 538 | self._analysisCompleted = False 539 | 540 | print('{:=^60}\n'.format(' RUNNING PRE-ANALYSIS CHECKS ')) 541 | self.checkAnalysis() 542 | 543 | print('{:=^60}\n'.format(' WRITING INPUT FILE ')) 544 | inputDeckContents = self.writeInput() 545 | 546 | inputDeckPath = os.path.join(self._workingDirectory,'input.inp') 547 | with open(inputDeckPath, "w") as text_file: 548 | text_file.write(inputDeckContents) 549 | 550 | # Set environment variables for performing multi-threaded 551 | os.environ["CCX_NPROC_STIFFNESS"] = '{:d}'.format(Simulation.NUMTHREADS) 552 | os.environ["CCX_NPROC_EQUATION_SOLVER"] = '{:d}'.format(Simulation.NUMTHREADS) 553 | os.environ["OMP_NUM_THREADS"] = '{:d}'.format(Simulation.NUMTHREADS) 554 | 555 | print('\n{:=^60}\n'.format(' RUNNING CALCULIX ')) 556 | 557 | if sys.platform == 'win32': 558 | cmdPath = os.path.join(self.CALCULIX_PATH, 'ccx.exe ') 559 | arguments = '-i input' 560 | 561 | cmd = cmdPath + arguments 562 | 563 | popen = subprocess.Popen(cmd, cwd=self._workingDirectory, stdout=subprocess.PIPE, universal_newlines=True) 564 | 565 | if self.VERBOSE_OUTPUT: 566 | for stdout_line in iter(popen.stdout.readline, ""): 567 | print(stdout_line, end='') 568 | 569 | popen.stdout.close() 570 | return_code = popen.wait() 571 | if return_code: 572 | raise subprocess.CalledProcessError(return_code, cmd) 573 | 574 | # A :return:nalysis was completed successfully 575 | self._analysisCompleted = True 576 | 577 | elif sys.platform == 'linux': 578 | 579 | filename = 'input' 580 | 581 | cmdSt = ['ccx', '-i', filename] 582 | 583 | popen = subprocess.Popen(cmdSt, cwd=self._workingDirectory, stdout=subprocess.PIPE, universal_newlines=True) 584 | 585 | if self.VERBOSE_OUTPUT: 586 | for stdout_line in iter(popen.stdout.readline, ""): 587 | print(stdout_line, end='') 588 | 589 | popen.stdout.close() 590 | return_code = popen.wait() 591 | if return_code: 592 | raise subprocess.CalledProcessError(return_code, cmdSt) 593 | 594 | # Analysis was completed successfully 595 | self._analysisCompleted = True 596 | 597 | else: 598 | raise NotImplemented(' Platform is not currently supported') 599 | -------------------------------------------------------------------------------- /pyccx/bc/__init__.py: -------------------------------------------------------------------------------- 1 | from .boundarycondition import BoundaryCondition, BoundaryConditionType, Acceleration, Film, Fixed, Force, HeatFlux, Pressure, Radiation 2 | -------------------------------------------------------------------------------- /pyccx/bc/boundarycondition.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from enum import Enum, Flag, auto 3 | from typing import Any, List, Tuple 4 | 5 | import numpy as np 6 | 7 | from ..core import ElementSet, NodeSet, SurfaceSet, DOF 8 | 9 | 10 | class BoundaryConditionType(Flag): 11 | """ 12 | Boundary condition type specifies which type of analyses the boundary condition may be applied to. Flags may be mixed 13 | when coupled analyses are performed (e.g. thermo-mechanical analysis: STRUCTURAL | THERMAL) 14 | """ 15 | 16 | ANY = auto() 17 | """ Boundary condition can be used in any analysis""" 18 | 19 | STRUCTURAL = auto() 20 | """ Boundary condition can be used in a structural analysis""" 21 | 22 | THERMAL = auto() 23 | """ Boundary condition can be used in a thermal analysis""" 24 | 25 | FLUID = auto() 26 | """ Boundary condition can be used in a fluid analysis""" 27 | 28 | 29 | class BoundaryCondition(abc.ABC): 30 | """ 31 | Base class for all boundary conditions 32 | """ 33 | 34 | def __init__(self, target): 35 | 36 | self.init = True 37 | self.target = target 38 | 39 | def getTargetName(self) -> str: 40 | return self.target.name 41 | 42 | def getBoundaryElements(self): 43 | 44 | if isinstance(self.target, ElementSet): 45 | return self.target.els 46 | 47 | return None 48 | 49 | def getBoundaryFaces(self): 50 | 51 | if isinstance(self.target, SurfaceSet): 52 | return self.target.surfacePairs 53 | 54 | return None 55 | 56 | def getBoundaryNodes(self): 57 | 58 | if isinstance(self.target, NodeSet): 59 | return self.target.nodes 60 | 61 | return None 62 | 63 | @abc.abstractmethod 64 | def type(self) -> BoundaryConditionType: 65 | """ 66 | Returns the BC type so that they are only applied to suitable load cases 67 | """ 68 | raise NotImplemented() 69 | 70 | @abc.abstractmethod 71 | def writeInput(self) -> str: 72 | raise NotImplemented() 73 | 74 | 75 | class Film(BoundaryCondition): 76 | """ 77 | The film or convective heat transfer boundary condition applies the Newton's law of cooling 78 | - :math:`q = h_{c}\\left(T-T_{amb}\\right)` to specified faces of 79 | boundaries elements (correctly ordered according to Calculix's requirements). This BC may be used in thermal and 80 | coupled thermo-mechanical analyses. 81 | """ 82 | 83 | def __init__(self, target, h: float = 0.0, TAmbient: float = 0.0): 84 | 85 | self.h = h 86 | self.T_amb = TAmbient 87 | 88 | if not isinstance(target, SurfaceSet): 89 | raise ValueError('A SurfaceSet must be used for a Film Boundary Condition') 90 | 91 | super().__init__(target) 92 | 93 | def type(self) -> BoundaryConditionType: 94 | return BoundaryConditionType.THERMAL 95 | 96 | @property 97 | def heatTransferCoefficient(self) -> float: 98 | """ 99 | The heat transfer coefficient :math:`h_{c}` used for the Film Boundary Condition 100 | """ 101 | return self.h 102 | 103 | @heatTransferCoefficient.setter 104 | def heatTransferCoefficient(self, h: float) -> None: 105 | self.h = h 106 | 107 | @property 108 | def ambientTemperature(self) -> float: 109 | """ 110 | The ambient temperature :math:`T_{amb}`. used for the Film Boundary Condition 111 | """ 112 | return self.T_amb 113 | 114 | @ambientTemperature.setter 115 | def ambientTemperature(self, Tamb: float) -> None: 116 | self.T_amb = Tamb 117 | 118 | def writeInput(self) -> str: 119 | bCondStr = '*FILM\n' 120 | 121 | bfaces = self.getBoundaryFaces() 122 | 123 | for i in len(bfaces): 124 | bCondStr += '{:d},F{:d},{:e},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], self.T_amb, self.h) 125 | 126 | return bCondStr 127 | 128 | 129 | class HeatFlux(BoundaryCondition): 130 | """ 131 | The flux boundary condition applies a uniform external heat flux :math:`q` to faces of surface 132 | boundaries elements (correctly ordered according to Calculix's requirements). This BC may be used in thermal and 133 | coupled thermo-mechanical analyses. 134 | """ 135 | 136 | def __init__(self, target, flux: float = 0.0): 137 | 138 | self._flux = flux 139 | 140 | if not isinstance(target, SurfaceSet): 141 | raise ValueError('A SurfaceSet must be used for a Heat Flux Boundary Condition') 142 | 143 | super().__init__(target) 144 | 145 | def type(self) -> BoundaryConditionType: 146 | return BoundaryConditionType.THERMAL 147 | 148 | @property 149 | def flux(self) -> float: 150 | """ 151 | The flux value :math:`q` used for the Heat Flux Boundary Condition 152 | """ 153 | return self._flux 154 | 155 | @flux.setter 156 | def flux(self, fluxVal: float) -> None: 157 | self._flux = fluxVal 158 | 159 | def writeInput(self) -> str: 160 | 161 | bCondStr = '*DFLUX\n' 162 | bfaces = self.getBoundaryFaces() 163 | 164 | for i in range(len(bfaces)): 165 | bCondStr += '{:d},S{:d},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], self._flux) 166 | 167 | return bCondStr 168 | 169 | 170 | class Radiation(BoundaryCondition): 171 | """ 172 | The radiation boundary condition applies Black-body radiation using the Stefan-Boltzmann Law, 173 | :math:`q_{rad} = \\epsilon \\sigma_b\\left(T-T_{amb}\\right)^4`, which is imposed on the faces of 174 | boundaries elements (correctly ordered according to Calculix's requirements). Ensure that the Stefan-Boltzmann constant :math: 175 | `\\sigma_b`, has consistent units, which is set in the :attr:`~pyccx.analysis.Simulation.SIGMAB`. This BC may be used in thermal and 176 | coupled thermo-mechanical analyses. 177 | """ 178 | 179 | def __init__(self, target, epsilon=1.0, TAmbient: float = 0.0): 180 | 181 | self.T_amb = TAmbient 182 | self._epsilon = epsilon 183 | 184 | if not isinstance(target, SurfaceSet): 185 | raise ValueError('A SurfaceSet must be used for a Radiation Boundary Condition') 186 | 187 | super().__init__(target) 188 | 189 | def type(self) -> BoundaryConditionType: 190 | return BoundaryConditionType.THERMAL 191 | 192 | @property 193 | def emmisivity(self) -> float: 194 | """ 195 | The emmisivity value :math:`\\epsilon` used for the Radiation Boundary Condition 196 | """ 197 | return self._epsilon 198 | 199 | @emmisivity.setter 200 | def emmisivity(self, val: float): 201 | self._epsilon = val 202 | 203 | @property 204 | def ambientTemperature(self) -> float: 205 | """ 206 | The ambient temperature :math:`T_{amb}`. used for the Radiation Boundary Condition 207 | """ 208 | return self.T_amb 209 | 210 | @ambientTemperature.setter 211 | def ambientTemperature(self, Tamb: float) -> None: 212 | self.T_amb = Tamb 213 | 214 | def writeInput(self) -> str: 215 | 216 | bCondStr = '*RADIATE\n' 217 | bfaces = self.getBoundaryFaces() 218 | 219 | for i in range(len(bfaces)): 220 | bCondStr += '{:d},F{:d},{:e},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], self.T_amb, self._epsilon) 221 | 222 | return bCondStr 223 | 224 | 225 | class Fixed(BoundaryCondition): 226 | """ 227 | The fixed boundary condition removes or sets the DOF (e.g. displacement components, temperature) specifically on 228 | a Node Set. This BC may be used in thermal and coupled thermo-mechanical analyses provided the DOF is applicable to 229 | the analysis type. 230 | """ 231 | 232 | def __init__(self, target: Any, dof: List[DOF] = [], values=None): 233 | 234 | if not isinstance(target, NodeSet): 235 | raise ValueError('The target for a Fixed Boundary Condition must be a NodeSet') 236 | 237 | # for d in dof: 238 | # if not d in DOF: 239 | # raise ValueError('Degree of freedom must be specified') 240 | 241 | self._dof = dof 242 | self._values = values 243 | 244 | super().__init__(target) 245 | 246 | def type(self) -> BoundaryConditionType: 247 | return BoundaryConditionType.ANY 248 | 249 | @property 250 | def dof(self): 251 | """ 252 | Degree of Freedoms to be fixed 253 | """ 254 | return self._dof 255 | 256 | @dof.setter 257 | def dof(self, vals): 258 | self._dof = vals 259 | 260 | @property 261 | def values(self) -> Any: 262 | """ 263 | Values to assign to the selected DOF to be fixed 264 | """ 265 | return self._dof 266 | 267 | @values.setter 268 | def values(self, vals): 269 | self._values = vals 270 | 271 | def writeInput(self) -> str: 272 | 273 | bCondStr = '*BOUNDARY\n' 274 | 275 | nodesetName = self.getTargetName() 276 | 277 | if len(self.dof) != len(self._values): 278 | raise ValueError('DOF and Prescribed DOF must have a matching size') 279 | 280 | # 1-3 U, 4-6, rotational DOF, 11 = Temp 281 | for i in range(len(self._dof)): 282 | if self._values: 283 | # Inhomogeneous boundary conditions 284 | bCondStr += '{:s},{:d},, {:e}\n'.format(nodesetName, self._dof[i], self._values[i]) 285 | else: 286 | # Fixed boundary condition 287 | bCondStr += '{:s},{:d}\n'.format(nodesetName, self._dof[i]) 288 | 289 | return bCondStr 290 | 291 | 292 | class Acceleration(BoundaryCondition): 293 | """ 294 | The Acceleration Boundary Condition applies an acceleration term across a Volume (i.e. Element Set) during a structural 295 | analysis. This is provided as magnitude, direction of the acceleration on the body. 296 | """ 297 | 298 | def __init__(self, target, dir=None, mag=1.0): 299 | 300 | self.mag = 1.0 301 | 302 | if not isinstance(target, NodeSet) or not isinstance(target, ElementSet): 303 | raise ValueError('The target for an Acceleration BC should be a node or element set.') 304 | 305 | if dir: 306 | self.dir = dir 307 | else: 308 | self.dir = np.array([0.0, 0.0, 1.0]) 309 | 310 | super().__init__(target) 311 | 312 | def type(self) -> BoundaryConditionType: 313 | return BoundaryConditionType.STRUCTURAL 314 | 315 | def setVector(self, v) -> None: 316 | """ 317 | The acceleration of the body set by an Acceleration Vector 318 | 319 | :param v: The vector of the acceleration 320 | """ 321 | from numpy import linalg 322 | mag = linalg.norm(v) 323 | self.dir = v / linalg.norm(v) 324 | self.magnitude = mag 325 | 326 | @property 327 | def magnitude(self) -> float: 328 | """ 329 | The acceleration magnitude applied onto the body 330 | """ 331 | return self.mag 332 | 333 | @magnitude.setter 334 | def magnitude(self, magVal: float) -> None: 335 | from numpy import linalg 336 | self.mag = magVal 337 | 338 | @property 339 | def direction(self) -> np.ndarray: 340 | """ 341 | The acceleration direction (normalised vector) 342 | """ 343 | return self.dir 344 | 345 | @direction.setter 346 | def direction(self, v: float) -> None: 347 | from numpy import linalg 348 | self.dir = v / linalg.norm(v) 349 | 350 | def writeInput(self) -> str: 351 | bCondStr = '*DLOAD\n' 352 | bCondStr += '{:s},GRAV,{:.5f}, {:.3f},{:.3f},{:.3f}\n'.format(self.target.name, self.mag, *self.dir) 353 | return bCondStr 354 | 355 | 356 | class Pressure(BoundaryCondition): 357 | """ 358 | The Pressure Boundary Condition applies a uniform pressure to faces across an element boundary. 359 | """ 360 | 361 | def __init__(self, target, magnitude: float = 0.0): 362 | 363 | self.mag = magnitude 364 | 365 | if not isinstance(target, SurfaceSet): 366 | raise ValueError('A surface set must be assigned to a Pressure boundary condition.') 367 | 368 | super().__init__(target) 369 | 370 | def type(self) -> BoundaryConditionType: 371 | return BoundaryConditionType.STRUCTURAL 372 | 373 | @property 374 | def magnitude(self) -> float: 375 | """ 376 | The magnitude of pressure applied onto the surface 377 | """ 378 | return self.mag 379 | 380 | @magnitude.setter 381 | def magnitude(self, magVal: float) -> None: 382 | self.mag = magVal 383 | 384 | def writeInput(self) -> str: 385 | 386 | bCondStr = '*DLOAD\n' 387 | bfaces = self.getBoundaryFaces() 388 | 389 | for i in range(len(bfaces)): 390 | bCondStr += '{:d},P{:d},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], self.mag) 391 | 392 | return bCondStr 393 | 394 | 395 | class Force(BoundaryCondition): 396 | """ 397 | The Force Boundary applies a uniform force directly to nodes. This BC may be used in thermal and 398 | coupled thermo-mechanical analyses provided the DOF is applicable to the analysis type. 399 | """ 400 | 401 | def __init__(self, target): 402 | self.mag = 0.0 403 | self.dir = np.array([0.0, 0.0, 1.0]) 404 | 405 | super().__init__(target) 406 | 407 | def type(self) -> BoundaryConditionType: 408 | return BoundaryConditionType.STRUCTURAL 409 | 410 | def setVector(self, v) -> None: 411 | """ 412 | The applied force set by the vector 413 | 414 | :param v: The Force vector 415 | """ 416 | from numpy import linalg 417 | mag = linalg.norm(v) 418 | self.dir = v / linalg.norm(v) 419 | self.magnitude = mag 420 | 421 | @property 422 | def magnitude(self) -> float: 423 | """ 424 | The magnitude of the force applied 425 | """ 426 | return self.mag 427 | 428 | @magnitude.setter 429 | def magnitude(self, magVal: float) -> None: 430 | self.mag = magVal 431 | 432 | @property 433 | def direction(self) -> np.ndarray: 434 | """ 435 | The normalised vector of the force direction 436 | """ 437 | return self.dir 438 | 439 | @direction.setter 440 | def direction(self, v: float) -> None: 441 | from numpy import linalg 442 | self.dir = v / linalg.norm(v) 443 | 444 | def writeInput(self) -> str: 445 | bCondStr = '*CLOAD\n' 446 | nodesetName = self.getTargetName() 447 | 448 | for i in range(3): 449 | compMag = self.mag * self.dir[i] 450 | bCondStr += '{:s},{:d}\n'.format(nodesetName, i, compMag) 451 | 452 | return bCondStr 453 | -------------------------------------------------------------------------------- /pyccx/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import numpy as np 3 | 4 | class MeshSet: 5 | 6 | def __init__(self, name): 7 | self._name = name 8 | 9 | @property 10 | def name(self) -> str: 11 | return self._name 12 | 13 | @name.setter 14 | def name(self, name): 15 | self._name = name 16 | 17 | 18 | class NodeSet(MeshSet): 19 | """ 20 | An node set is basic entity for storing node set lists. The set remains constant without any dynamic referencing 21 | to any underlying geometric entities. 22 | """ 23 | def __init__(self, name, nodes): 24 | super().__init__(name) 25 | self._nodes = nodes 26 | 27 | @property 28 | def nodes(self): 29 | """ 30 | Nodes contains the list of Node IDs 31 | """ 32 | return self._nodes 33 | 34 | @nodes.setter 35 | def nodes(self, nodes): 36 | self._nodes = nodes 37 | 38 | def writeInput(self) -> str: 39 | out = '*NSET,NSET={:s}\n'.format(self.name) 40 | out += np.array2string(self.nodes, precision=2, separator=', ', threshold=9999999999)[1:-1] 41 | return out 42 | 43 | 44 | class ElementSet(MeshSet): 45 | """ 46 | An element set is basic entity for storing element set lists.The set remains constant without any dynamic referencing 47 | to any underlying geometric entities. 48 | """ 49 | def __init__(self, name, els): 50 | super().__init__(name) 51 | self._els = els 52 | 53 | @property 54 | def els(self): 55 | """ 56 | Elements contains the list of Node IDs 57 | """ 58 | return self._els 59 | 60 | @els.setter 61 | def els(self, elements): 62 | self._els = elements 63 | 64 | def writeInput(self) -> str: 65 | 66 | out = '*ELSET,ELSET={:s}\n'.format(self.name) 67 | out += np.array2string(self.els, precision=2, separator=', ', threshold=9999999999)[1:-1] 68 | return out 69 | 70 | 71 | class SurfaceSet(MeshSet): 72 | """ 73 | A surface-set set is basic entity for storing element face lists, typically for setting directional fluxes onto 74 | surface elements based on the element ordering. The set remains constant without any dynamic referencing 75 | to any underlying geometric entities. 76 | """ 77 | def __init__(self, name, surfacePairs): 78 | 79 | super().__init__(name) 80 | self._elSurfacePairs = surfacePairs 81 | 82 | @property 83 | def surfacePairs(self): 84 | """ 85 | Elements with the associated face orientations are specified as Nx2 numpy array, with the first column being 86 | the element Id, and the second column the chosen face orientation 87 | """ 88 | return self._elSurfacePairs 89 | 90 | @surfacePairs.setter 91 | def surfacePairs(self, surfacePairs): 92 | self._elSurfacePairs = surfacePairs 93 | 94 | def writeInput(self) -> str: 95 | 96 | out = '*SURFACE,NAME={:s}\n'.format(self.name) 97 | 98 | for i in range(self._elSurfacePairs.shape[0]): 99 | out += '{:d},S{:d}\n'.format(self._elSurfacePairs[i,0], self._elSurfacePairs[i,1]) 100 | 101 | #out += np.array2string(self.els, precision=2, separator=', ', threshold=9999999999)[1:-1] 102 | return out 103 | 104 | 105 | class Connector: 106 | """ 107 | A Connector ir a rigid connector between a set of nodes and an (optional) reference node. 108 | """ 109 | def __init__(self, name, nodes, refNode = None): 110 | self.name = name 111 | self._refNode = refNode 112 | self._nodeset = None 113 | 114 | @property 115 | def refNode(self): 116 | """ 117 | Reference Node ID 118 | """ 119 | return self._refNode 120 | 121 | @refNode.setter 122 | def refNode(self, node): 123 | self._refNode = node 124 | 125 | @property 126 | def nodeset(self): 127 | """ 128 | Nodes contains the list of Node IDs 129 | """ 130 | return self._nodeset 131 | 132 | @nodeset.setter 133 | def nodeset(self, nodes): 134 | 135 | if isinstance(nodes, list) or isinstance(nodes,np.ndarray): 136 | self._nodeset = NodeSet('Connecter_{:s}'.format(self.name), np.array(nodes)) 137 | elif isinstance(nodes,NodeSet): 138 | self._nodeset = nodes 139 | else: 140 | raise ValueError('Invalid type for nodes passed to Connector()') 141 | 142 | def writeInput(self) -> str: 143 | # A nodeset is automatically created from the name of the connector 144 | strOut = '*RIGIDBODY, NSET={:s}'.format(self.nodeset.name) 145 | 146 | # A reference node is optional 147 | if isinstance(self.refNode, int): 148 | strOut += ',REF NODE={:d}\n'.format(self.refNode) 149 | else: 150 | strOut += '\n' 151 | 152 | return strOut 153 | 154 | 155 | class DOF: 156 | """ 157 | Provides a reference to the typical DOF used for setting boundary conditions and displaying output in Calculix. 158 | """ 159 | 160 | UX = 1 161 | """ Translation in the X direction """ 162 | UY = 2 163 | """ Translation in the Y direction """ 164 | UZ = 3 165 | """ Translation in the Z direction """ 166 | RX = 4 167 | """ Rotation about the X-axis""" 168 | RY = 5 169 | """ Rotation about the Y-axis""" 170 | RZ = 6 171 | """ Rotation about the Z-axis""" 172 | T = 11 173 | """ Temperature """ 174 | -------------------------------------------------------------------------------- /pyccx/loadcase/__init__.py: -------------------------------------------------------------------------------- 1 | from .loadcase import LoadCase, LoadCaseType 2 | -------------------------------------------------------------------------------- /pyccx/loadcase/loadcase.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import abc 3 | import os 4 | 5 | from enum import Enum, auto 6 | from typing import List, Tuple, Type 7 | 8 | from ..bc import BoundaryCondition, BoundaryConditionType 9 | from ..results import Result 10 | 11 | 12 | class LoadCaseType(Enum): 13 | """ 14 | Enum Class specifies the Load Case Type 15 | """ 16 | 17 | STATIC = auto() 18 | """Linear Static structural analysis""" 19 | THERMAL = auto() 20 | """Thermal analysis for performing heat transfer studies""" 21 | UNCOUPLEDTHERMOMECHANICAL = auto() 22 | """Coupled thermo-mechanical analysis""" 23 | BUCKLE = auto() 24 | """Buckling analysis of a structure""" 25 | MODAL = auto() 26 | """Modal analysis of a structure""" 27 | DYNAMIC = auto() 28 | """Dynamic analysis of a structure""" 29 | 30 | 31 | class LoadCase: 32 | """ 33 | A unique Load case defines a set of simulation analysis conditions and a set of boundary conditions to apply to the domain. 34 | The default and initial timestep provide an estimate for the solver should be specified along with the total duration 35 | of the load case using :meth:`setTimeStep`. The analysis type for the loadcase should be 36 | specified using :meth:`setLoadCaseType`. Depending on the analysis type the steady-state solution 37 | may instead be calculated. 38 | """ 39 | def __init__(self, loadCaseName, loadCaseType: LoadCaseType = None, resultSets = None): 40 | 41 | self._input = '' 42 | self._loadcaseName = loadCaseName 43 | self._loadCaseType = None 44 | self.isSteadyState = False 45 | self.initialTimeStep = 0.1 46 | self.defaultTimeStep = 0.1 47 | self.totalTime = 1.0 48 | self._resultSet = [] 49 | self._boundaryConditions = [] 50 | 51 | if loadCaseType: 52 | if loadCaseType is LoadCaseType: 53 | self._loadCaseType = loadCaseType 54 | else: 55 | raise ValueError('Loadcase type must valid') 56 | 57 | if resultSets: 58 | self.resultSet = resultSets 59 | 60 | @property 61 | def loadCaseType(self) -> LoadCaseType: 62 | return self._loadCaseType 63 | 64 | @property 65 | def boundaryConditions(self) -> List[BoundaryCondition]: 66 | """ 67 | The list of boundary conditions to be applied in the LoadCase 68 | """ 69 | return self._boundaryConditions 70 | 71 | @boundaryConditions.setter 72 | def boundaryConditions(self, bConds: List[BoundaryCondition]): 73 | self._boundaryConditions = bConds 74 | 75 | @property 76 | def resultSet(self) -> List[Result]: 77 | """ 78 | The result outputs (:class:`~pyccx.results.ElementResult`, :class:`~pyccx.results.NodeResult`) to generate 79 | the set of results from this loadcase. 80 | """ 81 | return self._resultSet 82 | 83 | @resultSet.setter 84 | def resultSet(self, rSets: Type[Result]): 85 | if not any(isinstance(x, Result) for x in rSets): 86 | raise ValueError('Loadcase ResultSets must be derived from a Result class') 87 | else: 88 | self._resultSet = rSets 89 | 90 | @property 91 | def name(self) -> str: 92 | return self._loadcaseName 93 | 94 | @name.setter 95 | def name(self, loadCaseName): 96 | self._loadcaseName = loadCaseName 97 | 98 | @property 99 | def steadyState(self) -> bool: 100 | """ 101 | Returns True if the loadcase is a steady-state analysis 102 | """ 103 | return self.isSteadyState 104 | 105 | @steadyState.setter 106 | def steadyState(self,state:bool) -> None: 107 | self.isSteadyState = state 108 | 109 | 110 | def setTimeStep(self, defaultTimeStep: float = 1.0, initialIimeStep: float = None, totalTime: float = None) -> None: 111 | """ 112 | Set the timestepping values for the loadcase 113 | 114 | :param defaultTimeStep: float: Default timestep to use throughout the loadcase 115 | :param initialIimeStep: float: The initial timestep to use for the increment 116 | :param totalTime: float: The total time for the loadcase 117 | 118 | """ 119 | self.defaultTimeStep = defaultTimeStep 120 | 121 | if initialIimeStep is not None: 122 | self.initialTimeStep = initialIimeStep 123 | 124 | if totalTime is not None: 125 | self.totalTime = totalTime 126 | 127 | def setLoadCaseType(self, loadCaseType: LoadCaseType) -> None: 128 | """ 129 | Set the loadcase type based on the analysis types available in :class:`~pyccx.loadcase.LoadCaseType`. 130 | 131 | :param loadCaseType: Set the loadcase type using the enum :class:`~pyccx.loadcase.LoadCaseType` 132 | """ 133 | 134 | if isinstance(loadCaseType, LoadCaseType): 135 | self._loadCaseType = loadCaseType 136 | else: 137 | raise ValueError('Load case type is not supported') 138 | 139 | def writeBoundaryCondition(self) -> str: 140 | """ 141 | Generates the string for Boundary Conditions in self.boundaryConditions containing all the attached boundary 142 | conditions. Calculix cannot share existing boundary conditions and therefore has to be explicitly 143 | created per load case. 144 | 145 | :return: outStr 146 | """ 147 | bcondStr = '' 148 | 149 | for bcond in self.boundaryConditions: 150 | bcondStr += bcond.writeInput() 151 | 152 | if False: 153 | for bcond in self.boundaryConditions: 154 | 155 | if bcond['type'] == 'film': 156 | 157 | bcondStr += '*FILM\n' 158 | bfaces = bcond['faces'] 159 | for i in len(bfaces): 160 | bcondStr += '{:d},F{:d},{:e},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], bcond['tsink'], bcond['h']) 161 | 162 | elif bcond['type'] == 'bodyflux': 163 | 164 | bcondStr += '*DFLUX\n' 165 | bcondStr += '{:s},BF,{:e}\n'.format(bcond['el'], bcond['flux']) # use element set 166 | 167 | elif bcond['type'] == 'faceflux': 168 | 169 | bcondStr += '*DFLUX\n' 170 | bfaces = bcond['faces'] 171 | for i in range(len(bfaces)): 172 | bcondStr += '{:d},S{:d},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], bcond['flux']) 173 | 174 | elif bcond['type'] == 'radiation': 175 | 176 | bcondStr += '*RADIATE\n' 177 | bfaces = bcond['faces'] 178 | for i in len(bfaces): 179 | bcondStr += '{:d},F{:d},{:e},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], bcond['tsink'], 180 | bcond['emmisivity']) 181 | 182 | elif bcond['type'] == 'fixed': 183 | 184 | bcondStr += '*BOUNDARY\n' 185 | nodeset = bcond['nodes'] 186 | # 1-3 U, 4-6, rotational DOF, 11 = Temp 187 | 188 | for i in range(len(bcond['dof'])): 189 | if 'value' in bcond.keys(): 190 | bcondStr += '{:s},{:d},,{:e}\n'.format(nodeset, bcond['dof'][i], 191 | bcond['value'][i]) # inhomogenous boundary conditions 192 | else: 193 | bcondStr += '{:s},{:d}\n'.format(nodeset, bcond['dof'][i]) 194 | 195 | elif bcond['type'] == 'accel': 196 | 197 | bcondStr += '*DLOAD\n' 198 | bcondStr += '{:s},GRAV,{:.3f}, {:.3f},{:.3f},{:.3f}\n'.format(bcond['el'], bcond['mag'], bcond['dir'][0], 199 | bcond['dir'][1], bcond['dir'][2]) 200 | 201 | elif bcond['type'] == 'force': 202 | 203 | bcondStr += '*CLOAD\n' 204 | nodeset = bcond['nodes'] 205 | 206 | for i in bcond['dof']: 207 | bcondStr += '{:s},{:d}\n'.format(nodeset, i, bcond['mag']) 208 | 209 | elif bcond['type'] == 'pressure': 210 | 211 | bcondStr += '*DLOAD\n' 212 | bfaces = bcond['faces'] 213 | for i in range(len(bfaces)): 214 | bcondStr += '{:d},P{:d},{:e}\n'.format(bfaces[i, 0], bfaces[i, 1], bcond['mag']) 215 | 216 | return bcondStr 217 | 218 | def writeInput(self) -> str: 219 | 220 | outStr = '{:*^64}\n'.format(' LOAD CASE ({:s}) '.format(self.name)) 221 | outStr += '*STEP\n' 222 | # Write the thermal analysis loadstep 223 | 224 | if self._loadCaseType == LoadCaseType.STATIC: 225 | outStr += '*STATIC' 226 | elif self._loadCaseType == LoadCaseType.THERMAL: 227 | outStr += '*HEAT TRANSFER' 228 | elif self._loadCaseType == LoadCaseType.UNCOUPLEDTHERMOMECHANICAL: 229 | outStr += '*UNCOUPLED TEMPERATURE-DISPLACEMENT' 230 | else: 231 | raise ValueError('Loadcase type ({:s} is not currently supported in PyCCX'.format(self._loadCaseType)) 232 | 233 | if self.isSteadyState: 234 | outStr += ', STEADY STATE' 235 | 236 | # Write the timestepping information 237 | outStr += '\n{:.3f}, {:.3f}\n'.format(self.initialTimeStep, self.totalTime) 238 | 239 | # Write the individual boundary conditions associated with this loadcase 240 | outStr += self.writeBoundaryCondition() 241 | 242 | outStr += os.linesep 243 | for postResult in self.resultSet: 244 | outStr += postResult.writeInput() 245 | 246 | outStr += '*END STEP\n\n' 247 | 248 | return outStr 249 | -------------------------------------------------------------------------------- /pyccx/material/__init__.py: -------------------------------------------------------------------------------- 1 | from .material import Material, ElastoPlasticMaterial -------------------------------------------------------------------------------- /pyccx/material/material.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import abc 3 | from enum import Enum, auto 4 | 5 | 6 | class Material(abc.ABC): 7 | """ 8 | Base class for all material model definitions 9 | """ 10 | MATERIALMODEL = 'INVALID' 11 | 12 | def __init__(self, name): 13 | self._input = '' 14 | self._name = name 15 | self._materialModel = '' 16 | 17 | @property 18 | def name(self) -> str: 19 | return self._name 20 | 21 | def setName(self, matName: str): 22 | self._name = matName 23 | 24 | @property 25 | @abc.abstractmethod 26 | def materialModel(self): 27 | raise NotImplementedError() 28 | 29 | @abc.abstractmethod 30 | def writeInput(self): 31 | raise NotImplemented() 32 | 33 | @abc.abstractmethod 34 | def isValid(self) -> bool: 35 | """ 36 | Abstract method: re-implement in material models to check parameters are correct by the user 37 | """ 38 | raise NotImplemented() 39 | 40 | 41 | class ElastoPlasticMaterial(Material): 42 | """ 43 | Represents a generic non-linear elastic/plastic material which may be used in both structural, and thermal type analyses 44 | """ 45 | 46 | class WorkHardeningType(Enum): 47 | """ 48 | Work hardening mode selecting the hardening regime for the accumulation of plastic-strain 49 | """ 50 | 51 | NONE = auto() 52 | """ Prevents any plastic deformation """ 53 | 54 | ISOTROPIC = auto() 55 | """ Isotropic work hardening """ 56 | 57 | KINEMATIC = auto() 58 | """ Kinematic work hardening """ 59 | 60 | COMBINED = auto() 61 | """ Cyclic work hardening """ 62 | 63 | def __init__(self, name): 64 | 65 | super().__init__(name) 66 | 67 | self._E = 210e3 68 | self._nu = 0.33 69 | self._density = 7.85e-9 70 | self._alpha_CTE = 12e-6 71 | self._k = 50.0 72 | self._cp = 50.0 73 | 74 | # Plastic Behavior 75 | self._workHardeningMode = ElastoPlasticMaterial.WorkHardeningType.NONE 76 | self._hardeningCurve = None 77 | 78 | @property 79 | def E(self): 80 | """Elastic Modulus :math:`E` 81 | 82 | The Young's Modulus :math:`E` can be both isotropic by setting as a scalar value, or orthotropic by 83 | setting to an (1x3) array corresponding to :math:`E_{ii}, E_{jj}, E_{kk}` for each direction. Temperature dependent 84 | Young's Modulus can be set by providing an nx4 array, where the 1st column is the temperature :math:`T` 85 | and the remaining columns are the orthotropic values of :math:`E`. 86 | """ 87 | return self._E 88 | 89 | @E.setter 90 | def E(self, val): 91 | self._E = val 92 | 93 | @property 94 | def nu(self): 95 | """Poisson's Ratio :math:`\\nu` """ 96 | return self._nu 97 | 98 | @nu.setter 99 | def nu(self, val): 100 | self._nu = val 101 | 102 | @property 103 | def density(self): 104 | """Density :math:`\\rho`""" 105 | return self._density 106 | 107 | @density.setter 108 | def density(self, val): 109 | self._density = val 110 | 111 | @property 112 | def alpha_CTE(self): 113 | """Thermal Expansion Coefficient :math:`\\alpha_{cte}` 114 | 115 | The thermal conductivity :math:`alpha_{cte}` can be both isotropic by setting as a scalar value, or orthotropic by 116 | setting to an (1x3) array corresponding to :math:`\alpha_{cte}` for each direction. Temperature dependent thermal 117 | expansion coefficient can be set by providing an nx4 array, where the 1st column is the temperature :math:`T` 118 | and the remaining columns are the orthotropic values of :math:`\alpha_{cte}`. 119 | """ 120 | return self._alpha_CTE 121 | 122 | @alpha_CTE.setter 123 | def alpha_CTE(self, val): 124 | self._alpha_CTE = val 125 | 126 | @property 127 | def k(self): 128 | """Thermal conductivity :math:`k` 129 | 130 | The thermal conductivity :math:`k` can be both isotropic by setting as a scalar value, or orthotropic by setting to an (1x3) array corresponding 131 | to :math:`k_{ii}, k_{jj}, k_{kk}` for each direction. Temperature dependent thermal conductivity eat can be set 132 | by providing an nx4 array, where the 1st column is the temperature :math:`T` and the remaining columns are the 133 | orthotropic values of :math:`k`. 134 | """ 135 | return self._k 136 | 137 | @k.setter 138 | def k(self, val): 139 | self._k = val 140 | 141 | @property 142 | def cp(self): 143 | """Specific Heat :math:`c_p` 144 | 145 | The specific heat :math:`c_p` can be both isotropic by setting as a scalar value, or orthotropic by setting to an (1x3) array corresponding 146 | to :math:`c_p` for each direction. Temperature dependent specific heat can be set by providing an nx4 array, 147 | where the 1st column is the temperature :math:`T` and the remaining columns are the orthotropic values of :math:`c_p`. 148 | """ 149 | return self._cp 150 | 151 | @cp.setter 152 | def cp(self, val): 153 | self._cp = val 154 | 155 | def isPlastic(self) -> bool: 156 | """ 157 | Returns True if the material exhibits a plastic behaviour 158 | """ 159 | return self._workHardeningMode is not ElastoPlasticMaterial.WorkHardeningType.NONE 160 | 161 | @property 162 | def workHardeningMode(self): 163 | """ 164 | The work hardening mode of the material - if this is set, plastic behaviour will be assumed requiring a 165 | work hardening curve to be provided 166 | """ 167 | return self._workHardeningMode 168 | 169 | @workHardeningMode.setter 170 | def workHardeningMode(self, mode: WorkHardeningType) -> None: 171 | self._workHardeningMode = mode 172 | 173 | @property 174 | def hardeningCurve(self) -> np.ndarray: 175 | """ 176 | Sets the work hardening stress-strain curve with an nx3 array (curve) set with each row entry to 177 | (stress :math:`\\sigma`, plastic strain :math:`\\varepsilon_p`, Temperature :math:`T`. The first row 178 | of a temperature group describes the yield point :math:`\\sigma_y` for the onset of the plastic regime. 179 | """ 180 | return self._hardeningCurve 181 | 182 | @hardeningCurve.setter 183 | def hardeningCurve(self, curve): 184 | if not isinstance(curve, np.ndarray) or curve.shape[1] != 3: 185 | raise ValueError('Work hardening curve should be an nx3 numpy array') 186 | 187 | self._hardeningCurve = curve 188 | 189 | @property 190 | def materialModel(self): 191 | return 'elastic' # Calculix material model 192 | 193 | @staticmethod 194 | def cast2Numpy(tempVals): 195 | if type(tempVals) == float: 196 | tempVal = np.array([tempVals]) 197 | elif type(tempVals) == list: 198 | tempVal = np.array(tempVals) 199 | elif type(tempVals) == np.ndarray: 200 | tempVal = tempVals 201 | else: 202 | raise ValueError('Mat prop type not supported') 203 | 204 | return tempVal 205 | 206 | def _writeElasticProp(self) -> str: 207 | 208 | lineStr = '*elastic' 209 | nu = self.cast2Numpy(self.nu) 210 | E = self.cast2Numpy(self.E) 211 | 212 | if nu.ndim != E.ndim: 213 | raise ValueError("Both Poisson's ratio and Young's modulus must be temperature dependent or constant") 214 | 215 | if nu.shape[0] == 1: 216 | if nu.shape[0] != E.shape[0]: 217 | raise ValueError("Same number of entries must exist for Poissons ratio and Young' Modulus") 218 | 219 | lineStr += ',type=iso\n' 220 | if nu.ndim == 1: 221 | lineStr += '{:e},{:e}\n'.format(E[0], nu[0]) 222 | elif nu.ndim == 2: 223 | for i in range(nu.shape[0]): 224 | lineStr += '{:e},{:e},{:e}\n'.format(E[i, 1], nu[i, 1], E[0]) 225 | else: 226 | raise ValueError('Not currently support elastic mode') 227 | 228 | return lineStr 229 | 230 | def _writePlasticProp(self): 231 | 232 | if not self.isPlastic(): 233 | return '' 234 | 235 | if self.isPlastic() and self.hardeningCurve is None: 236 | raise ValueError('Plasticity requires a work hardening curve to be defined') 237 | 238 | lineStr = '' 239 | if self._workHardeningMode is ElastoPlasticMaterial.WorkHardeningType.ISOTROPIC: 240 | lineStr += '*plastic HARDENING=ISOTROPIC\n' 241 | elif self._workHardeningMode is ElastoPlasticMaterial.WorkHardeningType.KINEMATIC: 242 | lineStr += '*plastic HARDENING=KINEMATIC\n' 243 | elif self._workHardeningMode is ElastoPlasticMaterial.WorkHardeningType.COMBINED: 244 | lineStr += '*cyclic hardening HARDENING=COMBINED\n' 245 | 246 | for i in range(self.hardeningCurve.shape[0]): 247 | lineStr += '{:e},{:e},{:e}\n'.format(self._hardeningCurve[i, 0], # Stress 248 | self._hardeningCurve[i, 1], # Plastic Strain 249 | self._hardeningCurve[i, 2]) # Temperature 250 | 251 | def _writeMaterialProp(self, matPropName: str, tempVals) -> str: 252 | """ 253 | Helper method to write the material property name and formatted values depending on the anisotropy of the material 254 | and if non-linear parameters are used. 255 | 256 | :param matPropName: Material property 257 | :param tempVals: Values to assign material properties 258 | :return: str: 259 | """ 260 | 261 | if type(tempVals) == float: 262 | tempVal = np.array([tempVals]) 263 | elif type(tempVals) == list: 264 | tempVal = np.array(tempVals) 265 | elif type(tempVals) == np.ndarray: 266 | tempVal = tempVals 267 | else: 268 | raise ValueError('Material prop type not supported') 269 | 270 | lineStr = '*{:s}'.format(matPropName) 271 | 272 | if (tempVal.ndim == 1 and tempVal.shape[0] == 1) or (tempVal.ndim == 2 and tempVal.shape[1] == 1): 273 | lineStr += '\n' #',type=iso\n' 274 | elif (tempVal.ndim == 1 and tempVal.shape[0] == 3) or (tempVal.ndim == 2 and tempVal.shape[1] == 4): 275 | lineStr += ',type=ortho\n' 276 | else: 277 | raise ValueError('Invalid mat property({:s}'.format(matPropName)) 278 | 279 | if tempVal.ndim == 1: 280 | if tempVal.shape[0] == 1: 281 | lineStr += '{:e}\n'.format(tempVal[0]) 282 | elif tempVal.shape[0] == 3: 283 | lineStr += '{:e},{:e},{:e}\n'.format(tempVal[0], tempVal[1], tempVal[2]) 284 | 285 | if tempVal.ndim == 2: 286 | for i in range(tempVal.shape[0]): 287 | if tempVal.shape[1] == 2: 288 | lineStr += '{:e},{:e}\n'.format(tempVal[i, 1], tempVal[i, 0]) 289 | elif tempVal.shape[1] == 4: 290 | lineStr += '{:e},{:e},{:e},{:e}\n'.format(tempVal[1], tempVal[2], tempVal[3], tempVal[0]) 291 | 292 | return lineStr 293 | 294 | def isValid(self) -> bool: 295 | return True 296 | 297 | def writeInput(self) -> str: 298 | 299 | inputStr = '*material, name={:s}\n'.format(self._name) 300 | inputStr += '*{:s}\n'.format(self.materialModel) 301 | 302 | inputStr += self._writeElasticProp() 303 | 304 | if self._density: 305 | inputStr += self._writeMaterialProp('density', self._density) 306 | 307 | if self._cp: 308 | inputStr += self._writeMaterialProp('specific heat', self._cp) 309 | 310 | if self._alpha_CTE: 311 | inputStr += self._writeMaterialProp('expansion', self._alpha_CTE) 312 | 313 | if self._k: 314 | inputStr += self._writeMaterialProp('conductivity', self._k) 315 | 316 | # Write the plastic mode 317 | inputStr += self._writePlasticProp() 318 | 319 | return inputStr 320 | -------------------------------------------------------------------------------- /pyccx/mesh/__init__.py: -------------------------------------------------------------------------------- 1 | from .mesher import ElementType, Mesher, MeshingAlgorithm 2 | from .mesh import * 3 | -------------------------------------------------------------------------------- /pyccx/mesh/mesh.py: -------------------------------------------------------------------------------- 1 | import gmsh 2 | import numpy as np 3 | from .mesher import Mesher 4 | 5 | def removeSurfaceMeshes(model: Mesher) -> None: 6 | """ 7 | In order to assign face based boundary conditions to surfaces (e.g. flux, convection), the surface mesh is compared 8 | to the volumetric mesh to identify the actual surface mesh. This is then removed afterwards. 9 | 10 | :param model: Mesher: The GMSH model 11 | """ 12 | tags = model.getPhysicalGroups(2) 13 | 14 | for tag in tags: 15 | # Remove all tri group surfaces 16 | print('removing surface {:s}'.format(model.getPhysicalName(2, tag[1]))) 17 | model.removePhysicalGroups(tag) 18 | 19 | def getNodesFromVolume(volumeName : str, model: Mesher): 20 | """ 21 | Gets the nodes for a specified volume 22 | 23 | :param volumeName: str - The volume domain in the model to obtain the nodes from 24 | :param model: Mesher: The GMSH model 25 | :return: 26 | """ 27 | vols = model.getPhysicalGroups(3) 28 | names = [(model.getPhysicalName(3, x[1]), x[1]) for x in vols] 29 | 30 | volTagId = -1 31 | for name in names: 32 | if name[0] == volumeName: 33 | volTagId = name[1] 34 | 35 | if volTagId == -1: 36 | raise ValueError('Volume region ({:s}) was not found'.format(volumeName)) 37 | 38 | return model.mesh.getNodesForPhysicalGroup(3, volTagId)[0] 39 | 40 | def getNodesFromRegion(surfaceRegionName: str, model : Mesher): 41 | """ 42 | Gets the nodes for a specified surface region 43 | 44 | :param surfaceRegionName: str - The volume domain in the model to obtain the nodes from 45 | :param model: Mesher: The GMSH model 46 | :return: 47 | """ 48 | surfs = model.getPhysicalGroups(2) 49 | names = [(model.getPhysicalName(2, x[1]), x[1]) for x in surfs] 50 | 51 | surfTagId = -1 52 | for name in names: 53 | if name[0] == surfaceRegionName: 54 | surfTagId = name[1] 55 | 56 | if surfTagId == -1: 57 | raise ValueError('Surface region ({:s}) was not found'.format(surfaceRegionName)) 58 | 59 | return model.mesh.getNodesForPhysicalGroup(2, surfTagId)[0] 60 | 61 | 62 | def getSurfaceFacesFromRegion(regionName, model): 63 | """ 64 | Gets the faces from a surface region, which are compatible with GMSH in order to apply surface BCs to. 65 | 66 | :param surfaceRegionName: str - The volume domain in the model to obtain the nodes from 67 | :param model: Mesher: The GMSH model 68 | :return: 69 | """ 70 | 71 | surfs = model.getPhysicalGroups(2) 72 | names = [(model.getPhysicalName(2, x[1]), x[1]) for x in surfs] 73 | 74 | surfTagId = -1 75 | for name in names: 76 | if name[0] == regionName: 77 | surfTagId = name[1] 78 | 79 | if surfTagId == -1: 80 | raise ValueError('Surface region ({:s}) was not found'.format(regionName)) 81 | 82 | mesh = model.mesh 83 | 84 | surfNodeList2 = mesh.getNodesForPhysicalGroup(2, surfTagId)[0] 85 | 86 | # Get tet elements 87 | tetElList = mesh.getElementsByType(4) 88 | 89 | tetNodes = tetElList[1].reshape(-1, 4) 90 | tetMinEl = np.min(tetElList[0]) 91 | 92 | mask = np.isin(tetNodes, surfNodeList2) # Mark nodes which are on boundary 93 | ab = np.sum(mask, axis=1) # Count how many nodes were marked for each element 94 | fndIdx = np.argwhere(ab > 2) # For all tets 95 | elIdx = tetElList[0][fndIdx] 96 | 97 | if np.sum(ab > 3) > 0: 98 | raise ValueError('Instance of all nodes of tet where found') 99 | 100 | # Tet elements for Film [masks] 101 | F1 = [1, 1, 1, 0] # 1: 1 2 3 = [1,1,1,0] 102 | F2 = [1, 1, 0, 1] # 2: 1 4 2 = [1,1,0,1] 103 | F3 = [0, 1, 1, 1] # 3: 2 4 3 = [0,1,1,1] 104 | F4 = [1, 0, 1, 1] # 4: 3 4 1 = [1,0,1,1] 105 | 106 | surfFaces = np.zeros((len(elIdx), 2), dtype=np.uint32) 107 | surfFaces[:, 0] = elIdx.flatten() 108 | 109 | surfFaces[mask[fndIdx.ravel()].dot(F1) == 3, 1] = 1 # Mask 110 | surfFaces[mask[fndIdx.ravel()].dot(F2) == 3, 1] = 2 # Mask 111 | surfFaces[mask[fndIdx.ravel()].dot(F3) == 3, 1] = 3 # Mask 112 | surfFaces[mask[fndIdx.ravel()].dot(F4) == 3, 1] = 4 # Mask 113 | 114 | # sort by faces 115 | surfFaces = surfFaces[surfFaces[:, 1].argsort()] 116 | 117 | surfFaces[:, 0] = surfFaces[:, 0] - (tetMinEl + 1) 118 | 119 | return surfFaces -------------------------------------------------------------------------------- /pyccx/mesh/mesher.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import numpy as np 4 | import gmsh 5 | from enum import Enum 6 | from typing import List, Optional, Tuple 7 | 8 | 9 | class MeshingAlgorithm(Enum): 10 | DELAUNAY = 1 11 | FRONTAL = 4 12 | FRONTAL_DELAUNAY = 5 13 | FRONTAL_HEX = 6 14 | MMG3D = 7 15 | RTREE = 9 16 | HXT = 10 17 | 18 | 19 | class ElementType: 20 | """ 21 | Element types information used via GMSH and Calculix 22 | """ 23 | 24 | class NODE: 25 | """ A single node element""" 26 | id = 15 27 | name = 'Node' 28 | nodes = 1 29 | faces = None 30 | 31 | class TET4: 32 | """ 1st order linear Tet Element (C3D4) """ 33 | id = 4 34 | name = 'C3D4' 35 | nodes = 4 36 | faces = np.array([[1,2,3], [1,4,2], [2,4,3], [3,4,1]]) 37 | 38 | class TET10: 39 | """ 2nd order Quadratic Tet Element (C3D10) consisting of 10 nodes """ 40 | id = 11 41 | name = 'C3D10' 42 | nodes = 4 43 | faces = np.array([[1,2,3], [1,4,2], [2,4,3], [3,4,1]]) 44 | 45 | class HEX8: 46 | """ Linear Hex Element (C3D8) """ 47 | id = 5 48 | name = 'C3D8' 49 | nodes = 8 50 | faces = np.array([[1,2,3,4], [5,8,7,6], [1,5,6,2], [2,6,7,3], [3,7,8,4], [4,8,5,1]]) 51 | 52 | class HEX8R: 53 | """Linear Hex Element (C3D8R) with reduced order integration """ 54 | id = 5 55 | name = 'C3D8R' 56 | nodes = 8 57 | faces = np.array([[1, 2, 3, 4], [5, 8, 7, 6], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 8, 4], [4, 8, 5, 1]]) 58 | 59 | class HEX8R: 60 | """ 61 | Linear Hex Element (C3D8I) with reformulation to reduce the effects of shear and 62 | volumetric locking and hourglass effects under some extreme situations 63 | """ 64 | id = 5 65 | name = 'C3D8I' 66 | nodes = 8 67 | faces = np.array([[1, 2, 3, 4], [5, 8, 7, 6], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 8, 4], [4, 8, 5, 1]]) 68 | 69 | class HEX8R: 70 | """ 71 | Quadratic Hex Element (C3D20) consisting of 20 Nodes 72 | """ 73 | id = 17 74 | name = 'C3D20' 75 | nodes = 20 76 | faces = np.array([[1, 2, 3, 4], [5, 8, 7, 6], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 8, 4], [4, 8, 5, 1]]) 77 | 78 | 79 | class WEDGE6: 80 | """ Wedge or Prism Element (C3D6) """ 81 | id = 6 82 | name = 'C3D6' 83 | nodes = 6 84 | faces = np.array([[1,2,3], [4,5,6], [1,2,5,4], [2,3,6,5], [3,1,4,6]]) 85 | 86 | 87 | 88 | class Mesher: 89 | """ 90 | The Mesher class provides the base interface built upon the GMSH-SDK API operations. It provides the capability 91 | to mesh multiple PythonOCC objects 92 | """ 93 | 94 | # Static class variables for meshing operations 95 | 96 | ElementOrder = 1 97 | NumThreads = 4 98 | OptimiseNetgen = True 99 | Units = '' 100 | Initialised = False 101 | 102 | # Instance methods 103 | def __init__(self, modelName: str): 104 | """ 105 | :param modelName: str: A model name is required for GMSH 106 | """ 107 | 108 | # The GMSH Options must be initialised before performing any operations 109 | Mesher.initialise() 110 | 111 | # Create an individual model per class 112 | self._modelName = modelName 113 | self.modelMeshSizeFactor = 0.05 # 5% of model size 114 | self.geoms = [] 115 | self.surfaceSets = [] 116 | self.edgeSets = [] 117 | 118 | self._isMeshGenerated = False 119 | self._isDirty = False # Flag to indicate model hasn't been generated and is dirty 120 | self._isGeometryDirty = False 121 | self._meshingAlgorithm = MeshingAlgorithm.DELAUNAY 122 | 123 | # Set the model name for this instance 124 | gmsh.model.add(self._modelName) 125 | 126 | @classmethod 127 | def showGui(cls): 128 | """ 129 | Opens up the native GMSH Gui to inspect the geometry in the model and the mesh. This will block the Python script 130 | until the GUI is exited. 131 | """ 132 | if cls.Initialised: 133 | gmsh.fltk.run() 134 | 135 | def maxPhysicalGroupId(self, dim: int) -> int: 136 | """ 137 | Returns the highest physical group id in the GMSH model 138 | 139 | :param dim: int: The chosen dimension 140 | :return: int: The highest group id used 141 | """ 142 | self.setAsCurrentModel() 143 | return np.max([x[1] for x in gmsh.model.getPhysicalGroups(dim)]) 144 | 145 | def getVolumeName(self, volId: int) -> str: 146 | """ 147 | Gets the volume name (if assigned) 148 | 149 | :param volId: int: Volume id of a region 150 | """ 151 | self.setAsCurrentModel() 152 | return gmsh.model.getEntityName(3,volId) 153 | 154 | def getEntityName(self, id: Tuple[int, int]) -> str: 155 | """ 156 | Returns the name of an entity given an id (if assigned) 157 | 158 | :param id: Dimension, Entity Id 159 | 160 | """ 161 | self.setAsCurrentModel() 162 | return gmsh.model.getEntityName(id[0],id[1]) 163 | 164 | def setEntityName(self, id: Tuple[int, int], name: str) -> None: 165 | """ 166 | Set the geometrical entity name - useful only as reference when viewing in the GMSH GUI 167 | 168 | :param id: Entity Dimension and Entity Id 169 | :param name: The entity name 170 | 171 | """ 172 | self.setAsCurrentModel() 173 | gmsh.model.setEntityName(id[0], id[1], name) 174 | 175 | def setVolumePhysicalName(self, volId: int, name: str) -> None: 176 | """ 177 | Sets the geometric name of the volume id 178 | 179 | :param volId: Volume Id 180 | :param name: Name assigned to volume 181 | """ 182 | self.setAsCurrentModel() 183 | maxId = self.maxPhysicalGroupId(3) 184 | gmsh.model.addPhysicalGroup(3, [volId],maxId+1) 185 | gmsh.model.setPhysicalName(3,volId, name) 186 | 187 | def setSurfacePhysicalName(self, surfId: int, name: str) -> None: 188 | """ 189 | Sets the geometric name of the surface id 190 | 191 | :param surfId: Volume Id 192 | :param name: Name assigned to volume 193 | """ 194 | self.setAsCurrentModel() 195 | maxId = self.maxPhysicalGroupId(2) 196 | gmsh.model.addPhysicalGroup(2, [surfId],maxId+1) 197 | gmsh.model.setPhysicalName(2,surfId, name) 198 | 199 | def setEntityPhysicalName(self, id, name: str) -> None: 200 | """ 201 | Sets the geometric name of the volume id 202 | 203 | :param id: tuple(int, int): Dimension, Entity Id 204 | :param name: str: Name assigned to entity 205 | """ 206 | self.setAsCurrentModel() 207 | maxId = self.maxPhysicalGroupId(id[0]) 208 | gmsh.model.addPhysicalGroup(id[0], [id[1]],maxId+1) 209 | gmsh.model.setPhysicalName(id[0], id[1], name) 210 | 211 | def name(self) -> str: 212 | """ 213 | Each GMSH model requires a name 214 | """ 215 | return self._modelName 216 | 217 | def isDirty(self) -> bool: 218 | """ 219 | Has the model been successfully generated and no pending modifications exist. 220 | """ 221 | 222 | return self._isDirty 223 | 224 | def setModelChanged(self, state : bool = False) -> None: 225 | """ 226 | Any changes to GMSH model should call this to prevent inconsistency in a generated model 227 | 228 | :param state: Force the model to be shown as generated 229 | """ 230 | 231 | self._isDirty = state 232 | 233 | def addGeometry(self, filename: str, name: str, meshFactor: Optional[float] = 0.03): 234 | """ 235 | Adds CAD geometry into the GMSH kernel. The filename of compatiable model files along with the mesh factor 236 | should be used to specify a target mesh size. 237 | 238 | :param filename: 239 | :param name: Name to assign to the geometries imported 240 | :param meshFactor: Initialise the target element size to a proportion of the average bounding box dimensions 241 | """ 242 | 243 | if not (os.path.exists(filename) and os.access(filename, os.R_OK)): 244 | raise ValueError('File ({:s}) is not readable'.format(filename)) 245 | 246 | # Adds a single volume 247 | self.setAsCurrentModel() 248 | 249 | # Additional geometry will be merged into the current model 250 | gmsh.merge(filename) 251 | self.geoms.append({'name': name, 'filename': filename, 'meshSize': None, 'meshFactor': meshFactor}) 252 | 253 | # Set the name of the volume 254 | # This automatically done to ensure that are all exported. This may change to parse through all volumes following 255 | # merged and be recreated 256 | 257 | gmsh.model.setEntityName(3, len(self.geoms), name) 258 | gmsh.model.addPhysicalGroup(3, [len(self.geoms)], len(self.geoms)) 259 | gmsh.model.setPhysicalName(3, len(self.geoms), name) 260 | 261 | # set the mesh size for this geometry 262 | bbox = self.getGeomBoundingBoxById(len(self.geoms)) 263 | extents = bbox[1, :] - bbox[0, :] 264 | avgDim = np.mean(extents) 265 | meshSize = avgDim * meshFactor 266 | 267 | print('GMSH: Avg dim', avgDim, ' mesh size: ', meshSize) 268 | geomPoints = self.getPointsFromVolume(len(self.geoms)) 269 | 270 | # Set the geometry volume size 271 | self.geoms[-1]['meshSize'] = meshSize 272 | 273 | self.setMeshSize(geomPoints, meshSize) 274 | 275 | self._isGeometryDirty = True 276 | self.setModelChanged() 277 | 278 | def clearMesh(self) -> None: 279 | """ 280 | Clears any meshes previously generated 281 | """ 282 | self.setAsCurrentModel() 283 | gmsh.model.mesh.clear() 284 | 285 | @property 286 | def volumes(self) -> List[int]: 287 | self.setAsCurrentModel() 288 | tags = gmsh.model.getEntities(3) 289 | return [(x[1]) for x in tags] 290 | 291 | @property 292 | def surfaces(self) -> List[int]: 293 | """ 294 | Returns all the surface ids of geometry in the model 295 | """ 296 | self.setAsCurrentModel() 297 | tags = gmsh.model.getEntities(2) 298 | return [(x[1]) for x in tags] 299 | 300 | @property 301 | def edges(self) -> List[int]: 302 | tags = gmsh.model.getEntities(1) 303 | return [(x[1]) for x in tags] 304 | 305 | @property 306 | def points(self) -> List[int]: 307 | """ 308 | Returns all the point ids 309 | :return: 310 | """ 311 | self.setAsCurrentModel() 312 | tags = gmsh.model.getEntities(0) 313 | return [(x[1]) for x in tags] 314 | 315 | def mergeGeometry(self) -> None: 316 | """ 317 | Geometry is merged/fused together. Coincident surfaces and points are 318 | automatically merged together which enables a coherent mesh to be 319 | generated 320 | """ 321 | 322 | self.setAsCurrentModel() 323 | 324 | volTags = gmsh.model.getEntities(3) 325 | out = gmsh.model.occ.fragment(volTags[1:], [volTags[0]]) 326 | 327 | # Synchronise the model 328 | gmsh.model.occ.synchronize() 329 | 330 | self._isGeometryDirty = True 331 | self.setModelChanged() 332 | 333 | def boundingBox(self): 334 | self.setAsCurrentModel() 335 | return np.array(gmsh.model.getBoundingBox()) 336 | 337 | def getGeomBoundingBoxById(self, tagId: int): 338 | self.setAsCurrentModel() 339 | return np.array(gmsh.model.getBoundingBox(3, tagId)).reshape(-1, 3) 340 | 341 | def getGeomBoundingBoxByName(self, volumeName): 342 | self.setAsCurrentModel() 343 | volTagId = self.getIdByVolumeName(volumeName) 344 | return np.array(gmsh.model.getBoundingBox(3, volTagId)).reshape(-1, 3) 345 | 346 | def setMeshSize(self, pnts: List[int], size: float) -> None: 347 | """ 348 | Sets the mesh element size along an entity, however, this can only currently be performed using 349 | 350 | :param pnts: Point ids to set the mesh size 351 | :param size: Set the desired mesh length size at this point 352 | """ 353 | 354 | self.setAsCurrentModel() 355 | tags = [(0, x) for x in pnts] 356 | gmsh.model.mesh.setSize(tags, size) 357 | 358 | self.setModelChanged() 359 | 360 | def setMeshingAlgorithm(self, meshingAlgorithm) -> None: 361 | """ 362 | Sets the meshing algorithm to use by GMSH for the model 363 | 364 | :param meshingAlgorithm: MeshingAlgorith 365 | """ 366 | 367 | # The meshing algorithm is applied before generation, as this may be model specific 368 | print(meshingAlgorithm) 369 | self._meshingAlgorithm = meshingAlgorithm 370 | self.setModelChanged() 371 | 372 | ## Class Methods 373 | @classmethod 374 | def setUnits(cls, unitVal): 375 | cls.Units = unitVal 376 | 377 | if cls.Initialised: 378 | gmsh.option.setString("Geometry.OCCTargetUnit", Mesher.Units); 379 | 380 | @classmethod 381 | def setElementOrder(cls, elOrder: int): 382 | """ 383 | Sets the element order globally for the entire model. Note that different element orders cannot be used within 384 | the same GMSH model during generation 385 | 386 | :param elOrder: The element order 387 | """ 388 | cls.ElementOrder = elOrder 389 | 390 | if cls.Initialised: 391 | gmsh.option.setNumber("Mesh.ElementOrder", Mesher.ElementOrder) 392 | 393 | @classmethod 394 | def setOptimiseNetgen(cls, state: bool) -> None: 395 | """ 396 | Sets an option for GMSH to internally use Netgen to optimise the element quality of the generated mesh. Enabled 397 | by default. 398 | 399 | :param state: Toggles the option 400 | """ 401 | cls.OptimiseNetgen = state 402 | 403 | if cls.Initialised: 404 | gmsh.option.setNumber("Mesh.OptimizeNetgen", (1 if cls.OptimiseNetgen else 0)) 405 | 406 | @classmethod 407 | def getNumThreads(cls) -> int: 408 | """ 409 | Gets the number of threads used for parallel processing by GMSH 410 | """ 411 | return cls.NumThreads 412 | 413 | @classmethod 414 | def setNumThreads(cls, numThreads: int) -> None: 415 | """ 416 | Sets the number of threads to be used for parallel processing by GMSH 417 | 418 | :param numThreads: 419 | """ 420 | cls.NumThreads = numThreads 421 | 422 | if cls.Initialised: 423 | gmsh.option.setNumber("Mesh.MaxNumThreads3D", Mesher.NumThreads) 424 | 425 | @classmethod 426 | def setMeshSizeFactor(self, meshSizeFactor: float) -> None: 427 | """ 428 | The mesh factor size provides an estimate length for the initial element sizes based on proportion of the maximumum 429 | bounding box length. 430 | 431 | :param meshSizeFactor: The mesh factor size between [0.,1.0] 432 | """ 433 | 434 | if meshSizeFactor > 1.0 or meshSizeFactor < 1e-8: 435 | raise ValueError('Invalid size for the mesh size factor was provided') 436 | 437 | self.modelMeshSizeFactor = meshSizeFactor 438 | 439 | @classmethod 440 | def finalize(cls): 441 | gmsh.finalize() 442 | cls.Initialised = False 443 | 444 | @classmethod 445 | def initialise(cls) -> None: 446 | """ 447 | Initialises the GMSH runtime and sets default options. This is called automatically once. 448 | """ 449 | 450 | if cls.Initialised: 451 | return 452 | 453 | print('Initialising GMSH \n') 454 | 455 | gmsh.initialize() 456 | 457 | # Mesh.Algorithm3D 458 | # 3D mesh algorithm (1: Delaunay, 4: Frontal, 5: Frontal Delaunay, 6: Frontal Hex, 7: MMG3D, 9: R-tree, 10: HXT) 459 | # Default value: 1# 460 | 461 | gmsh.option.setNumber("Mesh.Algorithm", MeshingAlgorithm.FRONTAL_DELAUNAY.value); 462 | # gmsh.option.setNumber("Mesh.Algorithm3D", 10); 463 | 464 | gmsh.option.setNumber("Mesh.ElementOrder", Mesher.ElementOrder) 465 | # gmsh.option.setNumber("Mesh.OptimizeNetgen",1) 466 | gmsh.option.setNumber("Mesh.MaxNumThreads3D", Mesher.NumThreads) 467 | 468 | # gmsh.option.setNumber("Mesh.SaveGroupsOfNodes", 1); 469 | # gmsh.option.setNumber("Mesh.CharacteristicLengthMax", 15); 470 | 471 | gmsh.option.setNumber("Mesh.CharacteristicLengthFromCurvature", 0); 472 | gmsh.option.setNumber("Mesh.CharacteristicLengthFromPoints", 1); 473 | gmsh.option.setNumber("Mesh.SaveAll", 0); 474 | 475 | # OCC Options 476 | gmsh.option.setNumber("Geometry.OCCFixDegenerated", 1); 477 | gmsh.option.setString("Geometry.OCCTargetUnit", Mesher.Units); 478 | 479 | # General Options 480 | gmsh.option.setString("General.ErrorFileName", 'error.log'); 481 | gmsh.option.setNumber("General.Terminal", 1) 482 | 483 | ########## Gui Options ############ 484 | gmsh.option.setNumber("General.Antialiasing", 1) 485 | gmsh.option.setNumber("Geometry.SurfaceType", 2) 486 | 487 | # The following GMSH options do not appear to change anything 488 | # Import labels is required inorder to correctly reference geometric entities and their associated mesh entities 489 | gmsh.option.setNumber("Geometry.OCCImportLabels", 1) 490 | 491 | # gmsh.option.setNumber("Geometry.OCCAutoFix", 0) 492 | cls.Initialised = True 493 | 494 | def setAsCurrentModel(self): 495 | """ 496 | Sets the current model to that specified in the class instance because 497 | Only one instance of GMSH sdk is available so this must be 498 | dynamically switched between models for multiple instances of a Mesher. 499 | """ 500 | 501 | gmsh.model.setCurrent(self._modelName) 502 | 503 | def removeEdgeMeshes(self) -> None: 504 | """ 505 | Removes edges (1D mesh entities) from the GMSH model 506 | """ 507 | 508 | self.setAsCurrentModel() 509 | 510 | tags = gmsh.model.getPhysicalGroups(1) 511 | 512 | for tag in tags: 513 | # Remove all tri group surfaces 514 | print('removing edge {:s}'.format(gmsh.model.getPhysicalName(1, tag[1]))) 515 | gmsh.model.removePhysicalGroups(tag) 516 | 517 | self.setModelChanged() 518 | 519 | def removeSurfaceMeshes(self): 520 | """ 521 | Removes surface meshes (2D mesh entities) from the GMSH model 522 | """ 523 | self.setAsCurrentModel() 524 | 525 | tags = gmsh.model.getPhysicalGroups(2) 526 | 527 | for tag in tags: 528 | # Remove all tri group surfaces 529 | print('removing surface {:s}'.format(gmsh.model.getPhysicalName(2, tag[1]))) 530 | gmsh.model.removePhysicalGroups(tag) 531 | 532 | self.setModelChanged() 533 | 534 | # Geometric methods 535 | def getPointsFromVolume(self, id: int) -> List[int]: 536 | """ 537 | From a Volume Id, obtain all Point Ids associated with this volume - note may include shared points. 538 | 539 | :param id: Volume ID 540 | :return: list(int) - List of Point Ids 541 | """ 542 | self.setAsCurrentModel() 543 | pnts = gmsh.model.getBoundary([(3, id)], recursive=True) 544 | return [x[1] for x in pnts] 545 | 546 | def getPointsFromEntity(self, id: Tuple[int,int]) -> List[int]: 547 | """ 548 | From an Id, obtain all Point Ids associated with this volume - note may include shared points. 549 | 550 | :param id: Dimension and Entity ID 551 | :return: List of Point Ids 552 | """ 553 | self.setAsCurrentModel() 554 | pnts = gmsh.model.getBoundary([id], recursive=True) 555 | return [x[1] for x in pnts] 556 | 557 | def getChildrenFromEntities(self, id: Tuple[int,int]) -> List[int]: 558 | """ 559 | From a Entity, obtain all children associated with this volume - note may include shared entities. 560 | 561 | :param id: Dimension, Entity Id 562 | :return: List of Ids 563 | """ 564 | self.setAsCurrentModel() 565 | entities = gmsh.model.getBoundary([id], recursive=False) 566 | return [x[1] for x in entities] 567 | 568 | def getSurfacesFromVolume(self, id: int) -> List[int]: 569 | """ 570 | From a Volume Id, obtain all Surface Ids associated with this volume - note may include shared boundary surfaces. 571 | 572 | :param id: Volume Id 573 | :return: List of surface Ids 574 | """ 575 | 576 | self.setAsCurrentModel() 577 | surfs = gmsh.model.getBoundary( [(3, id)], recursive=False) 578 | return [x[1] for x in surfs] 579 | 580 | def getPointsFromVolumeByName(self, volumeName: str): 581 | """ 582 | Returns all geometric points from a given volume domain by its name (if assigned) 583 | :param volumeName: volumeName 584 | :return: 585 | """ 586 | return self.getPointsFromVolume(self.getIdBySurfaceName(volumeName)) 587 | 588 | # Mesh methods 589 | def getNodeIds(self): 590 | """ 591 | Returns the nodal ids from the entire GMSH model 592 | :return: 593 | """ 594 | self.setAsCurrentModel() 595 | 596 | if not self._isMeshGenerated: 597 | raise ValueError('Mesh is not generated') 598 | 599 | nodeList = gmsh.model.mesh.getNodes() 600 | return nodeList[0] 601 | 602 | def getNodes(self): 603 | """ 604 | Returns the nodal coordinates from the entire GMSH model 605 | 606 | :return: 607 | """ 608 | self.setAsCurrentModel() 609 | 610 | 611 | if not self._isMeshGenerated: 612 | raise ValueError('Mesh is not generated') 613 | 614 | nodeList = gmsh.model.mesh.getNodes() 615 | 616 | nodeCoords = nodeList[1].reshape(-1, 3) 617 | 618 | nodeCoordsSrt = nodeCoords[np.sort(nodeList[0]) - 1] 619 | return nodeCoordsSrt # , np.sort(nodeList[0])-1 620 | 621 | 622 | def getElements(self, entityId: Tuple[int,int] = None): 623 | """ 624 | Returns the elements for the entire model or optionally a specific entity. 625 | :param entityId: The entity id to obtain elements for 626 | :return: 627 | """ 628 | self.setAsCurrentModel() 629 | 630 | if not self._isMeshGenerated: 631 | raise ValueError('Mesh is not generated') 632 | 633 | if entityId: 634 | # return all the elements for the entity 635 | result = gmsh.model.mesh.getElements(entityId[0], entityId[1]) 636 | return np.hstack(result[1]) 637 | else: 638 | # Return all the elements in the model 639 | result = gmsh.model.mesh.getElements() 640 | return np.hstack(result[1]) 641 | 642 | 643 | def getElementsByType(self, elType) -> np.ndarray: 644 | """ 645 | Returns all elements of type (elType) from the GMSH model, within class ElementTypes. Note: the element ids are returned with 646 | an index starting from 1 - internally GMSH uses an index starting from 1, like most FEA pre-processors 647 | 648 | :return: List of element Ids. 649 | """ 650 | 651 | self.setAsCurrentModel() 652 | 653 | if not self._isMeshGenerated: 654 | raise ValueError('Mesh is not generated') 655 | 656 | elVar = gmsh.model.mesh.getElementsByType(elType.id) 657 | elements = elVar[1].reshape(-1, elType.nodes) # TODO check if necessary - 1 658 | 659 | return elements 660 | 661 | 662 | def getNodesFromEntity(self, entityId: Tuple[int,int]) -> np.ndarray: 663 | """ 664 | Returns all node ids from a selected entity in the GMSH model. 665 | 666 | :param entityId: int : Volume name 667 | :return: 668 | """ 669 | 670 | self.setAsCurrentModel() 671 | 672 | if not self._isMeshGenerated: 673 | raise ValueError('Mesh is not generated') 674 | 675 | return gmsh.model.mesh.getNodes(entityId[0], entityId[1], True)[0] 676 | 677 | def getNodesByEntityName(self, entityName: str) -> np.ndarray: 678 | """ 679 | Returns all nodes for a selected surface region 680 | 681 | :param entityName: The geometric surface name 682 | :return: Node Ids 683 | """ 684 | self.setAsCurrentModel() 685 | 686 | if not self._isMeshGenerated: 687 | raise ValueError('Mesh is not generated') 688 | 689 | tagId = self.getIdByEntityName(entityName) 690 | 691 | return gmsh.model.mesh.getNodes(tagId[0], tagId[1], True)[0] 692 | 693 | def getNodesFromVolumeByName(self, volumeName: str): 694 | """ 695 | Returns all node ids from a selected volume domain in the GMSH model. 696 | 697 | :param volumeName: Volume name 698 | :return: 699 | """ 700 | 701 | self.setAsCurrentModel() 702 | 703 | if not self._isMeshGenerated: 704 | raise ValueError('Mesh is not generated') 705 | 706 | volTagId = self.getIdByVolumeName(volumeName) 707 | 708 | return gmsh.model.mesh.getNodes(3, volTagId, True)[0] 709 | 710 | def getNodesFromEdgeByName(self, edgeName: str): 711 | """ 712 | Returns all nodes from a geometric edge 713 | 714 | :param edgeName: The geometric edge name 715 | :return: 716 | """ 717 | self.setAsCurrentModel() 718 | 719 | if not self._isMeshGenerated: 720 | raise ValueError('Mesh is not generated') 721 | 722 | edgeTagId = self.getIdByEdgeName(edgeName) 723 | 724 | return gmsh.model.mesh.getNodes(1, edgeTagId, True)[0] 725 | 726 | 727 | def getNodesFromSurfaceByName(self, surfaceRegionName: str): 728 | """ 729 | Returns all nodes for a selected surface region 730 | 731 | :param surfaceRegionName: The geometric surface name 732 | :return: 733 | """ 734 | self.setAsCurrentModel() 735 | 736 | if not self._isMeshGenerated: 737 | raise ValueError('Mesh is not generated') 738 | 739 | surfTagId = self.getIdBySurfaceName(surfaceRegionName) 740 | 741 | return gmsh.model.mesh.getNodes(2, surfTagId, True)[0] 742 | 743 | 744 | def getSurfaceFacesFromRegion(self, regionName): 745 | self.setAsCurrentModel() 746 | 747 | surfTagId = self.getIdBySurfaceName(regionName) 748 | 749 | return self.getSurfaceFacesFromSurfId(surfTagId) 750 | 751 | def _getFaceOrderMask(self, elementType): 752 | """ 753 | Private method which constructs the face mask array from the faces ordering of an element. 754 | :param elementType: 755 | :return: 756 | """ 757 | mask = np.zeros([elementType.faces.shape[0], np.max(elementType.faces)]) 758 | for i in np.arange(mask.shape[0]): 759 | mask[i, elementType.faces[i] - 1] = 1 760 | 761 | return mask 762 | 763 | 764 | def getSurfaceFacesFromSurfId(self, surfTagId): 765 | 766 | self.setAsCurrentModel() 767 | 768 | if not self._isMeshGenerated: 769 | raise ValueError('Mesh is not generated') 770 | 771 | 772 | mesh = gmsh.model.mesh 773 | 774 | surfNodeList2 = mesh.getNodes(2, surfTagId, True)[0] 775 | 776 | #surfNodeList2 = mesh.getNodesForEntity(2, surfTagId)[0] 777 | 778 | # Get tet elements 779 | tet4ElList = mesh.getElementsByType(ElementType.TET4.id) 780 | tet10ElList = mesh.getElementsByType(ElementType.TET10.id) 781 | 782 | tet4Nodes = tet4ElList[1].reshape(-1, 4) 783 | tet10Nodes = tet10ElList[1].reshape(-1, 10) 784 | 785 | tetNodes = np.vstack([tet4Nodes, 786 | tet10Nodes[:, :4]]) 787 | 788 | # Note subtract 1 to get an index starting from zero 789 | tetElList = np.hstack([tet4ElList[0] -1, 790 | tet10ElList[0] -1]) 791 | 792 | print(ElementType.TET4.id) 793 | tetMinEl = np.min(tetElList) 794 | 795 | mask = np.isin(tetNodes, surfNodeList2) # Mark nodes which are on boundary 796 | ab = np.sum(mask, axis=1) # Count how many nodes were marked for each element 797 | fndIdx = np.argwhere(ab > 2) # For all tets locate where the number of nodes on the surface = 3 798 | 799 | # Elements which belong onto the surface 800 | elIdx = tetElList[fndIdx] 801 | 802 | # Below if more than a four nodes (tet) lies on the surface, an error has occured 803 | if np.sum(ab > 3) > 0: 804 | raise ValueError('Instance of all nodes of tet where found') 805 | 806 | 807 | # Tet elements for Film [masks] 808 | 809 | surfFaces = np.zeros((len(elIdx), 2), dtype=np.uint32) 810 | surfFaces[:, 0] = elIdx.flatten() 811 | 812 | fMask = self._getFaceOrderMask(ElementType.TET4) 813 | 814 | # Iterate across each face of the element and apply mask across the elements 815 | for i in np.arange(fMask.shape[0]): 816 | surfFaces[mask[fndIdx.ravel()].dot(fMask[i]) == 3, 1] = 1 # Mask 817 | 818 | if False: 819 | F1 = [1, 1, 1, 0] # 1: 1 2 3 = [1,1,1,0] 820 | F2 = [1, 1, 0, 1] # 2: 1 4 2 = [1,1,0,1] 821 | F3 = [0, 1, 1, 1] # 3: 2 4 3 = [0,1,1,1] 822 | F4 = [1, 0, 1, 1] # 4: 3 4 1 = [1,0,1,1] 823 | 824 | surfFaces[mask[fndIdx.ravel()].dot(F1) == 3, 1] = 1 # Mask 825 | surfFaces[mask[fndIdx.ravel()].dot(F2) == 3, 1] = 2 # Mask 826 | surfFaces[mask[fndIdx.ravel()].dot(F3) == 3, 1] = 3 # Mask 827 | surfFaces[mask[fndIdx.ravel()].dot(F4) == 3, 1] = 4 # Mask 828 | 829 | # sort by faces 830 | surfFaces = surfFaces[surfFaces[:, 1].argsort()] 831 | 832 | surfFaces[:, 0] = surfFaces[:, 0] - (tetMinEl + 1) 833 | 834 | return surfFaces 835 | 836 | def renumberNodes(self) -> None: 837 | """ 838 | Renumbers the nodes of the entire GMSH Model 839 | """ 840 | self.setAsCurrentModel() 841 | 842 | if not self._isMeshGenerated: 843 | raise ValueError('Mesh is not generated') 844 | 845 | gmsh.model.mesh.renumberNodes() 846 | self.setModelChanged() 847 | 848 | def renumberElements(self) -> None: 849 | """ 850 | Renumbers the elements of the entire GMSH Model 851 | """ 852 | self.setAsCurrentModel() 853 | 854 | if not self._isMeshGenerated: 855 | raise ValueError('Mesh is not generated') 856 | 857 | gmsh.model.mesh.renumberElements() 858 | self.setModelChanged() 859 | 860 | def _setModelOptions(self) -> None: 861 | """ 862 | Private method for initialising any additional options for individual models within GMSH which are not global. 863 | """ 864 | self.setAsCurrentModel() 865 | gmsh.option.setNumber("Mesh.Algorithm", self._meshingAlgorithm.value) 866 | 867 | 868 | def generateMesh(self) -> None: 869 | """ 870 | Initialises the GMSH Meshing Proceedure. 871 | """ 872 | self._isMeshGenerated = False 873 | 874 | self._setModelOptions() 875 | 876 | self.setAsCurrentModel() 877 | 878 | print('Generating GMSH \n') 879 | 880 | gmsh.model.mesh.generate(1) 881 | gmsh.model.mesh.generate(2) 882 | 883 | try: 884 | gmsh.model.mesh.generate(3) 885 | except: 886 | print('Meshing Failed \n') 887 | 888 | self._isMeshGenerated = True 889 | self._isDirty = False 890 | 891 | def isMeshGenerated(self) -> bool: 892 | """ 893 | Returns if the mesh has been successfully generated by GMSH 894 | """ 895 | return self._isMeshGenerated 896 | 897 | def writeMesh(self, filename: str) -> None: 898 | """ 899 | Writes the generated mesh to the file 900 | 901 | :param filename: str - Filename (including the type) to save to. 902 | """ 903 | 904 | if self.isMeshGenerated(): 905 | self.setAsCurrentModel() 906 | gmsh.write(filename) 907 | else: 908 | raise ValueError('Mesh has not been generated before writing the file') 909 | 910 | def getIdByEntityName(self, entityName: str) -> int: 911 | """ 912 | Obtains the ID for volume name 913 | 914 | :param volumeName: str 915 | :return: int: Volume ID 916 | """ 917 | self.setAsCurrentModel() 918 | 919 | vols = gmsh.model.getEntities() 920 | names = [(gmsh.model.getEntityName(x[0], x[1]), x) for x in vols] 921 | 922 | tagId = -1 923 | for name in names: 924 | if name[0] == entityName: 925 | tagId = name[1] 926 | 927 | if tagId == -1: 928 | raise ValueError('Volume region ({:s}) was not found'.format(entityName)) 929 | 930 | return tagId 931 | 932 | 933 | def getIdByVolumeName(self, volumeName: str) -> int: 934 | """ 935 | Obtains the ID for volume name 936 | 937 | :param volumeName: str 938 | :return: int: Volume ID 939 | """ 940 | self.setAsCurrentModel() 941 | 942 | vols = gmsh.model.getEntities(3) 943 | names = [(gmsh.model.getEntityName(3, x[1]), x[1]) for x in vols] 944 | 945 | volTagId = -1 946 | for name in names: 947 | if name[0] == volumeName: 948 | volTagId = name[1] 949 | 950 | if volTagId == -1: 951 | raise ValueError('Volume region ({:s}) was not found'.format(volumeName)) 952 | 953 | return volTagId 954 | 955 | def getIdByEdgeName(self, edgeName: str) -> int: 956 | """ 957 | Obtains the ID for the edge name 958 | 959 | :param edgeName: Geometric edge name 960 | :return: Edge ID 961 | """ 962 | 963 | self.setAsCurrentModel() 964 | 965 | surfs = gmsh.model.getEntities(1) 966 | names = [(gmsh.model.getEntityName(1, x[1]), x[1]) for x in surfs] 967 | 968 | edgeTagId = -1 969 | for name in names: 970 | if name[0] == edgeName: 971 | surfTagId = name[1] 972 | 973 | if edgeTagId == -1: 974 | raise ValueError('Surface region ({:s}) was not found'.format(edgeName)) 975 | 976 | return edgeTagId 977 | 978 | def getIdBySurfaceName(self, surfaceName : str) -> int: 979 | """ 980 | Obtains the ID for the surface name 981 | 982 | :param surfaceName: Geometric surface name 983 | :return: Surface Ids 984 | """ 985 | 986 | self.setAsCurrentModel() 987 | 988 | surfs = gmsh.model.getEntities(2) 989 | names = [(gmsh.model.getEntityName(2, x[1]), x[1]) for x in surfs] 990 | 991 | surfTagId = -1 992 | for name in names: 993 | if name[0] == surfaceName: 994 | surfTagId = name[1] 995 | 996 | if surfTagId == -1: 997 | raise ValueError('Surface region ({:s}) was not found'.format(surfaceName)) 998 | 999 | return surfTagId 1000 | 1001 | def setEdgeSet(self, grpTag, edgeName): 1002 | # Adding a physical group will export the surface mesh later so these need removing 1003 | 1004 | self.edgeSets.append({'name': edgeName, 'tag': grpTag, 'nodes': np.array([])}) 1005 | 1006 | # below is not needed - it is safe to get node list directly from entity 1007 | 1008 | # gmsh.model.addPhysicalGroup(1, [grpTag], len(self.edgeSets)) 1009 | # gmsh.model.setPhysicalName(1, len(self.edgeSets), edgeName) 1010 | 1011 | def setSurfaceSet(self, grpTag: int, surfName:str) -> None: 1012 | """ 1013 | Generates a surface set based on the geometric surface name. GMSH creates an associative surface mesh, which will 1014 | later be automatically removed. 1015 | 1016 | :param grpTag: int: A unique Geometric Id used to associate the surface set as a physical group 1017 | :param surfName: str: The surface name for the set 1018 | :return: 1019 | """ 1020 | # Adding a physical group will export the surface mesh later so these need removing 1021 | 1022 | self.setAsCurrentModel() 1023 | self.surfaceSets.append({'name': surfName, 'tag': grpTag, 'nodes': np.array([])}) 1024 | gmsh.model.mesh.get 1025 | gmsh.model.addPhysicalGroup(2, [grpTag], len(self.surfaceSets)) 1026 | gmsh.model.setPhysicalName(2, len(self.surfaceSets), surfName) 1027 | gmsh.model.removePhysicalGroups() 1028 | self.setModelChanged() 1029 | 1030 | def interpTri(triVerts, triInd): 1031 | 1032 | from matplotlib import pyplot as plt 1033 | 1034 | import matplotlib.pyplot as plt 1035 | import matplotlib.tri as mtri 1036 | import numpy as np 1037 | 1038 | x = triVerts[:, 0] 1039 | y = triVerts[:, 1] 1040 | triang = mtri.Triangulation(triVerts[:, 0], triVerts[:, 1], triInd) 1041 | 1042 | # Interpolate to regularly-spaced quad grid. 1043 | z = np.cos(1.5 * x) * np.cos(1.5 * y) 1044 | xi, yi = np.meshgrid(np.linspace(0, 3, 20), np.linspace(0, 3, 20)) 1045 | 1046 | interp_lin = mtri.LinearTriInterpolator(triang, z) 1047 | zi_lin = interp_lin(xi, yi) 1048 | 1049 | interp_cubic_geom = mtri.CubicTriInterpolator(triang, z, kind='geom') 1050 | zi_cubic_geom = interp_cubic_geom(xi, yi) 1051 | 1052 | interp_cubic_min_E = mtri.CubicTriInterpolator(triang, z, kind='min_E') 1053 | zi_cubic_min_E = interp_cubic_min_E(xi, yi) 1054 | 1055 | # Set up the figure 1056 | fig, axs = plt.subplots(nrows=2, ncols=2) 1057 | axs = axs.flatten() 1058 | 1059 | # Plot the triangulation. 1060 | axs[0].tricontourf(triang, z) 1061 | axs[0].triplot(triang, 'ko-') 1062 | axs[0].set_title('Triangular grid') 1063 | 1064 | # Plot linear interpolation to quad grid. 1065 | axs[1].contourf(xi, yi, zi_lin) 1066 | axs[1].plot(xi, yi, 'k-', lw=0.5, alpha=0.5) 1067 | axs[1].plot(xi.T, yi.T, 'k-', lw=0.5, alpha=0.5) 1068 | axs[1].set_title("Linear interpolation") 1069 | 1070 | # Plot cubic interpolation to quad grid, kind=geom 1071 | axs[2].contourf(xi, yi, zi_cubic_geom) 1072 | axs[2].plot(xi, yi, 'k-', lw=0.5, alpha=0.5) 1073 | axs[2].plot(xi.T, yi.T, 'k-', lw=0.5, alpha=0.5) 1074 | axs[2].set_title("Cubic interpolation,\nkind='geom'") 1075 | 1076 | # Plot cubic interpolation to quad grid, kind=min_E 1077 | axs[3].contourf(xi, yi, zi_cubic_min_E) 1078 | axs[3].plot(xi, yi, 'k-', lw=0.5, alpha=0.5) 1079 | axs[3].plot(xi.T, yi.T, 'k-', lw=0.5, alpha=0.5) 1080 | axs[3].set_title("Cubic interpolation,\nkind='min_E'") 1081 | 1082 | fig.tight_layout() 1083 | plt.show() -------------------------------------------------------------------------------- /pyccx/results/__init__.py: -------------------------------------------------------------------------------- 1 | from .results import Result, NodalResult, ElementResult, ResultProcessor 2 | -------------------------------------------------------------------------------- /pyccx/results/results.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import re 3 | import os 4 | 5 | from ..core import ElementSet, NodeSet 6 | 7 | import numpy as np 8 | 9 | 10 | class Result(abc.ABC): 11 | """ 12 | Base Class for all Calculix Results 13 | """ 14 | def __init__(self): 15 | self.frequency = 1 16 | 17 | def setFrequency(self, freq): 18 | self.frequency = freq 19 | 20 | @abc.abstractmethod 21 | def writeInput(self): 22 | raise NotImplemented() 23 | 24 | 25 | class NodalResult(Result): 26 | 27 | def __init__(self, nodeSet: NodeSet): 28 | 29 | self._nodeSet = nodeSet 30 | 31 | self.useNodalDisplacements = False 32 | self.useNodalTemperatures = False 33 | self.useReactionForces = False 34 | self.useHeatFlux = False 35 | self.useCauchyStress = False # Int points are interpolated to nodes 36 | self.usePlasticStrain = False 37 | self.useNodalStrain = False 38 | 39 | super().__init__() 40 | 41 | @property 42 | def nodeSet(self) -> NodeSet: 43 | """ 44 | The elementset to obtain values for post-processing. 45 | """ 46 | return self._nodeSet 47 | 48 | @nodeSet.setter 49 | def nodeSet(self, nodeSet: NodeSet): 50 | self._nodeSet = nodeSet 51 | 52 | def writeInput(self): 53 | inputStr = '' 54 | inputStr += '*NODE FILE, ' 55 | 56 | if isinstance(self.nodeSet, str) and self.nodeSet != '': 57 | inputStr += 'NSET={:s}, '.format(self.nodeSet) 58 | 59 | inputStr += 'FREQUENCY={:d}\n'.format(self.frequency) 60 | 61 | if self.useNodalDisplacements: 62 | inputStr += 'U\n' 63 | 64 | if self.useNodalTemperatures: 65 | inputStr += 'NT\n' 66 | 67 | if self.useReactionForces: 68 | inputStr += 'RF\n' 69 | 70 | inputStr += self.writeElementInput() 71 | 72 | return inputStr 73 | 74 | def writeElementInput(self): 75 | str = '*EL FILE, NSET={:s}, FREQUENCY={:d}\n'.format(self._nodeSet.name, self.frequency) 76 | 77 | if self.useCauchyStress: 78 | str += 'S\n' 79 | if self.useNodalStrain: 80 | str += 'E\n' 81 | 82 | if self.usePlasticStrain: 83 | str += 'PEEQ\n' 84 | 85 | if self.useHeatFlux: 86 | str += 'HFL\n' 87 | 88 | return str 89 | 90 | 91 | class ElementResult(Result): 92 | def __init__(self, elSet: ElementSet): 93 | 94 | self._elSet = elSet 95 | self.useElasticStrain = False 96 | self.useCauchyStress = False 97 | self.useHeatFlux = False 98 | self.useESE = False 99 | 100 | super().__init__() 101 | 102 | @property 103 | def elementSet(self) -> ElementSet: 104 | """ 105 | The elementset to obtain values for post-processing. 106 | """ 107 | return self._elSet 108 | 109 | @elementSet.setter 110 | def elementSet(self, elSet: ElementSet): 111 | self._elSet = elSet 112 | 113 | def writeInput(self): 114 | str = '' 115 | str += '*EL PRINT, ELSET={:s}, FREQUENCY={:d}\n'.format(self._elSet.name, self.frequency) 116 | 117 | if self.useCauchyStress: 118 | str += 'S\n' 119 | 120 | if self.useElasticStrain: 121 | str += 'E\n' 122 | 123 | if self.useESE: 124 | str += 'ELSE\n' 125 | 126 | if self.useHeatFlux: 127 | str += 'HFL\n' 128 | 129 | return str 130 | 131 | 132 | class ResultProcessor: 133 | """ 134 | ResultProcessor takes the output (results) file from the Calculix simulation and processes the ASCII .frd file 135 | to load the results into a structure. Individual timesteps (increments) are segregated and may be accessed 136 | accordingly. 137 | """ 138 | 139 | def __init__(self, jobName): 140 | 141 | self.increments = {} 142 | self.jobName = jobName 143 | 144 | print('Reading file {:s}'.format(jobName)) 145 | 146 | def lastIncrement(self): 147 | """ 148 | Returns the last increment of the Calculix results file 149 | :return: 150 | """ 151 | 152 | idx = sorted(list(self.increments.keys()))[-1] 153 | return self.increments[idx] 154 | 155 | def findIncrementByTime(self, incTime) -> int: 156 | 157 | for inc, increment in self.increments.items(): 158 | if abs(increment['time'] - incTime) < 1e-9: 159 | return inc, increment 160 | else: 161 | raise ValueError('Increment could not be found at time <{:.5f}>s'.format(incTime)) 162 | 163 | @staticmethod 164 | def _getVals(fstr: str, line: str): 165 | """ 166 | Returns a list of typed items based on an input format string. Credit for 167 | the processing of the .dat file is based from the PyCalculix project. 168 | https://github.com/spacether/pycalculix 169 | 170 | :param fstr: C format string, commas separate fields 171 | :param line: str: line string to parse 172 | :return: list: List of typed items extracted from the line 173 | """ 174 | 175 | res = [] 176 | fstr = fstr.split(',') 177 | thestr = str(line) 178 | for item in fstr: 179 | if item[0] == "'": 180 | # strip off the char quaotes 181 | item = item[1:-1] 182 | # this is a string entry, grab the val out of the line 183 | ind = len(item) 184 | fwd = thestr[:ind] 185 | thestr = thestr[ind:] 186 | res.append(fwd) 187 | else: 188 | # format is: 1X, A66, 5E12.5, I12 189 | # 1X is number of spaces 190 | (mult, ctype) = (1, None) 191 | m_pat = re.compile(r'^\d+') # find multiplier 192 | c_pat = re.compile(r'[XIEA]') # find character 193 | if m_pat.findall(item) != []: 194 | mult = int(m_pat.findall(item)[0]) 195 | ctype = c_pat.findall(item)[0] 196 | if ctype == 'X': 197 | # we are dealing with spaces, just reduce the line size 198 | thestr = thestr[mult:] 199 | elif ctype == 'A': 200 | # character string only, add it to results 201 | fwd = thestr[:mult].strip() 202 | thestr = thestr[mult:] 203 | res.append(fwd) 204 | else: 205 | # IE, split line into m pieces 206 | w_pat = re.compile(r'[IE](\d+)') # find the num after char 207 | width = int(w_pat.findall(item)[0]) 208 | while mult > 0: 209 | # only add items if we have enough line to look at 210 | if width <= len(thestr): 211 | substr = thestr[:width] 212 | thestr = thestr[width:] 213 | substr = substr.strip() # remove space padding 214 | if ctype == 'I': 215 | substr = int(substr) 216 | elif ctype == 'E': 217 | substr = float(substr) 218 | res.append(substr) 219 | mult -= 1 220 | return res 221 | 222 | @staticmethod 223 | def __get_first_dataline(infile): 224 | """ 225 | Reads infile until a line with data is found, then returns it 226 | A line that starts with ' -1' has data 227 | """ 228 | while True: 229 | line = infile.readline() 230 | if line[:3] == ' -1': 231 | return line 232 | 233 | def readNodeDisp(self, line, rfstr) -> tuple: 234 | nid, ux, uy, uz = self._getVals(rfstr, line)[1:] 235 | return nid, ux, uy, uz 236 | 237 | def readNodeForce(self, line, rfstr): 238 | nid, f_x, f_y, f_z = self._getVals(rfstr, line)[1:] 239 | return nid, f_x, f_y, f_z 240 | 241 | def readNodeFlux(self, line, rfstr) -> tuple: 242 | nid, f_x, f_y, f_z = self._getVals(rfstr, line)[1:] 243 | return nid, f_x, f_y, f_z 244 | 245 | def readNodeTemp(self, line, rfstr) -> tuple: 246 | nid, temp = self._getVals(rfstr, line)[1:] 247 | return nid, temp 248 | 249 | def readNodeStress(self, line, rfstr) -> tuple: 250 | nid, sxx, syy, szz, sxy, syz, szx = self._getVals(rfstr, line)[1:] 251 | return nid, sxx, syy, szz, sxy, syz, szx 252 | 253 | def readNodeStrain(self, line, rfstr) -> tuple: 254 | nid, exx, eyy, ezz, exy, eyz, ezx = self._getVals(rfstr, line)[1:] 255 | return nid, exx, eyy, ezz, exy, eyz, ezx 256 | 257 | def readElFlux(self, line, rfstr, time): 258 | """Saves element integration point stresses""" 259 | elFlux = self._getVals(rfstr, line)[1:] 260 | 261 | elId, intp, qx, qy, qz = self._getVals(rfstr, line) 262 | 263 | return elId, intp, qx, qy, qz 264 | 265 | def readElStress(self, line, rfstr, time): 266 | """Saves element integration point stresses""" 267 | 268 | elId, intp, sxx, syy, szz, sxy, syz, szx = self._getVals(rfstr, line) 269 | 270 | return elId, intp, sxx, syy, szz, sxy, syz, szx 271 | 272 | 273 | def readElResultBlock(self, infile, line): 274 | 275 | """Returns an array of line, mode, rfstr, time""" 276 | words = line.strip().split() 277 | # add time if not present 278 | time = float(words[-1]) 279 | 280 | # set mode 281 | rfstr = "I10,2X,I2,6E14.2" 282 | 283 | mode = 'stress' 284 | infile.readline() 285 | line = infile.readline() 286 | return [line, mode, rfstr, time] 287 | 288 | def readNodalResultsBlock(self, infile): 289 | 290 | """Returns an array of line, mode, rfstr, time""" 291 | line = infile.readline() 292 | fstr = "1X,' 100','C',6A1,E12.5,I12,20A1,I2,I5,10A1,I2" 293 | tmp = self._getVals(fstr, line) 294 | # [key, code, setname, value, numnod, text, ictype, numstp, analys, format_] 295 | time, format_ = tmp[3], tmp[9] 296 | 297 | # set results format to short, long or binary 298 | # only short and long are parsed so far 299 | if format_ == 0: 300 | rfstr = "1X,I2,I5,6E12.5" 301 | elif format_ == 1: 302 | rfstr = "1X,I2,I10,6E12.5" 303 | elif format_ == 2: 304 | # binary 305 | pass 306 | 307 | # set the time 308 | # self.__store_time(time) 309 | 310 | # get the name to determine if stress or displ 311 | line = infile.readline() 312 | fstr = "1X,I2,2X,8A1,2I5" 313 | # [key, name, ncomps, irtype] 314 | ar2 = self._getVals(fstr, line) 315 | name = ar2[1] 316 | iteration = tmp[7] 317 | line = self.__get_first_dataline(infile) 318 | 319 | return [line, name, rfstr, time, iteration] 320 | 321 | def read(self) -> None: 322 | """ 323 | Opens up the results files and processes the results 324 | """ 325 | 326 | infile = open('{:s}.frd'.format(self.jobName), 'r') 327 | print('Loading nodal results from file: ' + self.jobName) 328 | 329 | mode = None 330 | time = 0.0 331 | rfstr = '' 332 | 333 | while True: 334 | line = infile.readline() 335 | if not line: 336 | break 337 | 338 | # set the results mode 339 | if '1PSTEP' in line: 340 | # we are in a results block 341 | arr = self.readNodalResultsBlock(infile) 342 | line, mode, rfstr, time, inc = arr 343 | inc = int(inc) 344 | if inc not in self.increments.keys(): 345 | self.increments[inc] = {'time' : time, 346 | 'disp' : [], 347 | 'stress': [], 348 | 'strain': [], 349 | 'force' : [], 350 | 'temp' : []} 351 | 352 | # set mode to none if we hit the end of a resuls block 353 | if line[:3] == ' -3': 354 | mode = None 355 | 356 | if not mode: 357 | continue 358 | 359 | if mode == 'DISP': 360 | self.increments[inc]['disp'].append(self.readNodeDisp(line, rfstr)) 361 | elif mode == 'STRESS': 362 | self.increments[inc]['stress'].append(self.readNodeStress(line, rfstr)) 363 | elif mode == 'TOSTRAIN': 364 | self.increments[inc]['strain'].append(self.readNodeStrain(line, rfstr)) 365 | elif mode == 'FORC': 366 | self.increments[inc]['force'].append(self.readNodeForce(line, rfstr)) 367 | elif mode == 'NDTEMP': 368 | self.increments[inc]['temp'].append(self.readNodeTemp(line, rfstr)) 369 | 370 | infile.close() 371 | 372 | self.readDat() 373 | 374 | # Process the nodal blocks 375 | for inc in self.increments.values(): 376 | inc['disp'] = self.orderNodes(np.array(inc['disp'])) 377 | inc['stress'] = self.orderNodes(np.array(inc['stress'])) 378 | inc['strain'] = self.orderNodes(np.array(inc['strain'])) 379 | inc['force'] = self.orderNodes(np.array(inc['force'])) 380 | inc['temp'] = self.orderNodes(np.array(inc['temp'])) 381 | 382 | print('The following times have been read:') 383 | print(len(self.increments)) 384 | 385 | @staticmethod 386 | def orderNodes(nodeVals): 387 | if nodeVals.size == 0: 388 | return nodeVals 389 | 390 | return nodeVals[nodeVals[:, 0].argsort(), :] 391 | 392 | @staticmethod 393 | def orderElements(elVals): 394 | if elVals.size == 0: 395 | return elVals 396 | 397 | return elVals[elVals[:, 0].argsort(), :] 398 | 399 | def readDat(self): 400 | 401 | fname = '{:s}.dat'.format(self.jobName) 402 | 403 | if not os.path.isfile(fname): 404 | print('Error: %s file not found' % fname) 405 | return 406 | 407 | infile = open(fname, 'r') 408 | print('Loading element results from file: ' + fname) 409 | 410 | mode = None 411 | rfstr = '' 412 | incTime = 0.0 413 | inc = -1 414 | while True: 415 | line = infile.readline() 416 | 417 | if not line: 418 | break 419 | 420 | if line.strip() == '': 421 | mode = None 422 | 423 | # check for stress, we skip down to the line data when 424 | # we call __modearr_estrsresults 425 | if 'stress' in line: 426 | arr = self.readElResultBlock(infile, line) 427 | line, mode, rfstr, incTime = arr 428 | 429 | # store stress results 430 | inc, increment = self.findIncrementByTime(incTime) 431 | self.increments[inc]['elStress'].append(self.readElStress(line, rfstr, incTime)) 432 | elif 'heat flux' in line: 433 | arr = self.readElResultBlock(infile, line) 434 | line, mode, rfstr, incTime = arr 435 | 436 | print(incTime) 437 | print(self.increments) 438 | # store the heatlufx results 439 | inc, increment = self.findIncrementByTime(incTime) 440 | 441 | mode = 'elHeatFlux' 442 | self.increments[inc][mode] = [] 443 | 444 | if mode and inc > -1: 445 | self.increments[inc][mode].append(self.readElFlux(line, rfstr, incTime)) 446 | 447 | for inc in self.increments.values(): 448 | 449 | if 'elStress' in inc: 450 | inc['elStress'] = self.orderElements(np.array(inc['elStress'])) 451 | 452 | if 'elHeatFlux' in inc: 453 | inc['elHeatFlux'] = self.orderElements(np.array(inc['elHeatFlux'])) 454 | 455 | infile.close() -------------------------------------------------------------------------------- /pyccx/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.2' 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | numpy 3 | gmsh>=4.7 4 | 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | # load __version__ without importing anything 5 | version_file = os.path.join( 6 | os.path.dirname(__file__), 7 | 'pyccx/version.py') 8 | 9 | with open(version_file, 'r') as f: 10 | # use eval to get a clean string of version from file 11 | __version__ = eval(f.read().strip().split('=')[-1]) 12 | 13 | # load README.md as long_description 14 | long_description = '' 15 | if os.path.exists('README.rst'): 16 | with open('README.rst', 'r') as f: 17 | long_description = f.read() 18 | 19 | # minimal requirements for installing pyccx 20 | # note that `pip` requires setuptools itself 21 | requirements_default = set([ 22 | 'numpy', # all data structures 23 | 'gmsh', # Required for meshing geometry 24 | 'setuptools' # used for packaging 25 | ]) 26 | 27 | # "easy" requirements should install without compiling 28 | # anything on Windows, Linux, and Mac, for Python 2.7-3.4+ 29 | requirements_easy = set([ 30 | 'setuptools', # do setuptools stuff 31 | 'colorlog']) # log in pretty colors 32 | 33 | 34 | # requirements for building documentation 35 | requirements_docs = set([ 36 | 'sphinx', 37 | 'jupyter', 38 | 'sphinx_rtd_theme', 39 | 'pypandoc', 40 | 'autodocsumm']) 41 | 42 | with open('README.rst') as f: 43 | readme = f.read() 44 | 45 | with open('LICENSE') as f: 46 | license = f.read() 47 | 48 | setup( 49 | name='PyCCX', 50 | version=__version__, 51 | description='Simulation and FEA environment for Python built upon Calculix and GMSH', 52 | long_description=long_description, 53 | long_description_content_type = 'text/x-rst', 54 | author='Luke Parry', 55 | author_email='dev@lukeparry.uk', 56 | url='https://github.com/drlukeparry/pyccx', 57 | keywords='FEA, Finite Element Analysis, Simulation, Calculix, GMSH', 58 | python_requires='>=3.5', 59 | classifiers=[ 60 | 'License :: OSI Approved :: BSD License', 61 | 'Programming Language :: Python', 62 | 'Programming Language :: Python :: 3.5', 63 | 'Programming Language :: Python :: 3.6', 64 | 'Programming Language :: Python :: 3.7', 65 | 'Programming Language :: Python :: 3.8', 66 | 'Programming Language :: Python :: 3.9', 67 | 'Programming Language :: Python :: 3.10', 68 | 'Programming Language :: Python :: 3.11', 69 | 'Natural Language :: English', 70 | 'Topic :: Scientific/Engineering'], 71 | license="", 72 | packages=find_packages(exclude=('tests', 'docs')), 73 | install_requires=list(requirements_default), 74 | extras_require={'easy': list(requirements_easy), 75 | 'docs': list(requirements_docs)}, 76 | 77 | project_urls={ 78 | 'Documentation': 'https://pyccx.readthedocs.io/en/latest/', 79 | 'Source': 'https://github.com/drylukeparry/pyccx/pyccx/', 80 | 'Tracker': 'https://github.com/drlukeparry/pyccx/issues' 81 | } 82 | 83 | 84 | ) 85 | 86 | 87 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drlukeparry/pyccx/f77197c0570e6134422234f03fb2140087566524/tests/__init__.py -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import os 5 | import pyccx 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/test_advanced.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import pyccx 3 | 4 | import unittest 5 | import platform 6 | import tempfile 7 | 8 | class AdvancedTestSuite(unittest.TestCase): 9 | """Advanced test cases.""" 10 | 11 | def test_thoughts(self): 12 | assert True 13 | 14 | 15 | if __name__ == '__main__': 16 | unittest.main() 17 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import pyccx 3 | 4 | import unittest 5 | import platform 6 | import tempfile 7 | 8 | 9 | class BasicTestSuite(unittest.TestCase): 10 | """Basic test cases.""" 11 | 12 | def test_absolute_truth_and_meaning(self): 13 | assert True 14 | 15 | 16 | if __name__ == '__main__': 17 | unittest.main() --------------------------------------------------------------------------------