├── .clang-format ├── .coveragerc ├── .editorconfig ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── _build_and_publish_documentation.yml │ ├── _build_package.yml │ ├── _code_quality.yml │ ├── _merge_into_release.yml │ ├── _test.yml │ ├── _test_future.yml │ ├── nightly_build.yml │ ├── publish_release.yml │ ├── pull_request_to_main.yml │ ├── push.yml │ ├── push_to_main.yml │ └── push_to_release.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .sourcery.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── ADVANCED.md ├── CHANGELOG.md ├── LICENSE ├── LICENSE_THIRD_PARTY ├── MLMODEL.md ├── README.md ├── STYLEGUIDE.md ├── docs ├── Makefile ├── interface │ └── schema.html ├── make.bat ├── schema │ └── schema.json └── source │ ├── ADVANCED.md │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── LICENSE_THIRD_PARTY.md │ ├── MLMODEL.md │ ├── README.md │ ├── STYLEGUIDE.md │ ├── _static │ └── mlfmu_logo_v1.svg │ ├── _templates │ ├── custom-class.rst │ └── custom-module.rst │ ├── api.rst │ ├── cli.mlfmu.rst │ ├── cli.rst │ ├── conf.py │ └── index.rst ├── examples ├── README.md ├── wind_generator │ ├── WindGenerator │ │ ├── modelDescription.xml │ │ ├── resources │ │ │ └── example.onnx │ │ └── sources │ │ │ ├── fmu.cpp │ │ │ └── model_definitions.h │ ├── config │ │ ├── example.onnx │ │ └── interface.json │ └── generated_fmu │ │ └── WindGenerator.fmu ├── wind_to_power │ ├── WindToPower │ │ ├── modelDescription.xml │ │ ├── resources │ │ │ └── example.onnx │ │ └── sources │ │ │ ├── fmu.cpp │ │ │ └── model_definitions.h │ ├── config │ │ ├── example.onnx │ │ └── interface.json │ └── generated_fmu │ │ └── WindToPower.fmu └── wind_to_power_pyspark │ ├── MLFMU_RF_Regressor │ ├── modelDescription.xml │ ├── resources │ │ └── rf_model.onnx │ └── sources │ │ ├── fmu.cpp │ │ └── model_definitions.h │ ├── config │ ├── interface.json │ └── rf_model.onnx │ ├── generated_fmu │ └── MLFMU_RF_Regressor.fmu │ └── ml_model │ ├── T1.csv │ └── wind-turbine-power-prediction-gbtregressor-pyspark.ipynb ├── pyproject.toml ├── pytest.ini ├── qa.bat ├── ruff.toml ├── src └── mlfmu │ ├── __init__.py │ ├── api.py │ ├── cli │ ├── __init__.py │ ├── mlfmu.py │ └── publish_docs.py │ ├── fmu_build │ ├── CMakeLists.txt │ ├── cmake │ │ ├── FindFMUComplianceChecker.cmake │ │ ├── GenerateFmuGuid.cmake │ │ ├── ZipAll.cmake │ │ └── fmu-uuid.h.in │ ├── conanfile.txt │ ├── fmi │ │ ├── fmi2FunctionTypes.h │ │ ├── fmi2Functions.h │ │ ├── fmi2TypesPlatform.h │ │ ├── fmiFunctions.h │ │ └── fmiPlatformTypes.h │ └── templates │ │ ├── fmu │ │ ├── fmu_template.cpp │ │ └── model_definitions_template.h │ │ └── onnx_fmu │ │ ├── onnxFmu.cpp │ │ └── onnxFmu.hpp │ ├── py.typed │ ├── types │ ├── __init__.py │ ├── fmu_component.py │ └── onnx_model.py │ └── utils │ ├── __init__.py │ ├── builder.py │ ├── fmi_builder.py │ ├── interface.py │ ├── logging.py │ ├── path.py │ ├── signals.py │ └── strings.py ├── stubs └── onnxruntime │ └── __init__.pyi ├── tests ├── .gitignore ├── cli │ └── test_mlfmu_cli.py ├── conftest.py ├── data │ └── example.onnx ├── test_api.py ├── test_working_directory │ ├── .gitignore │ ├── test_config_file │ ├── test_config_file.json │ ├── test_config_file_empty │ └── test_config_file_empty.json ├── types │ └── test_fmu_component.py └── utils │ ├── test_fmu_template.py │ ├── test_interface_validation.py │ └── test_modelDescription_builder.py └── uv.lock /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | Standard: Cpp11 4 | BasedOnStyle: WebKit 5 | NamespaceIndentation: None 6 | IndentCaseLabels: true 7 | IndentPPDirectives: AfterHash 8 | FixNamespaceComments: true 9 | MaxEmptyLinesToKeep: 2 10 | SpaceAfterTemplateKeyword: false 11 | AllowShortCaseLabelsOnASingleLine: true 12 | AllowAllParametersOfDeclarationOnNextLine: true 13 | AllowShortIfStatementsOnASingleLine: true 14 | AllowShortBlocksOnASingleLine: true 15 | AllowShortLoopsOnASingleLine: true 16 | BreakBeforeBinaryOperators: None 17 | Cpp11BracedListStyle: true 18 | BreakBeforeBraces: Custom 19 | BraceWrapping: 20 | AfterEnum: true 21 | AfterStruct: true 22 | AfterClass: true 23 | SplitEmptyFunction: false 24 | AfterFunction: true 25 | AfterNamespace : true 26 | AfterControlStatement: false 27 | IncludeBlocks: Regroup 28 | IncludeCategories: 29 | - Regex: '^[<"]cosim[/.]' 30 | Priority: 20 31 | - Regex: '^[<"](boost|gsl|msgpack|nlohmann|Poco|zip)[/.]' 32 | Priority: 30 33 | - Regex: '^"' 34 | Priority: 10 35 | - Regex: '.*' 36 | Priority: 40 37 | AlignTrailingComments: true 38 | ... 39 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src/mlfmu 4 | */site-packages/mlfmu 5 | 6 | [run] 7 | source = mlfmu 8 | branch = True 9 | 10 | [report] 11 | fail_under = 10.0 12 | show_missing = True 13 | skip_covered = True 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.{yml,yaml}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.py text 7 | *.cpp text 8 | *.hpp text 9 | *.c text 10 | *.h text 11 | *.json text 12 | *.xml text 13 | *.txt text 14 | *.yml text 15 | *.yaml text 16 | *.toml text 17 | *.rst text 18 | *.ini text 19 | 20 | # Declare files that will always have CRLF line endings on checkout. 21 | *.vcproj text eol=crlf 22 | *.sln text eol=crlf 23 | *.md text eol=crlf 24 | 25 | # Declare files that will always have LF line endings on checkout. 26 | *.sh text eol=lf 27 | 28 | # Declare files that will not be normalized regardless of their content. 29 | *.jpg -text 30 | *.png -text 31 | *.gif -text 32 | *.ico -text 33 | 34 | # Denote all files that are truly binary and should not be modified. 35 | *.onnx binary 36 | *.fmu binary 37 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | 7 | 8 | Resolves #issue_nr 9 | 10 | 11 | 12 | ... 13 | 14 | 15 | 16 | ## How Has This Been Tested? 17 | 18 | 19 | 20 | 21 | - [ ] Test A 22 | - [ ] Test B 23 | 24 | ## Screenshots (if appropriate) 25 | 26 | ## Developer Checklist (before requesting PR review) 27 | 28 | - [ ] My code follows the style guidelines of this project 29 | - [ ] My changes generate no new warnings 30 | - [ ] Any dependent changes have been merged and published in downstream modules 31 | 32 | I have: 33 | 34 | - [ ] commented my code, particularly in hard-to-understand areas 35 | - [ ] performed a self-review of my own code 36 | - [ ] not committed unnecessary formatting changes, thereby occluding the actual changes (e.g. change of tab spacing, eol, etc.) 37 | - [ ] made corresponding changes to the documentation 38 | - [ ] added change to CHANGELOG.md 39 | - [ ] added tests that prove my fix is effective or that my feature works (for core features) 40 | 41 | ## Reviewer checklist 42 | 43 | I have: 44 | 45 | - [ ] performed a self-review of my own code have performed a review of the code 46 | - [ ] tested that the software still works as expected 47 | - [ ] checked updates to documentation 48 | - [ ] checked that the CHANGELOG is updated 49 | -------------------------------------------------------------------------------- /.github/workflows/_build_and_publish_documentation.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish documentation 2 | 3 | on: workflow_call 4 | 5 | env: 6 | DEFAULT_BRANCH: 'release' 7 | #SPHINXOPTS: '-W --keep-going -T' 8 | # ^-- If these SPHINXOPTS are enabled, then be strict about the builds and fail on any warnings 9 | 10 | jobs: 11 | build-and-publish-docs: 12 | name: Build and publish documentation 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout active branch 16 | uses: actions/checkout@v4 17 | with: 18 | lfs: true 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v2 21 | with: 22 | enable-cache: true 23 | cache-dependency-glob: "uv.lock" 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version-file: "pyproject.toml" 28 | - name: Install the project 29 | run: uv sync --upgrade 30 | - name: Print debugging information 31 | run: | 32 | echo "github.ref:" ${{github.ref}} 33 | echo "github.event_name:" ${{github.event_name}} 34 | echo "github.head_ref:" ${{github.head_ref}} 35 | echo "github.base_ref:" ${{github.base_ref}} 36 | set -x 37 | git rev-parse --abbrev-ref HEAD 38 | git branch 39 | git branch -a 40 | git remote -v 41 | uv run python -V 42 | uv pip list 43 | 44 | # Build documentation 45 | - uses: sphinx-doc/github-problem-matcher@master 46 | - name: Build documentation 47 | run: | 48 | cd docs 49 | uv run make html 50 | 51 | - name: Clone and cleanup gh-pages branch 52 | run: | 53 | set -x 54 | git fetch 55 | ( git branch gh-pages remotes/origin/gh-pages && git clone . --branch=gh-pages _gh-pages/ ) || mkdir _gh-pages 56 | rm -rf _gh-pages/.git/ 57 | mkdir -p _gh-pages/branch/ 58 | 59 | # Delete orphaned branch-folders: 60 | # Go through each subfolder in _gh-pages/branch/ 61 | # If it relates to an orphaned branch, delete it. 62 | - name: Delete orphaned branch-folders 63 | run: | 64 | set -x 65 | for brdir in `ls _gh-pages/branch/` ; do 66 | brname=${brdir//--/\/} # replace '--' with '/' 67 | if ! git show-ref remotes/origin/$brname ; then 68 | echo "Removing $brdir" 69 | rm -r _gh-pages/branch/$brdir/ 70 | fi 71 | done 72 | 73 | # Copy documentation to _gh-pages/ (if push happened on release branch) 74 | - name: Copy documentation to _gh-pages/ 75 | if: | 76 | contains(github.ref, env.DEFAULT_BRANCH) 77 | run: | 78 | set -x 79 | # Delete everything under _gh-pages/ that is from the 80 | # primary branch deployment. Excludes the other branches 81 | # _gh-pages/branch-* paths, and not including 82 | # _gh-pages itself. 83 | find _gh-pages/ -mindepth 1 ! -path '_gh-pages/branch*' -delete 84 | rsync -a docs/build/html/ _gh-pages/ 85 | 86 | # Copy documentation to _gh-pages/branch/$brname (if push happened on any other branch) 87 | # ('/' gets replaced by '--') 88 | - name: Copy documentation to _gh-pages/branch/${{github.ref}} 89 | if: | 90 | !contains(github.ref, env.DEFAULT_BRANCH) 91 | run: | 92 | set -x 93 | #brname=$(git rev-parse --abbrev-ref HEAD) 94 | brname="${{github.ref}}" 95 | brname="${brname##refs/heads/}" 96 | brdir=${brname//\//--} # replace '/' with '--' 97 | rm -rf _gh-pages/branch/${brdir} 98 | rsync -a docs/build/html/ _gh-pages/branch/${brdir} 99 | 100 | # Add .nojekyll file 101 | - name: Add .nojekyll file 102 | run: touch _gh-pages/.nojekyll 103 | 104 | # Publish: Commit gh-pages branch and publish it to GitHub Pages 105 | - name: Publish documentation 106 | uses: peaceiris/actions-gh-pages@v4 107 | with: 108 | publish_branch: gh-pages 109 | github_token: ${{ secrets.GITHUB_TOKEN }} 110 | publish_dir: _gh-pages/ 111 | force_orphan: true 112 | -------------------------------------------------------------------------------- /.github/workflows/_build_package.yml: -------------------------------------------------------------------------------- 1 | name: Build Package 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | build: 7 | name: Build source distribution 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | lfs: true 13 | - run: git submodule update --init --recursive 14 | name: Checkout submodules 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v2 17 | with: 18 | enable-cache: true 19 | cache-dependency-glob: "uv.lock" 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version-file: "pyproject.toml" 23 | - name: Build source distribution and wheel 24 | run: uv build 25 | - name: Run twine check 26 | run: uvx twine check --strict dist/* 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | path: | 30 | dist/*.tar.gz 31 | dist/*.whl 32 | -------------------------------------------------------------------------------- /.github/workflows/_code_quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | ruff_format: 7 | runs-on: ubuntu-latest 8 | name: ruff format 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Install uv 12 | uses: astral-sh/setup-uv@v2 13 | with: 14 | enable-cache: true 15 | cache-dependency-glob: "uv.lock" 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version-file: "pyproject.toml" 20 | - name: Install the project 21 | run: uv sync --upgrade 22 | - name: Run ruff format 23 | run: uv run ruff format --diff 24 | 25 | ruff_check: 26 | runs-on: ubuntu-latest 27 | name: ruff check 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v2 32 | with: 33 | enable-cache: true 34 | cache-dependency-glob: "uv.lock" 35 | - name: Set up Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version-file: "pyproject.toml" 39 | - name: Install the project 40 | run: uv sync --upgrade 41 | - name: Run ruff check 42 | run: uv run ruff check --diff 43 | 44 | pyright: 45 | runs-on: ubuntu-latest 46 | name: pyright 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Install uv 50 | uses: astral-sh/setup-uv@v2 51 | with: 52 | enable-cache: true 53 | cache-dependency-glob: "uv.lock" 54 | - name: Set up Python 55 | uses: actions/setup-python@v5 56 | with: 57 | python-version-file: "pyproject.toml" 58 | - name: Install the project 59 | run: uv sync --upgrade 60 | - name: Run pyright 61 | run: uv run pyright 62 | 63 | mypy: 64 | runs-on: ubuntu-latest 65 | name: mypy 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Install uv 69 | uses: astral-sh/setup-uv@v2 70 | with: 71 | enable-cache: true 72 | cache-dependency-glob: "uv.lock" 73 | - name: Set up Python 74 | uses: actions/setup-python@v5 75 | with: 76 | python-version-file: "pyproject.toml" 77 | - name: Install the project 78 | run: uv sync --upgrade 79 | - name: Run mypy 80 | run: uv run mypy 81 | -------------------------------------------------------------------------------- /.github/workflows/_merge_into_release.yml: -------------------------------------------------------------------------------- 1 | name: Merge into release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | RELEASE_TOKEN: 7 | required: true 8 | 9 | jobs: 10 | merge_into_release: 11 | name: Merge ${{ github.event.ref }} -> release branch 12 | runs-on: ubuntu-latest 13 | environment: release 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | # Fetch the whole history to prevent unrelated history errors 18 | fetch-depth: 0 19 | # The branch you want to checkout (usually equal to `branchtomerge`) 20 | # ref: ${{ github.event.ref }} 21 | - uses: devmasx/merge-branch@v1.4.0 22 | with: 23 | type: now 24 | target_branch: release 25 | github_token: ${{ secrets.RELEASE_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/_test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | test: 7 | name: Test on ${{matrix.python.version}}-${{matrix.platform.runner}} 8 | runs-on: ${{ matrix.platform.runner }} 9 | strategy: 10 | matrix: 11 | platform: 12 | - runner: ubuntu-latest 13 | - runner: windows-latest 14 | # - runner: macos-latest 15 | python: 16 | - version: '3.10' 17 | - version: '3.11' 18 | - version: '3.12' 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v2 23 | with: 24 | enable-cache: true 25 | cache-dependency-glob: "uv.lock" 26 | - name: Install Python ${{ matrix.python.version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python.version }} 30 | - name: Install the project 31 | run: uv sync --upgrade -p ${{ matrix.python.version }} --no-dev 32 | - name: Install pytest 33 | run: | 34 | uv pip install pytest 35 | uv pip install pytest-cov 36 | - name: Run pytest 37 | run: uv run pytest --cov 38 | -------------------------------------------------------------------------------- /.github/workflows/_test_future.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests (py313) 2 | # Test also with Python 3.13 (experimental; workflow will not fail on error.) 3 | 4 | on: workflow_call 5 | 6 | jobs: 7 | test313: 8 | name: Test on ${{matrix.python.version}}-${{matrix.platform.runner}} (experimental) 9 | continue-on-error: true 10 | runs-on: ${{ matrix.platform.runner }} 11 | strategy: 12 | matrix: 13 | platform: 14 | - runner: ubuntu-latest 15 | - runner: windows-latest 16 | python: 17 | - version: '3.13.0-alpha - 3.13.0' 18 | uvpy: '3.13' 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v2 23 | with: 24 | enable-cache: true 25 | cache-dependency-glob: "uv.lock" 26 | - name: Install Python ${{ matrix.python.version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python.version }} 30 | - name: Install the project 31 | run: uv sync --upgrade -p ${{ matrix.python.uvpy }} --no-dev 32 | - name: Install pytest 33 | run: | 34 | uv pip install pytest 35 | uv pip install pytest-cov 36 | - name: Run pytest 37 | run: uv run pytest --cov 38 | -------------------------------------------------------------------------------- /.github/workflows/nightly_build.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Build 2 | run-name: Nightly Build (by @${{ github.actor }}) 3 | 4 | on: 5 | schedule: 6 | - cron: '30 5 * * *' 7 | 8 | jobs: 9 | code_quality: 10 | uses: ./.github/workflows/_code_quality.yml 11 | test: 12 | uses: ./.github/workflows/_test.yml 13 | test_future: 14 | uses: ./.github/workflows/_test_future.yml 15 | build_package: 16 | needs: 17 | - test 18 | uses: ./.github/workflows/_build_package.yml 19 | build_and_publish_documentation: 20 | needs: 21 | - build_package 22 | uses: ./.github/workflows/_build_and_publish_documentation.yml 23 | -------------------------------------------------------------------------------- /.github/workflows/publish_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | run-name: Publish Release ${{ github.event.ref }} created by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | build_package: 11 | uses: ./.github/workflows/_build_package.yml 12 | publish_package: 13 | name: Publish package 14 | needs: 15 | - build_package 16 | runs-on: ubuntu-latest 17 | environment: release 18 | permissions: 19 | id-token: write 20 | steps: 21 | - uses: actions/download-artifact@v4 22 | with: 23 | name: artifact 24 | path: dist 25 | - uses: pypa/gh-action-pypi-publish@release/v1 26 | # with: # Uncomment this line to publish to testpypi 27 | # repository-url: https://test.pypi.org/legacy/ # Uncomment this line to publish to testpypi 28 | merge_into_release: 29 | uses: ./.github/workflows/_merge_into_release.yml 30 | secrets: 31 | RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_to_main.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request to main 2 | run-name: Pull Request to main from ${{ github.event.pull_request.head.ref }} by @${{ github.actor }} 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - opened 8 | # - synchronize 9 | - reopened 10 | - ready_for_review 11 | - converted_to_draft 12 | branches: 13 | - main 14 | 15 | concurrency: 16 | group: pr-${{ github.ref }}-1 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | code_quality: 21 | uses: ./.github/workflows/_code_quality.yml 22 | test: 23 | uses: ./.github/workflows/_test.yml 24 | # build_package: 25 | # needs: 26 | # - code_quality 27 | # - test 28 | # uses: ./.github/workflows/_build_package.yml 29 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push to custom branches 2 | run-name: Push to ${{ github.ref }} by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches-ignore: 7 | - main 8 | - release 9 | 10 | concurrency: 11 | group: push-${{ github.ref }}-1 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | code_quality: 16 | uses: ./.github/workflows/_code_quality.yml 17 | test: 18 | uses: ./.github/workflows/_test.yml 19 | -------------------------------------------------------------------------------- /.github/workflows/push_to_main.yml: -------------------------------------------------------------------------------- 1 | name: Push to main 2 | run-name: Push to main by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: push-${{ github.ref }}-1 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | code_quality: 15 | uses: ./.github/workflows/_code_quality.yml 16 | test: 17 | uses: ./.github/workflows/_test.yml 18 | build_package: 19 | needs: 20 | - code_quality 21 | - test 22 | uses: ./.github/workflows/_build_package.yml 23 | build_and_publish_documentation: 24 | needs: 25 | - build_package 26 | uses: ./.github/workflows/_build_and_publish_documentation.yml 27 | -------------------------------------------------------------------------------- /.github/workflows/push_to_release.yml: -------------------------------------------------------------------------------- 1 | name: Push to release 2 | run-name: Push to release by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches: 7 | - release 8 | 9 | concurrency: 10 | group: push-${{ github.ref }}-1 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build_and_publish_documentation: 15 | uses: ./.github/workflows/_build_and_publish_documentation.yml 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | _autosummary 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # uv 91 | # It is generally recommended to include `uv.lock` in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, `uv` might install dependencies in one environment that don't work in another. 94 | # In such case, `uv.lock` should be added to `.gitignore` 95 | # uv.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Ruff 141 | .ruff_cache 142 | 143 | # PyCharm 144 | .idea 145 | 146 | # VS Code 147 | .vscode/* 148 | !.vscode/settings.json 149 | !.vscode/tasks.json 150 | !.vscode/launch.json 151 | !.vscode/extensions.json 152 | !.vscode/*.code-snippets 153 | 154 | # Inside /examples folder: ignore temporary logs, db's and local data files 155 | examples/**/*.log 156 | examples/**/*.db 157 | examples/**/*.nc 158 | 159 | # Cmake generated files 160 | CMakeUserPresets.json 161 | 162 | # Generated FMUs 163 | *.fmu -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/mlfmu/fmu_build/cppfmu"] 2 | path = src/mlfmu/fmu_build/cppfmu 3 | url = https://github.com/viproma/cppfmu 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: mixed-line-ending 6 | fix: auto 7 | - id: trailing-whitespace 8 | - id: check-yaml 9 | - id: check-toml 10 | - id: check-merge-conflict 11 | - repo: https://github.com/astral-sh/ruff-pre-commit 12 | rev: v0.6.2 13 | hooks: 14 | - id: ruff 15 | - id: ruff-format 16 | # - repo: https://github.com/pre-commit/mirrors-mypy 17 | # rev: v1.9.0 18 | # hooks: 19 | # - id: mypy 20 | # exclude: tests/ 21 | -------------------------------------------------------------------------------- /.sourcery.yaml: -------------------------------------------------------------------------------- 1 | # 🪄 This is your project's Sourcery configuration file. 2 | 3 | # You can use it to get Sourcery working in the way you want, such as 4 | # ignoring specific refactorings, skipping directories in your project, 5 | # or writing custom rules. 6 | 7 | # 📚 For a complete reference to this file, see the documentation at 8 | # https://docs.sourcery.ai/Configuration/Project-Settings/ 9 | 10 | # This file was auto-generated by Sourcery on 2023-02-22 at 11:42. 11 | 12 | version: '1' # The schema version of this config file 13 | 14 | ignore: # A list of paths or files which Sourcery will ignore. 15 | - .git 16 | - .venv 17 | - .tox 18 | - dist 19 | - __pycache__ 20 | - src/mlfmu/fmu_build/cppfmu 21 | 22 | rule_settings: 23 | enable: 24 | - default 25 | disable: # A list of rule IDs Sourcery will never suggest. 26 | - inline-immediately-returned-variable 27 | rule_types: 28 | - refactoring 29 | - suggestion 30 | - comment 31 | python_version: '3.10' # A string specifying the lowest Python version your project supports. Sourcery will not suggest refactorings requiring a higher Python version. 32 | 33 | # rules: # A list of custom rules Sourcery will include in its analysis. 34 | # - id: no-print-statements 35 | # description: Do not use print statements in the test directory. 36 | # pattern: print(...) 37 | # language: python 38 | # replacement: 39 | # condition: 40 | # explanation: 41 | # paths: 42 | # include: 43 | # - test 44 | # exclude: 45 | # - conftest.py 46 | # tests: [] 47 | # tags: [] 48 | 49 | # rule_tags: {} # Additional rule tags. 50 | 51 | # metrics: 52 | # quality_threshold: 25.0 53 | 54 | # github: 55 | # labels: [] 56 | # ignore_labels: 57 | # - sourcery-ignore 58 | # request_review: author 59 | # sourcery_branch: sourcery/{base_branch} 60 | 61 | # clone_detection: 62 | # min_lines: 3 63 | # min_duplicates: 2 64 | # identical_clones_only: false 65 | 66 | # proxy: 67 | # url: 68 | # ssl_certs_file: 69 | # no_ssl_verify: false 70 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "ms-python.python", 7 | "ms-python.vscode-pylance", 8 | "visualstudioexptteam.vscodeintellicode", 9 | "njqdev.vscode-python-typehint", 10 | "charliermarsh.ruff", 11 | "sourcery.sourcery", 12 | "njpwerner.autodocstring", 13 | "editorconfig.editorconfig", 14 | "ms-python.mypy-type-checker", 15 | ], 16 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 17 | "unwantedRecommendations": [] 18 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Debug Tests", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "purpose": [ 13 | "debug-test" 14 | ], 15 | "console": "integratedTerminal", 16 | "env": { 17 | "PYTEST_ADDOPTS": "--no-cov" 18 | }, 19 | "autoReload": { 20 | "enable": true 21 | }, 22 | "justMyCode": true, 23 | }, 24 | { 25 | "name": "Python: Current File, cwd = file dir", 26 | "type": "debugpy", 27 | "request": "launch", 28 | "cwd": "${fileDirname}", // working dir = dir where current file is 29 | "program": "${file}", 30 | "console": "integratedTerminal", 31 | "justMyCode": true, 32 | "autoReload": { 33 | "enable": true 34 | }, 35 | }, 36 | { 37 | "name": "Python: Current File, cwd = workspace root folder", 38 | "type": "debugpy", 39 | "request": "launch", 40 | "cwd": "${workspaceFolder}", // working dir = workspace root folder 41 | "program": "${file}", 42 | "console": "integratedTerminal", 43 | "autoReload": { 44 | "enable": true 45 | }, 46 | "justMyCode": true, 47 | }, 48 | { 49 | "name": "mlfmu test_cli", 50 | "type": "debugpy", 51 | "request": "launch", 52 | "cwd": "${workspaceFolder}\\tests", 53 | "program": "${workspaceFolder}\\src\\mlfmu\\cli\\mlfmu.py", 54 | "args": [ 55 | "test_config_file", 56 | "--run", 57 | "-v", 58 | ], 59 | "console": "integratedTerminal", 60 | "autoReload": { 61 | "enable": true 62 | }, 63 | "justMyCode": true, 64 | }, 65 | ] 66 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "Pylance", 3 | "editor.formatOnSave": true, 4 | "notebook.formatOnSave.enabled": true, 5 | "notebook.codeActionsOnSave": { 6 | "notebook.source.fixAll": "explicit", 7 | "notebook.source.organizeImports": "explicit", 8 | }, 9 | "[python]": { 10 | "editor.formatOnSave": true, 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll": "always", 13 | "source.organizeImports": "explicit", 14 | }, 15 | "editor.defaultFormatter": "charliermarsh.ruff", 16 | }, 17 | "autoDocstring.docstringFormat": "numpy", 18 | "python.testing.unittestEnabled": false, 19 | "python.testing.pytestEnabled": true, 20 | "python.analysis.logLevel": "Warning", 21 | "python.analysis.completeFunctionParens": false, 22 | "python.analysis.diagnosticMode": "workspace", 23 | "python.analysis.indexing": true, 24 | "python.analysis.autoImportCompletions": true, 25 | "python.analysis.inlayHints.variableTypes": false, 26 | "python.analysis.inlayHints.functionReturnTypes": false, 27 | "python.analysis.inlayHints.pytestParameters": true, 28 | "python.terminal.executeInFileDir": true, 29 | "python.terminal.activateEnvironment": true, 30 | "python.terminal.activateEnvInCurrentTerminal": false, 31 | "python.analysis.packageIndexDepths": [ 32 | { 33 | "name": "pandas", 34 | "depth": 4, 35 | "includeAllSymbols": true, 36 | }, 37 | { 38 | "name": "matplotlib", 39 | "depth": 4, 40 | "includeAllSymbols": true, 41 | }, 42 | { 43 | "name": "mpl_toolkits", 44 | "depth": 4, 45 | "includeAllSymbols": true, 46 | }, 47 | ], 48 | "mypy-type-checker.importStrategy": "fromEnvironment", 49 | "mypy-type-checker.reportingScope": "workspace", 50 | "mypy-type-checker.preferDaemon": false, 51 | "ruff.configurationPreference": "filesystemFirst", 52 | } -------------------------------------------------------------------------------- /ADVANCED.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | ## Editing generated FMU source 4 | 5 | The command `mlfmu build` will both generate the C++ source code for the mlfmu and compile it automatically. However, it is possible to split this into two steps where it is possible to edit the source code to change the behavior of the resulting FMU. 6 | 7 | ```sh 8 | mlfmu codegen --interface-file Interface.json --model-file model.onnx --fmu-source-path path/to/generated/source 9 | ``` 10 | 11 | This will result in a folder containing the source structured as below. 12 | 13 | ```sh 14 | [FmuName] 15 | ├── resources 16 | │ └── *.onnx 17 | ├── sources 18 | │ ├── fmu.cpp 19 | │ └── model_definitions.h 20 | └── modelDescription.xml 21 | ``` 22 | 23 | Of these generated files, it is only recommended to modify `fmu.cpp`. 24 | In this file one can e.g. modify the `DoStep` function of the generated FMU class. 25 | 26 | ```cpp 27 | class FmuName : public OnnxFmu 28 | { 29 | public: 30 | FmuName(cppfmu::FMIString fmuResourceLocation) 31 | : OnnxFmu(fmuResourceLocation) 32 | { } 33 | 34 | bool DoStep(cppfmu::FMIReal currentCommunicationPoint, cppfmu::FMIReal dt, cppfmu::FMIBoolean newStep, 35 | cppfmu::FMIReal& endOfStep) override 36 | { 37 | // Implement custom behavior here 38 | // ... 39 | 40 | // Call the base class implementation 41 | return OnnxFmu::DoStep(currentCommunicationPoint, dt, newStep, endOfStep); 42 | } 43 | private: 44 | }; 45 | ``` 46 | 47 | After doing the modification to the source code, one can simply run the `compile` command to complete the process. 48 | 49 | ```sh 50 | mlfmu compile --fmu-source-path path/to/generated/source 51 | ``` 52 | 53 | ## Using class 54 | 55 | In addition to the command line interface, one can use the same functionality of the tool through a Python class. 56 | 57 | 1. Import `MlFmuBuilder` and create an instance of it: 58 | 59 | ```python 60 | from mlfmu.api import MlFmuBuilder 61 | from pathlib import Path 62 | 63 | builder = MlFmuBuilder( 64 | ml_model_file = Path("path/to/model.onnx") 65 | interface_file = Path("path/to/interface.json") 66 | ) 67 | ``` 68 | 69 | 2. Call the same commands using the class: 70 | 71 | - Run `build` 72 | 73 | ```python 74 | builder.build() 75 | ``` 76 | 77 | - Run `codegen` and then `compile` 78 | 79 | ```python 80 | builder.generate() 81 | 82 | # Do something ... 83 | 84 | builder.compile() 85 | ``` 86 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the [mlfmu] project will be documented in this file.
4 | The changelog format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | ## Unreleased 7 | 8 | ### Changed 9 | 10 | * Add README page for Examples dir, add generated FMUs for each example (given FMI tools page requirements for Examples), and update the files generated by mlfmu codegen (add for pyspark example) 11 | * Generation of modelDescription.xml file to correctly generate tags for each output with 1-indexed indexes and InitialUnknowns tags 12 | * Generation of modelDescription.xml adds the variables (inputs, parameters and outputs) in the orderer of their valueReference. This is for the indexing to work correctly 13 | 14 | ## [1.0.2] 15 | 16 | ### Changed 17 | 18 | * MLFMU logo is added and conf.py is updated. 19 | * Added checks for Windows vs Linux and fixed compilation for Linux/MacOS, updated README. Tested with Ubuntu 20.04, Ubuntu 22.04 and MacOS 14.4. 20 | 21 | ## [1.0.1] 22 | 23 | ### Changed 24 | 25 | * Update generated docs; cleaning, fix for warnings, add missing pages and info, update authors and maintainers. 26 | * Add where the source code for cppfmu can be found, add third party license. 27 | * Added missing unit tests for the template data generated when building the FMU. 28 | * Unit tests for the modelDescription.xml generation. 29 | * Unit tests for the Interface JSON validation. 30 | * Wind turbine power prediction model example is included in the mlfmu\examples\wind_to_power_pyspark directory. 31 | * README.md : Interface.json is updated to show how to define vectors as inputs. 32 | * Changed from `pip`/`tox` to `uv` as package manager 33 | * README.md : Completely rewrote section "Development Setup", introducing `uv` as package manager. 34 | * Added missing docstrings for py/cpp/h files with help of Github Copilot 35 | * Moved CMake + conan + c++ package files and folders with cpp code inside src folder to be included in package 36 | * Replace pkg_resources with importlib.metadata for getting packages version to work in python 3.12 37 | * Replace deprecated root_validator with model_validator in pydanitc class 38 | * Remove unnecessary hard coded values in utils/builder.py 39 | * Complete cli interface 40 | * Add subparsers to cli argparser to handle several different commands 41 | * Create MlFmuBuilder from args and run code according to command 42 | * Change cli test to match the new cli interface/parser 43 | * Default agent_(input/output)_indexes is [] by default instead of None 44 | * Updated doc by running publish-interface-docs 45 | * Deleted azure files from old azure devops repo 46 | * The generated modelDescription.xml files now also contain with a list of the outputs of the FMU. 47 | * OnnxFmu cpp template class updated to be able to initialize state with FMU Variables 48 | * Add variables to specify which FMU variable that should be used to initialize which state 49 | * Add function to DoStep that initializes the state at the beginning of the first time step. 50 | * model_definitions_template.h 51 | * Add definitions for the the number of states that should be initialized and array of index/value reference pairs to describe how the states should be initialized 52 | * Fmu Component json interface 53 | * Add name, description and start_value to state in the json interface 54 | * States changed from a single InternalState to a list of InternalState 55 | * Fixed typo from 'tunnable' to 'tunable' 56 | * Fixed number of onnx output check to be correct (1-3 and not always raising exception) 57 | * Fix correct fmi causality for parameters 58 | * Fix correct default variability for parameters 59 | * Fix expanding of array variables into one variable per index for inputs and parameters to work as outputs 60 | * Default build target in builder to wind_to_power example 61 | * CMakeList.txt (from old repo) to be able to take an arbitrary path where the fmu files to be compiled are located 62 | * Builder to run commands to compile generated files into an fmu 63 | * Can take and keep track of arbitrary paths 64 | * Builder checks if the files needed to compile the fmu exists before trying to compile the fmu 65 | * Replaced "TODO: Trow Error?" with raising a fitting exception 66 | * replaced black formatter with ruff formatter 67 | * Changed publishing workflow to use OpenID Connect (Trusted Publisher Management) when publishing to PyPI 68 | * Updated copyright statement 69 | * VS Code settings: Turned off automatic venv activation 70 | * Edited descriptions in the examples/wind_to_power/config/interface.json 71 | 72 | ### Added 73 | 74 | * Added BSD 3-Clause License 75 | * Added `mypy` as static type checker (in addition to `pyright`) 76 | * Add .gitattributes to handle line endings, removed eol from .editorconfig 77 | * Add .github/pull_request_template.md for enabling PR templates on Github 78 | * Add conan dependency to pyproject.toml 79 | * Add MlFmuBuilder class to generate code and compile FMU 80 | * Find default paths to files and directories if not given in cli 81 | * Run functions in utils/builder.py according to which command is being run 82 | * Clean up temporary/build files after the process is done 83 | * Added feature to be able to initialize states using previously defined parameters or inputs 84 | * This is done by setting "initializationVariable" = "{variable name}", instead of using the "name" and "start_value" attributes 85 | * Added flag in schema to allow a variable to be reused when initializing states 86 | * Allowed for multiple states to use the same variable for initialization 87 | * .clang-format to consistently format cpp code 88 | * Added code to generate parameters for initialization of state 89 | * Wind to power model example in examples 90 | * Onnx file containing ml model 91 | * Interface json file containing information needed not contained in onnx file 92 | * The generated fmu files resulting from running running builder on the new wind_to_power example 93 | * CMakeLists.txt and conanfile.txt from old repo to configure compiling/building FMU from generated files 94 | 95 | ### Removed 96 | 97 | * VS Code settings: Removed the setting which added the /src folder to PythonPath. This is no longer necessary. `uv` installs the project itself as a package in "editable" mode, which removes the need to manually add /src to the PythonPath environment variable. 98 | 99 | ### GitHub workflows 100 | 101 | * (all workflows): Adapted to use `uv` as package manager 102 | * _test_future.yml : updated Python version to 3.13.0-alpha - 3.13.0 103 | * _test_future.yml : updated name of test job to 'test313' 104 | 105 | ### Dependencies 106 | 107 | * Updated to ruff>=0.6.3 (from ruff==0.2.1) 108 | * Updated to pyright>=1.1.378 (from pyright==1.1.350) 109 | * Updated to sourcery>=1.22 (from sourcery==1.15) 110 | * Updated to pytest>=8.3 (from pytest>=7.4) 111 | * updated to pytest-cov>=5.0 (from pytest-cov>=4.1) 112 | * Updated to Sphinx>=8.0 (from Sphinx>=7.2) 113 | * Updated to sphinx-argparse-cli>=1.17 (from sphinx-argparse-cli>=1.11) 114 | * Updated to myst-parser>=4.0 (from myst-parser>=2.0) 115 | * Updated to furo>=2024.8 (from furo>=2023.9.10) 116 | * Updated to setup-python@v5 (from setup-python@v4) 117 | * Updated to actions-gh-pages@v4 (from actions-gh-pages@v3) 118 | * Updated to upload-artifact@v4 (from upload-artifact@v3) 119 | * Updated to download-artifact@v4 (from download-artifact@v3) 120 | * Updated to dictIO>=0.3.4 (from dictIO>=0.3.1) 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024 [DNV](https://www.dnv.com) [open source](https://github.com/dnv-opensource) 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MLMODEL.md: -------------------------------------------------------------------------------- 1 | # ML Model 2 | 3 | ## Onnx file 4 | 5 | There are some requirements for the onnx file to be compatible with this tool. This is so that the ML FMU tool knows how to connect the inputs and outputs of the onnx file to the FMU, to use it correctly. 6 | 7 | If the model takes a single input and has a single output it is already compatible as long as the input and output is shaped as (1, X) and can be directly connected to the variables of the FMU. 8 | 9 | ### The possible configuration of inputs and outputs of the model 10 | 11 | ```mermaid 12 | graph TD; 13 | subgraph 0["Only direct inputs"] 14 | inputs_3["inputs"]-->Model_3["Model"] 15 | Model_3["Model"]-->outputs_3["outputs"] 16 | end 17 | 18 | subgraph 3["All input types"] 19 | inputs_0["inputs"]-->Model_0["Model"] 20 | state_0["state"]-->Model_0["Model"] 21 | time_0["time"]-->Model_0["Model"] 22 | Model_0["Model"]-->outputs_0["outputs"] 23 | end 24 | 25 | subgraph 1["Inputs and States"] 26 | inputs_1["inputs"]-->Model_1["Model"] 27 | state_1["state"]-->Model_1["Model"] 28 | Model_1["Model"]-->outputs_1["outputs"] 29 | end 30 | 31 | subgraph 2["Inputs and time input"] 32 | inputs_2["inputs"]-->Model_2["Model"] 33 | time_2["time"]-->Model_2["Model"] 34 | Model_2["Model"]-->outputs_2["outputs"] 35 | end 36 | 37 | ``` 38 | 39 | ## Usage in FMU 40 | 41 | ```mermaid 42 | graph TD; 43 | 44 | fmu_inputs["FMU inputs"]-->inputs 45 | fmu_parameters["FMU parameters"]-->inputs 46 | inputs --> Model 47 | 48 | previous_outputs["previous outputs"] --> state 49 | state --> Model 50 | 51 | simulator-->time 52 | time-->Model 53 | 54 | Model-->outputs 55 | outputs-->fmu_outputs["FMU outputs"] 56 | 57 | subgraph ONNX 58 | inputs 59 | state 60 | time 61 | outputs 62 | Model 63 | end 64 | 65 | ``` 66 | 67 | [//]: # (## Tips and tricks - TODO) 68 | 69 | ## Examples 70 | 71 | ### Model that only uses "pure" inputs and outputs 72 | 73 | ```python 74 | class Model(): 75 | num_inputs: int = 2 76 | num_outputs: int = 2 77 | 78 | ... 79 | 80 | def call(self, all_inputs): 81 | inputs, *_ = all_inputs 82 | 83 | # Do something with the inputs 84 | outputs = self.layers(inputs) 85 | 86 | return outputs 87 | ``` 88 | 89 | ### Model using every input type 90 | 91 | Say we have trained an ML model to predict the derivatives of outputs from some data as shown below: 92 | 93 | ```python 94 | class DerivativePredictor(): 95 | def call(self, all_inputs): 96 | prev_outputs, prev_inputs, curr_inputs, time = all_inputs 97 | 98 | # Do some ML stuff 99 | derivative = ... 100 | 101 | return derivative 102 | ``` 103 | 104 | However, the FMU we want to create cannot use the same inputs and outputs as the trained ML model. 105 | 106 | We do not want to have previous inputs and outputs as inputs to the FMU. Instead we want it to remember the previous inputs and outputs itself. To do this we need to use the state inside the generated MLFMU using this tool. 107 | 108 | We also do not want the FMU to output the derivative, but instead use the derivative to integrate the outputs. This makes it possible for the outputs themselves to be the output of the FMU. 109 | 110 | To do this we need to make a wrapper around the trained ML model so that it is compatible with the tool and what we want the generated FMU to do. 111 | 112 | ``` python 113 | class ModelWrapper(): 114 | num_inputs: int = 2 115 | num_outputs: int = 2 116 | derivative_predictor: DerivativePredictor = None 117 | 118 | def call(self, all_inputs): 119 | # Unpack inputs 120 | inputs, state, time = all_inputs 121 | 122 | # Unpack state into what is known to be saved in state 123 | # What is contained in state is decided by what is output from this function and the state settings in interface.json 124 | previous_outputs = state[:,:self.num_outputs] 125 | previous_inputs = state[:,self.num_outputs:] 126 | 127 | # Unpack time input 128 | current_time = all_inputs[:,:1] 129 | dt = all_inputs[:,1:2] 130 | 131 | # Predict something (e.g. a derivative) from the unpacked input data 132 | outputs_derivative = self.derivative_predictor([previous_outputs, previous_inputs, inputs, current_time]) 133 | 134 | # Do other calculation to get data that needs to be output from the FMU 135 | outputs = previous_outputs + dt * output_derivative 136 | 137 | # Format outputs from the model to contain everything that needs to output from the FMU and/or saved as state 138 | # Saving the state is easier if outputs are in the same order as they are expected to be saved in state 139 | all_outputs = self.concat([outputs, inputs]) 140 | 141 | return all_outputs 142 | ``` 143 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -a -E 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/ADVANCED.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../ADVANCED.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/source/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CHANGELOG.md 2 | ``` -------------------------------------------------------------------------------- /docs/source/LICENSE.md: -------------------------------------------------------------------------------- 1 | # LICENSE 2 | ```{include} ../../LICENSE 3 | ``` -------------------------------------------------------------------------------- /docs/source/LICENSE_THIRD_PARTY.md: -------------------------------------------------------------------------------- 1 | # LICENSE THIRD PARTY 2 | 3 | ```{include} ../../LICENSE_THIRD_PARTY 4 | ``` 5 | -------------------------------------------------------------------------------- /docs/source/MLMODEL.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../MLMODEL.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/source/README.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../README.md 2 | ``` -------------------------------------------------------------------------------- /docs/source/STYLEGUIDE.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../STYLEGUIDE.md 2 | ``` -------------------------------------------------------------------------------- /docs/source/_static/mlfmu_logo_v1.svg: -------------------------------------------------------------------------------- 1 | MLFMU -------------------------------------------------------------------------------- /docs/source/_templates/custom-class.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :show-inheritance: 8 | 9 | 10 | {% block methods %} 11 | .. automethod:: __init__ 12 | {% if methods %} 13 | .. rubric:: {{ _('Methods') }} 14 | .. autosummary:: 15 | {% for item in methods %} 16 | ~{{ name }}.{{ item }} 17 | {%- endfor %} 18 | {% endif %} 19 | {% endblock %} 20 | 21 | 22 | {% block attributes %} 23 | {% if attributes %} 24 | .. rubric:: {{ _('Attributes') }} 25 | .. autosummary:: 26 | {% for item in attributes %} 27 | ~{{ name }}.{{ item }} 28 | {%- endfor %} 29 | {% endif %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /docs/source/_templates/custom-module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. automodule:: {{ fullname }} 4 | :members: 5 | {%- if classes %} 6 | :exclude-members: {% for item in classes %}{{ item }}{{','}}{%- endfor %} 7 | {% endif %} 8 | 9 | 10 | {% block attributes %} 11 | {%- if attributes %} 12 | .. rubric:: {{ _('Module Attributes') }} 13 | .. autosummary:: 14 | {% for item in attributes %} 15 | {{ item }} 16 | {%- endfor %} 17 | {% endif %} 18 | {%- endblock %} 19 | 20 | 21 | {%- block functions %} 22 | {%- if functions %} 23 | .. rubric:: {{ _('Functions') }} 24 | .. autosummary:: 25 | {% for item in functions %} 26 | {{ item }} 27 | {%- endfor %} 28 | {% endif %} 29 | {%- endblock %} 30 | 31 | 32 | {%- block classes %} 33 | {%- if classes %} 34 | .. rubric:: {{ _('Classes') }} 35 | .. autosummary:: 36 | :toctree: 37 | :template: custom-class.rst 38 | {% for item in classes %} 39 | {{ item }} 40 | {%- endfor %} 41 | {% endif %} 42 | {%- endblock %} 43 | 44 | 45 | {%- block exceptions %} 46 | {%- if exceptions %} 47 | .. rubric:: {{ _('Exceptions') }} 48 | .. autosummary:: 49 | {% for item in exceptions %} 50 | {{ item }} 51 | {%- endfor %} 52 | {% endif %} 53 | {%- endblock %} 54 | 55 | 56 | {%- block modules %} 57 | {%- if modules %} 58 | .. rubric:: Modules 59 | .. autosummary:: 60 | :toctree: 61 | :template: custom-module.rst 62 | :recursive: 63 | {% for item in modules %} 64 | {{ item }} 65 | {%- endfor %} 66 | {% endif %} 67 | {%- endblock %} 68 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. autosummary:: 5 | :toctree: _autosummary 6 | :template: custom-module.rst 7 | :recursive: 8 | 9 | mlfmu.api 10 | mlfmu.types 11 | mlfmu.utils 12 | -------------------------------------------------------------------------------- /docs/source/cli.mlfmu.rst: -------------------------------------------------------------------------------- 1 | .. sphinx_argparse_cli:: 2 | :module: mlfmu.cli.mlfmu 3 | :func: _argparser 4 | -------------------------------------------------------------------------------- /docs/source/cli.rst: -------------------------------------------------------------------------------- 1 | CLI Documentation 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | cli.mlfmu 8 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa 2 | # mypy: ignore-errors 3 | 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # For the full list of built-in configuration values, see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import sys 16 | from pathlib import Path 17 | 18 | sys.path.insert(0, str(Path("../../src").absolute())) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 23 | 24 | project = "mlfmu" 25 | copyright = "2024, DNV AS. All rights reserved." 26 | author = "Kristoffer Skare, Jorge Luis Mendez, Stephanie Kemna, Melih Akdag" 27 | 28 | # The full version, including alpha/beta/rc tags 29 | release = "1.0.2" 30 | 31 | # -- General configuration --------------------------------------------------- 32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 33 | 34 | extensions = [ 35 | "myst_parser", 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.napoleon", 38 | "sphinx_argparse_cli", 39 | "sphinx.ext.mathjax", 40 | "sphinx.ext.autosummary", 41 | "sphinx.ext.todo", 42 | "sphinxcontrib.mermaid", 43 | ] 44 | 45 | # The file extensions of source files. 46 | source_suffix = { 47 | ".rst": "restructuredtext", 48 | ".md": "markdown", 49 | } 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ["_templates"] 53 | 54 | # List of patterns, relative to source directory, that match files and 55 | # directories to ignore when looking for source files. 56 | # This pattern also affects html_static_path and html_extra_path. 57 | exclude_patterns = [] 58 | 59 | 60 | # -- Options for HTML output ------------------------------------------------- 61 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 62 | 63 | html_title = f"mlfmu {release}" 64 | html_theme = "furo" 65 | html_static_path = ["_static"] 66 | html_logo = "_static/mlfmu_logo_v1.svg" 67 | autodoc_default_options = { 68 | "member-order": "groupwise", 69 | "undoc-members": True, 70 | "exclude-members": "__weakref__", 71 | } 72 | autodoc_preserve_defaults = True 73 | 74 | myst_heading_anchors = 3 75 | 76 | # add markdown mermaid support 77 | myst_fence_as_directive = ["mermaid"] 78 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. mlfmu documentation master file, created by 2 | sphinx-quickstart on Wed Jul 6 21:16:21 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | mlfmu: Example Python Package 7 | ========================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 3 11 | :caption: Contents: 12 | 13 | README 14 | MLMODEL 15 | ADVANCED 16 | cli 17 | api 18 | CHANGELOG 19 | STYLEGUIDE 20 | LICENSE 21 | LICENSE_THIRD_PARTY 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This folder contains a couple of example files to use for testing the MLFMU tools and some FMUs that have been generated using this tool. 4 | Each folder is set up to contain at least: 5 | 6 | * `config`: the `.onnx` and `interface.json` files, which you can use to test the `mlfmu` commands with 7 | * `generated_fmu`: binary FMU files, to serve as examples, as generated when running `mlfmu build` for the config files 8 | * ``: files for the FMU as generated when running `mlfmu codegen` 9 | 10 | The FMUs in this folder have been validated using [FMU_check]. 11 | 12 | For further documentation of the `mlfmu` tool, see [README.md](../README.md) or the [docs] on GitHub pages. 13 | 14 | 15 | [FMU_check]: https://fmu-check.herokuapp.com/ 16 | [docs]: https://dnv-opensource.github.io/mlfmu/ 17 | -------------------------------------------------------------------------------- /examples/wind_generator/WindGenerator/modelDescription.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/wind_generator/WindGenerator/resources/example.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_generator/WindGenerator/resources/example.onnx -------------------------------------------------------------------------------- /examples/wind_generator/WindGenerator/sources/fmu.cpp: -------------------------------------------------------------------------------- 1 | #include "fmu-uuid.h" 2 | #include "model_definitions.h" 3 | 4 | #include 5 | #include 6 | 7 | 8 | /** 9 | * \class WindGenerator 10 | * \brief A class representing an WindGenerator FMU. 11 | * 12 | * This class is derived from the OnnxFmu class and provides functionality specific to the WindGenerator FMU. 13 | */ 14 | class WindGenerator : public OnnxFmu { 15 | public : 16 | /** 17 | * \brief Constructs a new WindGenerator object. 18 | * 19 | * \param fmuResourceLocation The location of the resources of the FMU. 20 | */ 21 | WindGenerator(cppfmu::FMIString fmuResourceLocation) : OnnxFmu(fmuResourceLocation) {} 22 | 23 | private : 24 | // Add private members and functions here 25 | }; 26 | 27 | /** 28 | * \brief Instantiate a `slave` instance of the FMU. 29 | * 30 | * \param instanceName The name of the FMU instance. 31 | * \param fmuGUID The GUID of the FMU. 32 | * \param fmuResourceLocation The location of the FMU resource. 33 | * \param mimeType The MIME type of the FMU. 34 | * \param timeout The timeout value for the instantiation process. 35 | * \param visible Flag indicating whether the FMU should be visible. 36 | * \param interactive Flag indicating whether the FMU should be interactive. 37 | * \param memory The memory to be used for the FMU instance. 38 | * \param logger The logger to be used for logging messages. 39 | * \returns A unique pointer to the instantiated slave instance. 40 | * 41 | * \throws std::runtime_error if the FMU GUID does not match. 42 | */ 43 | cppfmu::UniquePtr CppfmuInstantiateSlave( 44 | cppfmu::FMIString /*instanceName*/, cppfmu::FMIString fmuGUID, cppfmu::FMIString fmuResourceLocation, 45 | cppfmu::FMIString /*mimeType*/, cppfmu::FMIReal /*timeout*/, cppfmu::FMIBoolean /*visible*/, 46 | cppfmu::FMIBoolean /*interactive*/, cppfmu::Memory memory, cppfmu::Logger /*logger*/) 47 | { 48 | if (std::strcmp(fmuGUID, FMU_UUID) != 0) { 49 | throw std::runtime_error("FMU GUID mismatch"); 50 | } 51 | return cppfmu::AllocateUnique(memory, fmuResourceLocation); 52 | } 53 | -------------------------------------------------------------------------------- /examples/wind_generator/WindGenerator/sources/model_definitions.h: -------------------------------------------------------------------------------- 1 | #define NUM_FMU_VARIABLES 6 2 | 3 | #define NUM_ONNX_INPUTS 2 4 | #define NUM_ONNX_FMU_INPUTS 2 5 | #define NUM_ONNX_OUTPUTS 130 6 | #define NUM_ONNX_FMU_OUTPUTS 2 7 | #define NUM_ONNX_STATES 130 8 | #define NUM_ONNX_STATES_OUTPUTS 130 9 | #define NUM_ONNX_STATE_INIT 2 10 | 11 | #define ONNX_INPUT_VALUE_REFERENCES 0, 0, 1, 1 12 | #define ONNX_OUTPUT_VALUE_REFERENCES 0, 2, 1, 3 13 | #define ONNX_STATE_OUTPUT_INDEXES 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129 14 | #define ONNX_STATE_INIT_VALUE_REFERENCES 0, 4, 1, 5 15 | 16 | #define ONNX_USE_TIME_INPUT true 17 | 18 | #define ONNX_INPUT_NAME "inputs" 19 | #define ONNX_OUTPUT_NAME "output_1" 20 | #define ONNX_STATE_NAME "state" 21 | #define ONNX_TIME_INPUT_NAME "time" 22 | #define ONNX_FILENAME L"example.onnx" -------------------------------------------------------------------------------- /examples/wind_generator/config/example.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_generator/config/example.onnx -------------------------------------------------------------------------------- /examples/wind_generator/config/interface.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WindGenerator", 3 | "description": "A Machine Learning based FMU that outputs a synthetic time series of wind.", 4 | "usesTime": true, 5 | "inputs": [ 6 | { 7 | "name": "speedNoise", 8 | "description": "Noise to be added to the change in wind speed", 9 | "agentInputIndexes": [ 10 | "0" 11 | ] 12 | }, 13 | { 14 | "name": "directionNoise", 15 | "description": "Noise to be added to the change in wind direction", 16 | "agentInputIndexes": [ 17 | "1" 18 | ] 19 | } 20 | ], 21 | "parameters": [], 22 | "outputs": [ 23 | { 24 | "name": "windSpeed", 25 | "description": "The speed of the wind", 26 | "agentOutputIndexes": [ 27 | "0" 28 | ] 29 | }, 30 | { 31 | "name": "windDirection", 32 | "description": "The direction of the wind", 33 | "agentOutputIndexes": [ 34 | "1" 35 | ] 36 | } 37 | ], 38 | "states": [ 39 | { 40 | "name": "initialWindSpeed", 41 | "startValue": 10.0, 42 | "agentOutputIndexes": [ 43 | "0" 44 | ] 45 | }, 46 | { 47 | "name": "initialWindDirection", 48 | "startValue": 180.0, 49 | "agentOutputIndexes": [ 50 | "1" 51 | ] 52 | }, 53 | { 54 | "agentOutputIndexes": [ 55 | "2:130" 56 | ] 57 | } 58 | ] 59 | } -------------------------------------------------------------------------------- /examples/wind_generator/generated_fmu/WindGenerator.fmu: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_generator/generated_fmu/WindGenerator.fmu -------------------------------------------------------------------------------- /examples/wind_to_power/WindToPower/modelDescription.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/wind_to_power/WindToPower/resources/example.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power/WindToPower/resources/example.onnx -------------------------------------------------------------------------------- /examples/wind_to_power/WindToPower/sources/fmu.cpp: -------------------------------------------------------------------------------- 1 | #include "fmu-uuid.h" 2 | #include "model_definitions.h" 3 | 4 | #include 5 | #include 6 | 7 | 8 | /** 9 | * \class WindToPower 10 | * \brief A class representing an WindToPower FMU. 11 | * 12 | * This class is derived from the OnnxFmu class and provides functionality specific to the WindToPower FMU. 13 | */ 14 | class WindToPower : public OnnxFmu { 15 | public : 16 | /** 17 | * \brief Constructs a new WindToPower object. 18 | * 19 | * \param fmuResourceLocation The location of the resources of the FMU. 20 | */ 21 | WindToPower(cppfmu::FMIString fmuResourceLocation) : OnnxFmu(fmuResourceLocation) {} 22 | 23 | private : 24 | // Add private members and functions here 25 | }; 26 | 27 | /** 28 | * \brief Instantiate a `slave` instance of the FMU. 29 | * 30 | * \param instanceName The name of the FMU instance. 31 | * \param fmuGUID The GUID of the FMU. 32 | * \param fmuResourceLocation The location of the FMU resource. 33 | * \param mimeType The MIME type of the FMU. 34 | * \param timeout The timeout value for the instantiation process. 35 | * \param visible Flag indicating whether the FMU should be visible. 36 | * \param interactive Flag indicating whether the FMU should be interactive. 37 | * \param memory The memory to be used for the FMU instance. 38 | * \param logger The logger to be used for logging messages. 39 | * \returns A unique pointer to the instantiated slave instance. 40 | * 41 | * \throws std::runtime_error if the FMU GUID does not match. 42 | */ 43 | cppfmu::UniquePtr CppfmuInstantiateSlave( 44 | cppfmu::FMIString /*instanceName*/, cppfmu::FMIString fmuGUID, cppfmu::FMIString fmuResourceLocation, 45 | cppfmu::FMIString /*mimeType*/, cppfmu::FMIReal /*timeout*/, cppfmu::FMIBoolean /*visible*/, 46 | cppfmu::FMIBoolean /*interactive*/, cppfmu::Memory memory, cppfmu::Logger /*logger*/) 47 | { 48 | if (std::strcmp(fmuGUID, FMU_UUID) != 0) { 49 | throw std::runtime_error("FMU GUID mismatch"); 50 | } 51 | return cppfmu::AllocateUnique(memory, fmuResourceLocation); 52 | } 53 | -------------------------------------------------------------------------------- /examples/wind_to_power/WindToPower/sources/model_definitions.h: -------------------------------------------------------------------------------- 1 | #define NUM_FMU_VARIABLES 3 2 | 3 | #define NUM_ONNX_INPUTS 2 4 | #define NUM_ONNX_FMU_INPUTS 2 5 | #define NUM_ONNX_OUTPUTS 1 6 | #define NUM_ONNX_FMU_OUTPUTS 1 7 | #define NUM_ONNX_STATES 0 8 | #define NUM_ONNX_STATES_OUTPUTS 0 9 | #define NUM_ONNX_STATE_INIT 0 10 | 11 | #define ONNX_INPUT_VALUE_REFERENCES 0, 0, 1, 1 12 | #define ONNX_OUTPUT_VALUE_REFERENCES 0, 2 13 | #define ONNX_STATE_OUTPUT_INDEXES 14 | #define ONNX_STATE_INIT_VALUE_REFERENCES 15 | 16 | #define ONNX_USE_TIME_INPUT false 17 | 18 | #define ONNX_INPUT_NAME "args_0" 19 | #define ONNX_OUTPUT_NAME "output_1" 20 | #define ONNX_STATE_NAME "" 21 | #define ONNX_TIME_INPUT_NAME "" 22 | #define ONNX_FILENAME L"example.onnx" -------------------------------------------------------------------------------- /examples/wind_to_power/config/example.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power/config/example.onnx -------------------------------------------------------------------------------- /examples/wind_to_power/config/interface.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WindToPower", 3 | "description": "A Machine Learning based FMU that outputs the estimated power output of a wind turbine given the wind speed and direction.", 4 | "inputs": [ 5 | { 6 | "name": "windSpeed", 7 | "description": "The speed of the wind", 8 | "agentInputIndexes": [ 9 | "0" 10 | ] 11 | }, 12 | { 13 | "name": "windDirection", 14 | "description": "The direction of the wind", 15 | "agentInputIndexes": [ 16 | "1" 17 | ] 18 | } 19 | ], 20 | "parameters": [], 21 | "outputs": [ 22 | { 23 | "name": "power", 24 | "description": "The estimated wind turbine power output", 25 | "agentOutputIndexes": [ 26 | "0" 27 | ] 28 | } 29 | ], 30 | "states": [] 31 | } -------------------------------------------------------------------------------- /examples/wind_to_power/generated_fmu/WindToPower.fmu: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power/generated_fmu/WindToPower.fmu -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/MLFMU_RF_Regressor/modelDescription.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/MLFMU_RF_Regressor/resources/rf_model.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power_pyspark/MLFMU_RF_Regressor/resources/rf_model.onnx -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/MLFMU_RF_Regressor/sources/fmu.cpp: -------------------------------------------------------------------------------- 1 | #include "fmu-uuid.h" 2 | #include "model_definitions.h" 3 | 4 | #include 5 | #include 6 | 7 | 8 | /** 9 | * \class MLFMU_RF_Regressor 10 | * \brief A class representing an MLFMU_RF_Regressor FMU. 11 | * 12 | * This class is derived from the OnnxFmu class and provides functionality specific to the MLFMU_RF_Regressor FMU. 13 | */ 14 | class MLFMU_RF_Regressor : public OnnxFmu { 15 | public : 16 | /** 17 | * \brief Constructs a new MLFMU_RF_Regressor object. 18 | * 19 | * \param fmuResourceLocation The location of the resources of the FMU. 20 | */ 21 | MLFMU_RF_Regressor(cppfmu::FMIString fmuResourceLocation) : OnnxFmu(fmuResourceLocation) {} 22 | 23 | private : 24 | // Add private members and functions here 25 | }; 26 | 27 | /** 28 | * \brief Instantiate a `slave` instance of the FMU. 29 | * 30 | * \param instanceName The name of the FMU instance. 31 | * \param fmuGUID The GUID of the FMU. 32 | * \param fmuResourceLocation The location of the FMU resource. 33 | * \param mimeType The MIME type of the FMU. 34 | * \param timeout The timeout value for the instantiation process. 35 | * \param visible Flag indicating whether the FMU should be visible. 36 | * \param interactive Flag indicating whether the FMU should be interactive. 37 | * \param memory The memory to be used for the FMU instance. 38 | * \param logger The logger to be used for logging messages. 39 | * \returns A unique pointer to the instantiated slave instance. 40 | * 41 | * \throws std::runtime_error if the FMU GUID does not match. 42 | */ 43 | cppfmu::UniquePtr CppfmuInstantiateSlave( 44 | cppfmu::FMIString /*instanceName*/, cppfmu::FMIString fmuGUID, cppfmu::FMIString fmuResourceLocation, 45 | cppfmu::FMIString /*mimeType*/, cppfmu::FMIReal /*timeout*/, cppfmu::FMIBoolean /*visible*/, 46 | cppfmu::FMIBoolean /*interactive*/, cppfmu::Memory memory, cppfmu::Logger /*logger*/) 47 | { 48 | if (std::strcmp(fmuGUID, FMU_UUID) != 0) { 49 | throw std::runtime_error("FMU GUID mismatch"); 50 | } 51 | return cppfmu::AllocateUnique(memory, fmuResourceLocation); 52 | } 53 | -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/MLFMU_RF_Regressor/sources/model_definitions.h: -------------------------------------------------------------------------------- 1 | #define NUM_FMU_VARIABLES 5 2 | 3 | #define NUM_ONNX_INPUTS 4 4 | #define NUM_ONNX_FMU_INPUTS 4 5 | #define NUM_ONNX_OUTPUTS 1 6 | #define NUM_ONNX_FMU_OUTPUTS 1 7 | #define NUM_ONNX_STATES 0 8 | #define NUM_ONNX_STATES_OUTPUTS 0 9 | #define NUM_ONNX_STATE_INIT 0 10 | 11 | #define ONNX_INPUT_VALUE_REFERENCES 0, 0, 1, 1, 2, 2, 3, 3 12 | #define ONNX_OUTPUT_VALUE_REFERENCES 0, 4 13 | #define ONNX_STATE_OUTPUT_INDEXES 14 | #define ONNX_STATE_INIT_VALUE_REFERENCES 15 | 16 | #define ONNX_USE_TIME_INPUT false 17 | 18 | #define ONNX_INPUT_NAME "features" 19 | #define ONNX_OUTPUT_NAME "prediction" 20 | #define ONNX_STATE_NAME "" 21 | #define ONNX_TIME_INPUT_NAME "" 22 | #define ONNX_FILENAME L"rf_model.onnx" -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/config/interface.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MLFMU_RF_Regressor", 3 | "description": "A Random Forest Regressor based FMU", 4 | "usesTime": false, 5 | "inputs": [ 6 | { 7 | "name": "features", 8 | "description": "Inputs with four features: month, hour, wind_speed, wind_direction", 9 | "agentInputIndexes": ["0:4"], 10 | "type": "real", 11 | "isArray": true, 12 | "length": 4 13 | } 14 | ], 15 | "parameters": [], 16 | "outputs": [ 17 | { 18 | "name": "prediction", 19 | "description": "The prediction generated by ML model", 20 | "agentOutputIndexes": ["0"] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/config/rf_model.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power_pyspark/config/rf_model.onnx -------------------------------------------------------------------------------- /examples/wind_to_power_pyspark/generated_fmu/MLFMU_RF_Regressor.fmu: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/examples/wind_to_power_pyspark/generated_fmu/MLFMU_RF_Regressor.fmu -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling", 4 | ] 5 | build-backend = "hatchling.build" 6 | 7 | [tool.hatch.build.targets.sdist] 8 | only-include = [ 9 | "src/mlfmu", 10 | "tests", 11 | ".coveragerc", 12 | ".editorconfig", 13 | "pytest.ini", 14 | "ruff.toml", 15 | "uv.lock", 16 | ] 17 | 18 | [tool.hatch.build.targets.wheel] 19 | packages = [ 20 | "src/mlfmu", 21 | ] 22 | 23 | [project] 24 | name = "mlfmu" 25 | version = "1.0.2" 26 | description = "Export ML models represented as ONNX files to Functional-Mockup-Units (FMU)" 27 | readme = "README.md" 28 | requires-python = ">= 3.10" 29 | license = { file = "LICENSE" } 30 | authors = [ 31 | { name = "Jorge Luis Mendez", email = "jorge.luis.mendez@dnv.com" }, 32 | { name = "Kristoffer Skare", email = "kristoffer.skare@dnv.com" }, 33 | ] 34 | maintainers = [ 35 | { name = "Kristoffer Skare", email = "kristoffer.skare@dnv.com" }, 36 | { name = "Jorge Luis Mendez", email = "jorge.luis.mendez@dnv.com" }, 37 | { name = "Hee Jong Park", email = "hee.jong.park@dnv.com" }, 38 | { name = "Claas Rostock", email = "claas.rostock@dnv.com" }, 39 | ] 40 | keywords = [ 41 | ] 42 | classifiers = [ 43 | "Development Status :: 3 - Alpha", 44 | "License :: OSI Approved :: BSD License", 45 | "Programming Language :: Python :: 3.10", 46 | "Programming Language :: Python :: 3.11", 47 | "Programming Language :: Python :: 3.12", 48 | "Operating System :: Microsoft :: Windows", 49 | "Operating System :: POSIX :: Linux", 50 | # "Operating System :: MacOS", 51 | "Environment :: Console", 52 | "Intended Audience :: Developers", 53 | "Intended Audience :: Science/Research", 54 | "Topic :: Scientific/Engineering", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | ] 57 | dependencies = [ 58 | "dictIO>=0.3.4", 59 | "pydantic>=2.6", 60 | "json-schema-for-humans>=0.4.7", 61 | "onnxruntime==1.18.1", 62 | "conan>=2.1", 63 | ] 64 | 65 | [project.urls] 66 | Homepage = "https://github.com/dnv-opensource/mlfmu" 67 | Documentation = "https://dnv-opensource.github.io/mlfmu/README.html" 68 | Repository = "https://github.com/dnv-opensource/mlfmu.git" 69 | Issues = "https://github.com/dnv-opensource/mlfmu/issues" 70 | Changelog = "https://github.com/dnv-opensource/mlfmu/blob/main/CHANGELOG.md" 71 | 72 | 73 | [tool.uv] 74 | dev-dependencies = [ 75 | "pytest>=8.3", 76 | "pytest-cov>=5.0", 77 | "ruff>=0.6.3", 78 | "pyright>=1.1.378", 79 | "mypy>=1.11.1", 80 | "sourcery>=1.22", 81 | "pre-commit>=3.8", 82 | "Sphinx>=8.0", 83 | "sphinx-argparse-cli>=1.17", 84 | "sphinx-autodoc-typehints>=2.2", 85 | "sphinxcontrib-mermaid>=1.0.0", 86 | "myst-parser>=4.0", 87 | "furo>=2024.8", 88 | ] 89 | native-tls = true 90 | 91 | 92 | [project.scripts] 93 | publish-interface-docs = "mlfmu.cli.publish_docs:main" 94 | mlfmu = "mlfmu.cli.mlfmu:main" 95 | 96 | 97 | [tool.mypy] 98 | plugins = [ 99 | "numpy.typing.mypy_plugin", 100 | ] 101 | mypy_path = "stubs" 102 | files = [ 103 | "src", 104 | "tests", 105 | ] 106 | exclude = [ 107 | "^src/mlfmu/fmu_build/cppfmu/", 108 | ] 109 | check_untyped_defs = true 110 | disable_error_code = [ 111 | "misc", 112 | "import-untyped", 113 | ] 114 | 115 | 116 | [tool.pyright] 117 | stubPath = "stubs" 118 | include = [ 119 | "src", 120 | "tests", 121 | "examples", 122 | ] 123 | exclude = [ 124 | "src/mlfmu/fmu_build/cppfmu", 125 | ] 126 | 127 | typeCheckingMode = "basic" 128 | useLibraryCodeForTypes = true 129 | reportMissingParameterType = "error" 130 | reportUnknownParameterType = "warning" 131 | reportUnknownMemberType = "warning" # consider to set to `false` if you work a lot with matplotlib and pandas, which are both not properly typed and known to trigger this warning 132 | reportMissingTypeArgument = "error" 133 | reportPropertyTypeMismatch = "error" 134 | reportFunctionMemberAccess = "warning" 135 | reportPrivateUsage = "warning" 136 | reportTypeCommentUsage = "warning" 137 | reportIncompatibleMethodOverride = "warning" 138 | reportIncompatibleVariableOverride = "error" 139 | reportInconsistentConstructor = "error" 140 | reportOverlappingOverload = "warning" 141 | reportUninitializedInstanceVariable = "warning" 142 | reportCallInDefaultInitializer = "warning" 143 | reportUnnecessaryIsInstance = "information" 144 | reportUnnecessaryCast = "warning" 145 | reportUnnecessaryComparison = "warning" 146 | reportUnnecessaryContains = "warning" 147 | reportUnusedCallResult = "warning" 148 | reportUnusedExpression = "warning" 149 | reportMatchNotExhaustive = "warning" 150 | reportShadowedImports = "warning" 151 | reportUntypedFunctionDecorator = "warning" 152 | reportUntypedClassDecorator = "warning" 153 | reportUntypedBaseClass = "error" 154 | reportUntypedNamedTuple = "warning" 155 | reportUnnecessaryTypeIgnoreComment = "information" 156 | # Activate the following rules only locally and temporary, i.e. for a QA session. 157 | # (For server side CI they are considered too strict.) 158 | # reportMissingTypeStubs = true 159 | # reportConstantRedefinition = "warning" 160 | # reportImportCycles = "warning" 161 | # reportImplicitStringConcatenation = "warning" 162 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = 3 | tests 4 | addopts = --strict-markers --verbose 5 | xfail_strict = True 6 | -------------------------------------------------------------------------------- /qa.bat: -------------------------------------------------------------------------------- 1 | uv run ruff format 2 | uv run ruff check 3 | uv run pyright 4 | uv run mypy 5 | uv run sourcery review . 6 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = [ 2 | ".git", 3 | ".venv", 4 | "dist", 5 | "*cache", 6 | "**/__pycache__", 7 | "src/mlfmu/fmu_build/cppfmu", 8 | ] 9 | src = [ 10 | "src", 11 | ] 12 | line-length = 120 13 | target-version = "py310" 14 | 15 | [lint] 16 | # Settings for Ruff linter (invoked with `uv run ruff check`). 17 | # Start by including _all_ Ruff lint rules, then ignore selected rules as needed 18 | # https://docs.astral.sh/ruff/rules/ 19 | select = [ 20 | "ALL", 21 | ] 22 | ignore = [ 23 | # Ruff lint rules temporarily ignored, but which should be reactivated and resolved in the future. 24 | "D100", # Missing docstring in public module 25 | "D101", # Missing docstring in public class 26 | "D102", # Missing docstring in public method 27 | "D103", # Missing docstring in public function 28 | "D104", # Missing docstring in public package 29 | "D105", # Missing docstring in magic method 30 | "D107", # Missing docstring in __init__ 31 | # Ruff lint rules considered as too strict and hence ignored 32 | "ANN101", # Missing type annotation for `self` argument in instance methods (NOTE: also listed as deprecated by Ruff) 33 | "ANN102", # Missing type annotation for `cls` argument in class methods (NOTE: also listed as deprecated by Ruff) 34 | "FIX002", # Line contains TODO, consider resolving the issue 35 | "TD003", # Missing issue link on the line following a TODO 36 | "S101", # Use of assert detected 37 | "RET504", # Unnecessary assignment to `result` before `return` statement 38 | "EM101", # Exception must not use a string literal, assign to variable first 39 | "EM102", # Exception must not use an f-string literal, assign to variable first 40 | "TRY003", # Avoid specifying long messages outside the exception class 41 | "PLR1711", # Useless `return` statement at end of function 42 | "G00", # Logging statement uses string formatting ('G00' covers all rules flagging string formatting in logging, e.g. G001, G002, etc.) 43 | 44 | # Ruff lint rules recommended to keep enabled, 45 | # but which are typical candidates you might have a need to ignore, 46 | # especially in the beginning or when refactoring an existing codebase, 47 | # to avoid too many Ruff errors at once. 48 | # -> Listed here for easy access and reference. 49 | # (uncomment to ignore) 50 | # "N803", # Argument name should be lowercase (NOTE: ignore to allow capital arguments (e.g X) in scientific code) 51 | # "N806", # Variable in function should be lowercase (NOTE: ignore to allow capital variables (e.g X) in scientific code) 52 | # "TCH002", # Move third-party import into a type-checking block 53 | # "TCH003", # Move standard library import into a type-checking block 54 | 55 | # Ruff lint rules known to be in conflict with Ruff formatter. 56 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 57 | "W191", # Tab-indentation (in conflict with Ruff formatter) 58 | "E111", # Indentation with invalid multiple (in conflict with Ruff formatter) 59 | "E114", # Indentation with invalid multiple comment (in conflict with Ruff formatter) 60 | "E117", # Over-indented (in conflict with Ruff formatter) 61 | "D206", # Indent with spaces (in conflict with Ruff formatter) 62 | "D300", # Triple single quotes (in conflict with Ruff formatter) 63 | "Q000", # Bad quotes in inline string (in conflict with Ruff formatter) 64 | "Q001", # Bad quotes in multi-line string (in conflict with Ruff formatter) 65 | "Q002", # Bad quotes in DocString (in conflict with Ruff formatter) 66 | "Q003", # Avoidable escaped quote (in conflict with Ruff formatter) 67 | "COM812", # Missing trailing comma (in conflict with Ruff formatter) 68 | "COM819", # Prohibited trailing comma (in conflict with Ruff formatter) 69 | "ISC001", # Single-line implicit string concatenation (in conflict with Ruff formatter) 70 | "ISC002", # Multi-line implicit string concatenation (in conflict with Ruff formatter) 71 | ] 72 | # File patterns to be excluded from Ruff lint 73 | # (only needed for file patterns not already listed in the common `exclude` setting 74 | # at top of this file, i.e. list here _additional_ excludes specific to Ruff lint.) 75 | exclude = [ 76 | ] 77 | allowed-confusables = [ 78 | "×", # used as dim multiplication sign in comments, such as `19×16×15×16×8×6×3 = 10,506,240 possible combinations of parameters`. 79 | ] 80 | 81 | [lint.pep8-naming] 82 | ignore-names = [ 83 | "test_*", 84 | "setUp", 85 | "tearDown", 86 | ] 87 | 88 | [lint.pylint] 89 | max-args = 7 90 | 91 | [lint.flake8-pytest-style] 92 | raises-require-match-for = [ 93 | "BaseException", 94 | "Exception", 95 | "OSError", 96 | "IOError", 97 | "EnvironmentError", 98 | "socket.error", 99 | ] 100 | 101 | [lint.per-file-ignores] 102 | # `__init__.py` specific ignores 103 | "__init__.py" = [ 104 | "F401", # {name} imported but unused (NOTE: ignored as imports in `__init__.py` files are almost never used inside the module, but are intended for namespaces) 105 | "I001", # Import block is un-sorted or un-formatted 106 | ] 107 | # `tests` specific ignores 108 | "tests/**/*" = [ 109 | "D", # Missing docstrings 110 | "ERA001", # Found commented-out code 111 | "PT006", # Wrong type passed to first argument of `@pytest.mark.parametrize` (NOTE: ignored to allow parameters args as "args_1,arg_2,arg_3,..." 112 | "S101", # Use of assert detected 113 | "PLR2004", # Magic value used in comparison 114 | "ANN201", # Missing return type annotation for public function 115 | "ANN202", # Missing return type annotation for private function 116 | "INP001", # File is part of an implicit namespace package. Add an `__init__.py`. (NOTE: tests are not intended to be a module, __init__.py hence not required.) 117 | "SLF001", # Private member accessed 118 | "TRY004", # Prefer `TypeError` exception for invalid type 119 | ] 120 | # `stubs` specific ignores 121 | "stubs/**/*" = [ 122 | "D", # Missing docstrings 123 | "ERA001", # Found commented-out code 124 | "SLF001", # Private member accessed 125 | "F405", # {name} may be undefined, or defined from star imports: {module} 126 | "F403", # from {name} import * used; unable to detect undefined names 127 | "ANN", # Missing type annotation 128 | "N", # Naming violations 129 | "A001", # Variable {name} is shadowing a Python builtin 130 | "A002", # Argument {name} is shadowing a Python builtin 131 | "FBT001", # Boolean-typed positional argument in function definition 132 | "PYI042", # Type alias {name} should be CamelCase 133 | "PYI002", # complex if statement in stub 134 | "PLR0913", # Too many arguments in function definition 135 | ] 136 | # Jupyter notebook specific ignores 137 | "**/*.ipynb" = [ 138 | "D103", # Missing docstring in public function 139 | "T201", # `print` found 140 | "PGH003", # Use specific rule codes when ignoring type issues 141 | "TCH002", # Move third-party import into a type-checking block 142 | ] 143 | # `examples` specific ignores 144 | "examples/**/*" = [ 145 | "D", # Missing docstrings 146 | "S101", # Use of assert detected 147 | "PLR2004", # Magic value used in comparison 148 | "INP001", # File is part of an implicit namespace package. Add an `__init__.py`. (NOTE: tutorials are not intended to be a module, __init__.py hence not required.) 149 | "T201", # `print` found 150 | "E402", # Module level import not at top of cell 151 | ] 152 | 153 | [lint.pydocstyle] 154 | convention = "numpy" 155 | 156 | [format] 157 | docstring-code-format = true 158 | -------------------------------------------------------------------------------- /src/mlfmu/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | MLFMU_PACKAGE_PATH = Path().absolute().parent.parent 4 | -------------------------------------------------------------------------------- /src/mlfmu/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/cli/__init__.py -------------------------------------------------------------------------------- /src/mlfmu/cli/mlfmu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import logging 5 | import textwrap 6 | from pathlib import Path 7 | 8 | from mlfmu.api import MlFmuCommand, run 9 | from mlfmu.utils.logging import configure_logging 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def _argparser() -> argparse.ArgumentParser: 15 | """ 16 | Create and return an ArgumentParser object for parsing command line arguments. 17 | 18 | Returns 19 | ------- 20 | argparse.ArgumentParser: The ArgumentParser object. 21 | """ 22 | parser = argparse.ArgumentParser( 23 | prog="mlfmu", 24 | formatter_class=argparse.RawDescriptionHelpFormatter, 25 | description=textwrap.dedent("""\ 26 | mlfmu is a command line tool to build FMUs from ONNX ML models. 27 | Check the README and docs for more info. 28 | You can also run `mlfmu --help` for more info on a specific command."""), 29 | epilog=textwrap.dedent("""\ 30 | This tool utilizes cppfmu, source code is available at: https://github.com/viproma/cppfmu 31 | _________________mlfmu___________________ 32 | """), 33 | prefix_chars="-", 34 | add_help=True, 35 | ) 36 | 37 | common_args_parser = argparse.ArgumentParser(add_help=False) 38 | 39 | console_verbosity = common_args_parser.add_mutually_exclusive_group(required=False) 40 | 41 | _ = console_verbosity.add_argument( 42 | "-q", 43 | "--quiet", 44 | action="store_true", 45 | help=("console output will be quiet."), 46 | default=False, 47 | ) 48 | 49 | _ = console_verbosity.add_argument( 50 | "-v", 51 | "--verbose", 52 | action="store_true", 53 | help=("console output will be verbose."), 54 | default=False, 55 | ) 56 | 57 | _ = common_args_parser.add_argument( 58 | "-l", 59 | "--log", 60 | action="store", 61 | type=str, 62 | help=( 63 | "name of log file. If specified, this will activate logging to file. " 64 | "If not, it does not log to file, only console." 65 | ), 66 | default=None, 67 | required=False, 68 | ) 69 | 70 | _ = common_args_parser.add_argument( 71 | "-ll", 72 | "--log-level", 73 | action="store", 74 | type=str, 75 | help="log level applied to logging to file. Default: WARNING.", 76 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 77 | default="WARNING", 78 | required=False, 79 | ) 80 | 81 | # Create a sub parser for each command 82 | sub_parsers = parser.add_subparsers( 83 | dest="command", 84 | title="Available commands", 85 | metavar="command", 86 | required=True, 87 | ) 88 | 89 | # Main command 90 | # build command to go from config to compiled fmu 91 | build_parser = sub_parsers.add_parser( 92 | MlFmuCommand.BUILD.value, 93 | help="Build FMU from interface and model files", 94 | parents=[common_args_parser], 95 | add_help=True, 96 | ) 97 | 98 | # Add options for build command 99 | _ = build_parser.add_argument( 100 | "-i", 101 | "--interface-file", 102 | type=str, 103 | help="JSON file describing the FMU following schema", 104 | ) 105 | _ = build_parser.add_argument( 106 | "-m", 107 | "--model-file", 108 | type=str, 109 | help="ONNX file containing the ML Model", 110 | ) 111 | _ = build_parser.add_argument( 112 | "-f", 113 | "--fmu-path", 114 | type=str, 115 | help="Path to where the built FMU should be saved", 116 | ) 117 | 118 | # Split the main build command into steps for customization 119 | # generate-code command to go from config to generated fmu source code 120 | code_generation_parser = sub_parsers.add_parser( 121 | MlFmuCommand.GENERATE.value, 122 | help="Generate FMU source code from interface and model files", 123 | parents=[common_args_parser], 124 | add_help=True, 125 | ) 126 | 127 | # Add options for code generation command 128 | _ = code_generation_parser.add_argument( 129 | "--interface-file", 130 | type=str, 131 | help="json file describing the FMU following schema (e.g. interface.json).", 132 | ) 133 | _ = code_generation_parser.add_argument( 134 | "--model-file", 135 | type=str, 136 | help="onnx file containing the ML Model (e.g. example.onnx).", 137 | ) 138 | _ = code_generation_parser.add_argument( 139 | "--fmu-source-path", 140 | help=( 141 | "Path to where the generated FMU source code should be saved. " 142 | "Given path/to/folder the files can be found in path/to/folder/[FmuName]" 143 | ), 144 | ) 145 | 146 | # build-code command to go from fmu source code to compiled fmu 147 | build_code_parser = sub_parsers.add_parser( 148 | MlFmuCommand.COMPILE.value, 149 | help="Build FMU from FMU source code", 150 | parents=[common_args_parser], 151 | add_help=True, 152 | ) 153 | 154 | # Add option for fmu compilation 155 | _ = build_code_parser.add_argument( 156 | "--fmu-source-path", 157 | type=str, 158 | help=( 159 | "Path to the folder where the FMU source code is located. " 160 | "The folder needs to have the same name as the FMU. E.g. path/to/folder/[FmuName]" 161 | ), 162 | ) 163 | _ = build_code_parser.add_argument( 164 | "--fmu-path", 165 | type=str, 166 | help="Path to where the built FMU should be saved.", 167 | ) 168 | 169 | return parser 170 | 171 | 172 | def main() -> None: 173 | """ 174 | Entry point for console script as configured in setup.cfg. 175 | 176 | Runs the command line interface and parses arguments and options entered on the console. 177 | """ 178 | parser = _argparser() 179 | args = parser.parse_args() 180 | 181 | # Configure Logging 182 | # ..to console 183 | log_level_console: str = "WARNING" 184 | if any([args.quiet, args.verbose]): 185 | log_level_console = "ERROR" if args.quiet else log_level_console 186 | log_level_console = "INFO" if args.verbose else log_level_console 187 | # ..to file 188 | log_file: Path | None = Path(args.log) if args.log else None 189 | log_level_file: str = args.log_level 190 | configure_logging(log_level_console, log_file, log_level_file) 191 | logger.info("Logging to file: %s", log_file) 192 | 193 | command: MlFmuCommand | None = MlFmuCommand.from_string(args.command) 194 | 195 | if command is None: 196 | raise ValueError( 197 | f"The given command (={args.command}) does not match any of the existing commands " 198 | f"(={[command.value for command in MlFmuCommand]})." 199 | ) 200 | 201 | interface_file = args.interface_file if "interface_file" in args else None 202 | model_file = args.model_file if "model_file" in args else None 203 | fmu_path = args.fmu_path if "fmu_path" in args else None 204 | source_folder = args.fmu_source_path if "fmu_source_path" in args else None 205 | 206 | # Invoke API 207 | try: 208 | run( 209 | command=command, 210 | interface_file=interface_file, 211 | model_file=model_file, 212 | fmu_path=fmu_path, 213 | source_folder=source_folder, 214 | ) 215 | except Exception: 216 | logger.exception("Unhandled exception in run: %s") 217 | 218 | 219 | if __name__ == "__main__": 220 | try: 221 | main() 222 | except Exception: 223 | logger.exception("Unhandled exception in main: %s") 224 | -------------------------------------------------------------------------------- /src/mlfmu/cli/publish_docs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from mlfmu.utils.interface import publish_interface_schema 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def main() -> None: 9 | """Start the publishing process for the mlfmu interface docs, by calling the publish_interface_schema function.""" 10 | logger.info("Start publish-interface-docs.py") 11 | publish_interface_schema() 12 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.24) 2 | set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") 3 | 4 | project(cpp-fmus) 5 | 6 | option(FMU_USE_STATIC_RUNTIME 7 | "Whether to link the model code against a static C(++) runtime library" 8 | ON) 9 | option(FMU_CHECK_COMPLIANCE 10 | "Whether to test each FMU with the FMU Compliance checker" 11 | OFF) 12 | set(FMU_OUTPUT_DIR "${CMAKE_BINARY_DIR}" 13 | CACHE PATH "Where to put the generated FMUs") 14 | set(FMU_STAGING_DIR "${CMAKE_BINARY_DIR}/fmu-staging" 15 | CACHE PATH "Where to put the generated FMU contents before zipping") 16 | set(FMU_UUID_DIR "${CMAKE_BINARY_DIR}/fmu-uuids" 17 | CACHE PATH "Where to put the headers containing FMU UUIDs") 18 | set(FMU_SOURCE_PATH "${CMAKE_SOURCE_DIR}/src" 19 | CACHE PATH "Where the folder with the FMU source is located") 20 | 21 | # The names of the FMUs built in this project 22 | 23 | # Set variable -DFMU_NAMES from command line instead 24 | set(FMU_NAMES) 25 | 26 | # ============================================================================== 27 | # Compiler/platform specific settings 28 | # ============================================================================== 29 | 30 | if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") 31 | add_compile_options("-Wall" "-Wextra" "-Wpedantic") 32 | add_compile_options("-Wno-parentheses") 33 | if(FMU_USE_STATIC_RUNTIME) 34 | add_compile_options("-static") 35 | endif() 36 | elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") 37 | add_compile_options("/W4") 38 | add_compile_options("/wd4996") 39 | add_definitions("-D_SCL_SECURE_NO_WARNINGS" "-D_CRT_SECURE_NO_WARNINGS") 40 | if(FMU_USE_STATIC_RUNTIME) 41 | foreach(lang "C" "CXX") 42 | string(REPLACE "/MD" "/MT" "CMAKE_${lang}_FLAGS" "${${CMAKE_${lang}_FLAGS}}") 43 | foreach(config ${CMAKE_CONFIGURATION_TYPES}) 44 | string(TOUPPER "${config}" configUpper) 45 | set(flagVar "CMAKE_${lang}_FLAGS_${configUpper}") 46 | string(REPLACE "/MD" "/MT" ${flagVar} ${${flagVar}}) 47 | endforeach() 48 | endforeach() 49 | endif() 50 | endif() 51 | 52 | if(UNIX) 53 | # Remove the "lib" prefix on the generated .so files 54 | set(CMAKE_SHARED_MODULE_PREFIX) 55 | endif() 56 | 57 | # ============================================================================== 58 | # Build 59 | # ============================================================================== 60 | 61 | # List CPPFMU sources 62 | file(GLOB cppfmuSourceFiles "${CMAKE_SOURCE_DIR}/cppfmu/*.?pp") 63 | file(GLOB onnxfmuSourceFiles "${CMAKE_SOURCE_DIR}/templates/onnx_fmu/*.?pp") 64 | # Find FMU Compliance Checker 65 | if(FMU_CHECK_COMPLIANCE) 66 | enable_testing() 67 | find_package(FMUComplianceChecker REQUIRED) 68 | endif() 69 | 70 | # Detect platform 71 | if("${CMAKE_SYSTEM_NAME}" MATCHES "Darwin") 72 | set(os "darwin") 73 | elseif("${CMAKE_SYSTEM_NAME}" MATCHES "Linux") 74 | set(os "linux") 75 | elseif(WIN32) 76 | set(os "win") 77 | else() 78 | message(FATAL_ERROR "Unknown or unsupported platform: ${CMAKE_SYSTEM_NAME}") 79 | endif() 80 | math(EXPR wordSize 8*${CMAKE_SIZEOF_VOID_P}) 81 | set(platformIdentifier "${os}${wordSize}") 82 | 83 | # Prepare names for FMU staging directories. 84 | if(CMAKE_CONFIGURATION_TYPES) 85 | set(configStagingDir "${FMU_STAGING_DIR}/$") 86 | else() 87 | set(configStagingDir "${FMU_STAGING_DIR}") 88 | endif() 89 | 90 | # Create targets 91 | set(includeDirs 92 | ) 93 | 94 | find_package (onnxruntime REQUIRED) 95 | 96 | foreach(fmuName IN LISTS FMU_NAMES) 97 | set(fmuLibraryTarget "${fmuName}") 98 | set(fmuGuidTarget "${fmuName}_guid") 99 | set(fmuTarget "${fmuName}_fmu") 100 | 101 | set(fmuSourceDir "${FMU_SOURCE_PATH}/${fmuName}") 102 | set(fmuSourceModelDescription "${fmuSourceDir}/modelDescription.xml") 103 | set(fmuSourceFiles "${fmuSourceDir}/sources/fmu.cpp") 104 | 105 | set(fmuGuidDir "${FMU_UUID_DIR}/${fmuName}") 106 | set(fmuGuidModelDescription "${fmuGuidDir}/modelDescription.xml") 107 | set(fmuGuidHeaderFile "${fmuGuidDir}/fmu-uuid.h") 108 | 109 | set(fmuStagingDir "${configStagingDir}/${fmuName}") 110 | set(fmuStagedBinariesDir "${fmuStagingDir}/binaries/${platformIdentifier}") 111 | 112 | set(fmuOutputFile "${FMU_OUTPUT_DIR}/${fmuName}.fmu") 113 | 114 | # Target to generate GUID 115 | add_custom_command( 116 | OUTPUT "${fmuGuidModelDescription}" "${fmuGuidHeaderFile}" 117 | COMMAND 118 | "${CMAKE_COMMAND}" "-E" "make_directory" "${fmuGuidDir}" 119 | COMMAND 120 | "${CMAKE_COMMAND}" 121 | "-DINPUT_MODEL_DESCRIPTION=${fmuSourceModelDescription}" 122 | "-DINPUT_HEADER=${CMAKE_SOURCE_DIR}/cmake/fmu-uuid.h.in" 123 | "-DOUTPUT_MODEL_DESCRIPTION=${fmuGuidModelDescription}" 124 | "-DOUTPUT_HEADER=${fmuGuidHeaderFile}" 125 | "-DADDITIONAL_INPUT=${fmuSourceFiles}" 126 | "-P" "${CMAKE_SOURCE_DIR}/cmake/GenerateFmuGuid.cmake" 127 | DEPENDS "${fmuSourceModelDescription}" 128 | VERBATIM 129 | ) 130 | add_custom_target(${fmuGuidTarget} 131 | DEPENDS "${fmuGuidModelDescription}" "${fmuGuidHeaderFile}" 132 | ) 133 | 134 | # Target to build dynamic library 135 | add_library(${fmuLibraryTarget} MODULE 136 | "${fmuSourceModelDescription}" 137 | ${fmuSourceFiles} 138 | ${cppfmuSourceFiles} 139 | ${onnxfmuSourceFiles} 140 | ) 141 | add_dependencies(${fmuLibraryTarget} ${fmuGuidTarget}) 142 | target_link_libraries(${fmuLibraryTarget} onnxruntime::onnxruntime) 143 | target_include_directories(${fmuLibraryTarget} PRIVATE 144 | "${CMAKE_SOURCE_DIR}/fmi" 145 | "${CMAKE_SOURCE_DIR}/cppfmu" 146 | "${CMAKE_SOURCE_DIR}/templates/onnx_fmu" 147 | "${fmuSourceDir}/sources" 148 | "${fmuGuidDir}" 149 | ) 150 | 151 | # Target to generate FMU contents 152 | add_custom_command(OUTPUT "${fmuOutputFile}" 153 | COMMAND "${CMAKE_COMMAND}" "-E" "copy_directory" "${fmuSourceDir}" "${fmuStagingDir}" 154 | COMMAND "${CMAKE_COMMAND}" "-E" "make_directory" "${fmuStagedBinariesDir}" 155 | COMMAND "${CMAKE_COMMAND}" "-E" "copy" "$" "${fmuStagedBinariesDir}/" 156 | COMMAND "${CMAKE_COMMAND}" "-E" "copy" "${fmuGuidModelDescription}" "${fmuStagingDir}/modelDescription.xml" 157 | COMMAND "${CMAKE_COMMAND}" "-E" "copy" "${fmuGuidHeaderFile}" "${fmuStagingDir}/sources/" 158 | COMMAND "${CMAKE_COMMAND}" "-DBASE_DIR=${fmuStagingDir}" "-DOUTPUT_FILE=${fmuOutputFile}" "-P" "${CMAKE_SOURCE_DIR}/cmake/ZipAll.cmake" 159 | DEPENDS ${fmuLibraryTarget} ${fmuGuidTarget} 160 | VERBATIM) 161 | add_custom_target(${fmuTarget} ALL DEPENDS "${fmuOutputFile}") 162 | 163 | # Test compliance 164 | if(FMU_CHECK_COMPLIANCE) 165 | add_fmu_compliance_check("${fmuTarget}_compliance" "${fmuOutputFile}") 166 | endif() 167 | endforeach() 168 | 169 | source_group("Metadata" REGULAR_EXPRESSION "modelDescription.xml") 170 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/cmake/FindFMUComplianceChecker.cmake: -------------------------------------------------------------------------------- 1 | # Finds FMUComplianceChecker 2 | # (https://github.com/modelica-tools/FMUComplianceChecker) 3 | # 4 | # The script sets the following variables: 5 | # 6 | # FMUComplianceChecker_FOUND - TRUE on success, FALSE on failure 7 | # FMUComplianceChecker_EXECUTABLE - The fmuCheck executable, if found. 8 | # 9 | # It also defines the following macro, which adds a new test (using add_test) 10 | # that runs the FMU Compliance Checker on an FMU: 11 | # 12 | # add_fmu_compliance_check(test_name fmu_path) 13 | # 14 | 15 | # Detect platform 16 | if("${CMAKE_SYSTEM_NAME}" MATCHES "Darwin") 17 | set(_os "darwin") 18 | elseif("${CMAKE_SYSTEM_NAME}" MATCHES "Linux") 19 | set(_os "linux") 20 | elseif(WIN32) 21 | set(_os "win") 22 | else() 23 | message(FATAL_ERROR "Unknown or unsupported platform: ${CMAKE_SYSTEM_NAME}") 24 | endif() 25 | math(EXPR _wordSize 8*${CMAKE_SIZEOF_VOID_P}) 26 | 27 | find_program( 28 | FMUComplianceChecker_EXECUTABLE 29 | "fmuCheck.${_os}${_wordSize}") 30 | 31 | include(FindPackageHandleStandardArgs) 32 | find_package_handle_standard_args( 33 | FMUComplianceChecker 34 | DEFAULT_MSG 35 | FMUComplianceChecker_EXECUTABLE) 36 | 37 | macro(add_fmu_compliance_check testName fmuPath) 38 | add_test( 39 | NAME "${testName}" 40 | COMMAND "${FMUComplianceChecker_EXECUTABLE}" "${fmuPath}" 41 | WORKING_DIRECTORY "${CMAKE_BINARY_DIR}") 42 | endmacro() 43 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/cmake/GenerateFmuGuid.cmake: -------------------------------------------------------------------------------- 1 | # Generates a UUID to be used as the "guid" attribute of an FMU, and writes it 2 | # to the modelDescription.xml file and to a header that can be imported in the 3 | # FMU code. 4 | # 5 | # The script takes a template modelDescription.xml and header file as input, 6 | # and substitutes a placeholder in each of them with the generated UUID before 7 | # writing the output files. The placeholder should be on the form "@FMU_UUID@" 8 | # (the name is configurable, but the @ signs are not). 9 | # 10 | # The UUID will be generated as a hash of the input model description and, 11 | # optionally, other input files (e.g. model source code files). 12 | # 13 | # Mandatory configuration variables: 14 | # 15 | # INPUT_MODEL_DESCRIPTION 16 | # The model description XML file containing a placeholder in the "guid" 17 | # attribute. 18 | # 19 | # INPUT_HEADER 20 | # A header file containing a placeholder for the UUID (typically in a 21 | # #define directive or constant declaration). 22 | # 23 | # OUTPUT_MODEL_DESCRIPTION 24 | # The path to the output model description XML file. 25 | # 26 | # OUTPUT_HEADER 27 | # The path to the output header file. 28 | # 29 | # Optional configuration variables: 30 | # 31 | # ADDITIONAL_INPUT 32 | # Other input files that should be hashed into the UUID. 33 | # 34 | # PLACEHOLDER_NAME 35 | # The name of the placeholder in the input model description and header. 36 | # The default value is "FMU_UUID". 37 | # 38 | cmake_minimum_required(VERSION 3.1) 39 | 40 | if((NOT INPUT_MODEL_DESCRIPTION) OR (NOT INPUT_HEADER) OR (NOT OUTPUT_MODEL_DESCRIPTION) OR (NOT OUTPUT_HEADER)) 41 | message(FATAL_ERROR "One or more mandatory configuration variables are not set") 42 | endif() 43 | if(NOT PLACEHOLDER_NAME) 44 | set(PLACEHOLDER_NAME "FMU_UUID") 45 | endif() 46 | 47 | macro(append_file_contents_without_newlines filePath outputVar) 48 | file(READ "${filePath}" contents) 49 | string(REGEX REPLACE "[\r\n]" "" contentsWithoutNewlines "${contents}") 50 | string(APPEND ${outputVar} "${contentsWithoutNewlines}") 51 | endmacro() 52 | 53 | # Read input model description file and any additional input files 54 | set(text) 55 | append_file_contents_without_newlines("${INPUT_MODEL_DESCRIPTION}" text) 56 | foreach(f IN LISTS ADDITIONAL_INPUT) 57 | append_file_contents_without_newlines("${f}" text) 58 | endforeach() 59 | 60 | # Generate UUID 61 | set(uuidNamespace "23d0734c-3983-4f65-a8f7-3bd5c5693546") 62 | string(UUID ${PLACEHOLDER_NAME} NAMESPACE "${uuidNamespace}" NAME "${text}" TYPE "SHA1") 63 | 64 | # Write output files 65 | configure_file("${INPUT_MODEL_DESCRIPTION}" "${OUTPUT_MODEL_DESCRIPTION}" @ONLY) 66 | configure_file("${INPUT_HEADER}" "${OUTPUT_HEADER}" @ONLY) 67 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/cmake/ZipAll.cmake: -------------------------------------------------------------------------------- 1 | # Changes to BASE_DIR and zips all files found there into OUTPUT_FILE. 2 | if(NOT IS_ABSOLUTE "${BASE_DIR}") 3 | message(FATAL_ERROR "BASE_DIR does not contain an absolute path") 4 | endif() 5 | if(NOT IS_DIRECTORY "${BASE_DIR}") 6 | message(FATAL_ERROR "BASE_DIR does not refer to a directory") 7 | endif() 8 | if(NOT IS_ABSOLUTE "${OUTPUT_FILE}") 9 | message(FATAL_ERROR "OUTPUT_FILE does not contain an absolute path") 10 | endif() 11 | 12 | file(GLOB inputFiles RELATIVE "${BASE_DIR}" "${BASE_DIR}/*") 13 | execute_process( 14 | COMMAND "${CMAKE_COMMAND}" "-E" "tar" "cf" "${OUTPUT_FILE}" "--format=zip" ${inputFiles} 15 | WORKING_DIRECTORY "${BASE_DIR}" 16 | RESULT_VARIABLE exitCode 17 | ) 18 | if(NOT exitCode EQUAL 0) 19 | message(FATAL_ERROR "Zip command failed") 20 | endif() 21 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/cmake/fmu-uuid.h.in: -------------------------------------------------------------------------------- 1 | #define FMU_UUID "@FMU_UUID@" 2 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/conanfile.txt: -------------------------------------------------------------------------------- 1 | [requires] 2 | onnxruntime/1.18.1 3 | 4 | [options] 5 | *:shared=False 6 | onnx*:disable_static_registration=True 7 | 8 | [generators] 9 | CMakeDeps 10 | CMakeToolchain 11 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/fmi/fmi2FunctionTypes.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/fmu_build/fmi/fmi2FunctionTypes.h -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/fmi/fmi2Functions.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/fmu_build/fmi/fmi2Functions.h -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/fmi/fmi2TypesPlatform.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/fmu_build/fmi/fmi2TypesPlatform.h -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/fmi/fmiFunctions.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/fmu_build/fmi/fmiFunctions.h -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/fmi/fmiPlatformTypes.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/fmu_build/fmi/fmiPlatformTypes.h -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/templates/fmu/fmu_template.cpp: -------------------------------------------------------------------------------- 1 | #include "fmu-uuid.h" 2 | #include "model_definitions.h" 3 | 4 | #include 5 | #include 6 | 7 | 8 | /** 9 | * \class {FmuName} 10 | * \brief A class representing an {FmuName} FMU. 11 | * 12 | * This class is derived from the OnnxFmu class and provides functionality specific to the {FmuName} FMU. 13 | */ 14 | class {FmuName} : public OnnxFmu {{ 15 | public : 16 | /** 17 | * \brief Constructs a new {FmuName} object. 18 | * 19 | * \param fmuResourceLocation The location of the resources of the FMU. 20 | */ 21 | {FmuName}(cppfmu::FMIString fmuResourceLocation) : OnnxFmu(fmuResourceLocation) {{}} 22 | 23 | private : 24 | // Add private members and functions here 25 | }}; 26 | 27 | /** 28 | * \brief Instantiate a `slave` instance of the FMU. 29 | * 30 | * \param instanceName The name of the FMU instance. 31 | * \param fmuGUID The GUID of the FMU. 32 | * \param fmuResourceLocation The location of the FMU resource. 33 | * \param mimeType The MIME type of the FMU. 34 | * \param timeout The timeout value for the instantiation process. 35 | * \param visible Flag indicating whether the FMU should be visible. 36 | * \param interactive Flag indicating whether the FMU should be interactive. 37 | * \param memory The memory to be used for the FMU instance. 38 | * \param logger The logger to be used for logging messages. 39 | * \returns A unique pointer to the instantiated slave instance. 40 | * 41 | * \throws std::runtime_error if the FMU GUID does not match. 42 | */ 43 | cppfmu::UniquePtr CppfmuInstantiateSlave( 44 | cppfmu::FMIString /*instanceName*/, cppfmu::FMIString fmuGUID, cppfmu::FMIString fmuResourceLocation, 45 | cppfmu::FMIString /*mimeType*/, cppfmu::FMIReal /*timeout*/, cppfmu::FMIBoolean /*visible*/, 46 | cppfmu::FMIBoolean /*interactive*/, cppfmu::Memory memory, cppfmu::Logger /*logger*/) 47 | {{ 48 | if (std::strcmp(fmuGUID, FMU_UUID) != 0) {{ 49 | throw std::runtime_error("FMU GUID mismatch"); 50 | }} 51 | return cppfmu::AllocateUnique<{FmuName}>(memory, fmuResourceLocation); 52 | }} 53 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/templates/fmu/model_definitions_template.h: -------------------------------------------------------------------------------- 1 | #define NUM_FMU_VARIABLES {numFmuVariables} 2 | 3 | #define NUM_ONNX_INPUTS {numOnnxInputs} 4 | #define NUM_ONNX_FMU_INPUTS {numOnnxFmuInputs} 5 | #define NUM_ONNX_OUTPUTS {numOnnxOutputs} 6 | #define NUM_ONNX_FMU_OUTPUTS {numOnnxFmuOutputs} 7 | #define NUM_ONNX_STATES {numOnnxStates} 8 | #define NUM_ONNX_STATES_OUTPUTS {numOnnxStatesOutputs} 9 | #define NUM_ONNX_STATE_INIT {numOnnxStateInit} 10 | 11 | #define ONNX_INPUT_VALUE_REFERENCES {onnxInputValueReferences} 12 | #define ONNX_OUTPUT_VALUE_REFERENCES {onnxOutputValueReferences} 13 | #define ONNX_STATE_OUTPUT_INDEXES {onnxStateOutputIndexes} 14 | #define ONNX_STATE_INIT_VALUE_REFERENCES {onnxStateInitValueReferences} 15 | 16 | #define ONNX_USE_TIME_INPUT {onnxUsesTime} 17 | 18 | #define ONNX_INPUT_NAME "{onnxInputName}" 19 | #define ONNX_OUTPUT_NAME "{onnxOutputName}" 20 | #define ONNX_STATE_NAME "{onnxStatesName}" 21 | #define ONNX_TIME_INPUT_NAME "{onnxTimeInputName}" 22 | #define ONNX_FILENAME L"{onnxFileName}" -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.cpp: -------------------------------------------------------------------------------- 1 | #include "onnxFmu.hpp" 2 | 3 | #include 4 | 5 | /** 6 | * \brief Constructs an instance of the OnnxFmu class. 7 | * 8 | * Constructs an instance of the OnnxFmu class. 9 | * 10 | * \param fmuResourceLocation The location of the FMU resource. 11 | */ 12 | OnnxFmu::OnnxFmu(cppfmu::FMIString fmuResourceLocation) 13 | { 14 | formatOnnxPath(fmuResourceLocation); 15 | try { 16 | CreateSession(); 17 | } catch (const std::runtime_error& e) { 18 | std::cerr << e.what() << '\n'; 19 | } 20 | OnnxFmu::Reset(); 21 | } 22 | 23 | 24 | /** 25 | * \brief Formats the onnx path. 26 | * 27 | * Formats the onnx path by appending the ONNX_FILENAME to the given fmuResourceLocation. 28 | * If the path starts with "file:///", it removes the "file://" prefix. 29 | * This is directly stored to the class var onnxPath_. 30 | * 31 | * \param fmuResourceLocation The location of the FMU resource. 32 | */ 33 | void OnnxFmu::formatOnnxPath(cppfmu::FMIString fmuResourceLocation) 34 | { 35 | // Creating complete path to onnx file 36 | std::wostringstream onnxPathStream; 37 | onnxPathStream << fmuResourceLocation; 38 | onnxPathStream << L"/"; 39 | onnxPathStream << ONNX_FILENAME; 40 | 41 | // Remove file:// from the path if it is at the beginning 42 | std::wstring path = onnxPathStream.str(); 43 | std::wstring startPath = path.substr(0, 8); 44 | std::wstring endPath = path.substr(8); 45 | if (startPath == L"file:///") { 46 | path = endPath; 47 | } 48 | // save to onnxPath_ (wstring for Windows, else string) 49 | #ifdef _WIN32 50 | onnxPath_ = path; 51 | #else 52 | onnxPath_ = std::string(path.begin(), path.end()); 53 | #endif 54 | } 55 | 56 | /** 57 | * \brief Creates a onnx runtime session for the model. 58 | * 59 | * This function creates a session to the ONNX model, using the specified ONNX model file. 60 | * This loads the weights of the model such that we can run predictions in the doStep function. 61 | * 62 | * \note The ONNX model file path must be set before calling this function. 63 | * \throws std::runtime_error if the session creation fails. 64 | */ 65 | void OnnxFmu::CreateSession() 66 | { 67 | // Create the ONNX environment 68 | session_ = Ort::Session(env, onnxPath_.c_str(), Ort::SessionOptions {nullptr}); 69 | } 70 | 71 | /** 72 | * \brief Resets the state of the OnnxFmu. 73 | * 74 | * This function is called to reset the state of the OnnxFmu. 75 | */ 76 | void OnnxFmu::Reset() 77 | { 78 | doStateInit_ = true; 79 | return; 80 | } 81 | 82 | /** 83 | * Sets the real values of the specified FMI value references. 84 | * 85 | * \param vr An array of FMI value references. 86 | * \param nvr The number of FMI value references in the array. 87 | * \param value An array of FMI real values to be set. 88 | */ 89 | void OnnxFmu::SetReal(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIReal value[]) 90 | { 91 | for (std::size_t i = 0; i < nvr; ++i) { 92 | OnnxFmu::fmuVariables_[vr[i]].real = value[i]; 93 | } 94 | } 95 | 96 | 97 | /** 98 | * \brief Retrieves the real values of the specified FMI value references. 99 | * 100 | * \param vr An array of FMI value references. 101 | * \param nvr The number of FMI value references in the array. 102 | * \param value An array to store the retrieved real values. 103 | */ 104 | void OnnxFmu::GetReal(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIReal value[]) const 105 | { 106 | for (std::size_t i = 0; i < nvr; ++i) { 107 | value[i] = fmuVariables_[vr[i]].real; 108 | } 109 | } 110 | 111 | 112 | /** 113 | * \brief Sets the integer values of the specified FMI value references. 114 | * 115 | * This function sets the integer values of the FMI value references specified in the 116 | * `vr` array to the corresponding values in the `value` array. 117 | * 118 | * \param vr An array of FMI value references. 119 | * \param nvr The number of FMI value references in the `vr` array. 120 | * \param value An array of FMI integer values. 121 | */ 122 | void OnnxFmu::SetInteger(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIInteger value[]) 123 | { 124 | for (std::size_t i = 0; i < nvr; ++i) { 125 | fmuVariables_[vr[i]].integer = value[i]; 126 | } 127 | } 128 | 129 | 130 | /** 131 | * \brief Retrieves the integer values of the specified FMI value references. 132 | * 133 | * This function retrieves the integer values of the FMI value references specified in the 134 | * `vr` array and stores them in the `value` array. 135 | * 136 | * \param vr An array of FMI value references. 137 | * \param nvr The number of FMI value references in the `vr` array. 138 | * \param value An array to store the retrieved integer values. 139 | */ 140 | void OnnxFmu::GetInteger(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIInteger value[]) const 141 | { 142 | for (std::size_t i = 0; i < nvr; ++i) { 143 | value[i] = fmuVariables_[vr[i]].integer; 144 | } 145 | } 146 | 147 | /** 148 | * \brief Sets the boolean values for the specified FMI value references. 149 | * 150 | * This function sets the boolean values for the specified FMI value references in the 151 | * OnnxFmu object. 152 | * 153 | * \param vr An array of FMI value references. 154 | * \param nvr The number of FMI value references in the array. 155 | * \param value An array of FMI boolean values. 156 | */ 157 | void OnnxFmu::SetBoolean(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIBoolean value[]) 158 | { 159 | for (std::size_t i = 0; i < nvr; ++i) { 160 | fmuVariables_[vr[i]].boolean = value[i]; 161 | } 162 | } 163 | 164 | /** 165 | * \brief Retrieves boolean values for the specified value references. 166 | * 167 | * This function retrieves boolean values for the specified value references from the 168 | * OnnxFmu object. 169 | * 170 | * \param vr An array of FMIValueReference representing the value references. 171 | * \param nvr The number of value references in the vr array. 172 | * \param value An array of FMIBoolean to store the retrieved boolean values. 173 | */ 174 | void OnnxFmu::GetBoolean(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIBoolean value[]) const 175 | { 176 | for (std::size_t i = 0; i < nvr; ++i) { 177 | value[i] = fmuVariables_[vr[i]].boolean; 178 | } 179 | } 180 | 181 | /** 182 | * \brief Sets the ONNX inputs for the ONNX FMU, matching FMU variables with inputs of ONNX model. 183 | * 184 | * This function matches the FMU variables with the inputs to the ONNX model. 185 | * It iterates over the ONNX input value-reference index pairs and assigns the corresponding FMU 186 | * variable's real value to the ONNX input. 187 | * 188 | * \returns `true` if the ONNX inputs are successfully set, `false` otherwise. 189 | */ 190 | bool OnnxFmu::SetOnnxInputs() 191 | { 192 | for (int index = 0; index < NUM_ONNX_FMU_INPUTS; index++) { 193 | int inputIndex = onnxInputValueReferenceIndexPairs_[index][0]; 194 | int valueReference = onnxInputValueReferenceIndexPairs_[index][1]; 195 | FMIVariable var = fmuVariables_[valueReference]; 196 | // TODO: Change to handle if the variable is not a real 197 | onnxInputs_[inputIndex] = var.real; 198 | } 199 | return true; 200 | } 201 | 202 | /** 203 | * \brief Sets the ONNX states. 204 | * 205 | * This function sets the ONNX states by assigning the corresponding ONNX outputs to the 206 | * ONNX states array. 207 | * 208 | * \returns `true` if the ONNX states are successfully set, `false` otherwise. 209 | */ 210 | bool OnnxFmu::SetOnnxStates() 211 | { 212 | for (int index = 0; index < NUM_ONNX_STATES_OUTPUTS; index++) { 213 | onnxStates_[index] = onnxOutputs_[onnxStateOutputIndexes_[index]]; 214 | } 215 | return true; 216 | } 217 | 218 | /** 219 | * \brief Retrieves the ONNX outputs and updates the corresponding FMU variables. 220 | * 221 | * This function iterates over the ONNX output value-reference index pairs and updates 222 | * the FMU variables with the corresponding ONNX outputs. 223 | * The function assumes that the FMU variables are of type `FMIVariable` and Real valued 224 | * and the ONNX outputs are stored in the `onnxOutputs_` array. 225 | * 226 | * \returns `true` if the ONNX outputs are successfully retrieved and FMU variables are 227 | * updated, `false` otherwise. 228 | */ 229 | bool OnnxFmu::GetOnnxOutputs() 230 | { 231 | for (int index = 0; index < NUM_ONNX_FMU_OUTPUTS; index++) { 232 | int outputIndex = onnxOutputValueReferenceIndexPairs_[index][0]; 233 | int valueReference = onnxOutputValueReferenceIndexPairs_[index][1]; 234 | FMIVariable var = fmuVariables_[valueReference]; 235 | // TODO: Change to handle if the variable is not a real 236 | var.real = onnxOutputs_[outputIndex]; 237 | 238 | fmuVariables_[valueReference] = var; 239 | } 240 | return true; 241 | } 242 | 243 | /** 244 | * \brief Initializes the ONNX states of the ONNX model. 245 | * 246 | * This function initializes the ONNX states of the ONNX FMU by assigning the initial values 247 | * of the ONNX states from the corresponding variables in the FMU. 248 | * The function assumes that the FMU variables are of type `FMIVariable` and Real valued. 249 | * 250 | * \returns `true` if the ONNX states are successfully initialized, `false` otherwise. 251 | */ 252 | bool OnnxFmu::InitOnnxStates() 253 | { 254 | for (int index = 0; index < NUM_ONNX_STATE_INIT; index++) { 255 | int stateIndex = onnxStateInitValueReferenceIndexPairs_[index][0]; 256 | int valueReference = onnxStateInitValueReferenceIndexPairs_[index][1]; 257 | FMIVariable var = fmuVariables_[valueReference]; 258 | // TODO: Change to handle if the variable is not a real 259 | onnxStates_[stateIndex] = var.real; 260 | } 261 | return true; 262 | } 263 | 264 | /** 265 | * \brief Runs the ONNX model for the FMU. 266 | * 267 | * This function runs the ONNX model for the FMU using the provided current communication 268 | * point and time step. 269 | * It takes the input data, states (if any), and time inputs (if enabled) and calls Run 270 | * to produce the output data. 271 | * 272 | * \param currentCommunicationPoint The current communication point of the FMU. 273 | * \param dt The time step of the FMU. 274 | * \returns `true` if the ONNX model was successfully run, `false` otherwise. 275 | */ 276 | bool OnnxFmu::RunOnnxModel(cppfmu::FMIReal currentCommunicationPoint, cppfmu::FMIReal dt) 277 | { 278 | try { 279 | Ort::MemoryInfo memoryInfo = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeCPU); 280 | 281 | std::vector inputs; 282 | const char* inputNames[3] = {inputName_.c_str()}; 283 | int numInputs = 1; 284 | inputs.emplace_back(Ort::Value::CreateTensor(memoryInfo, onnxInputs_.data(), onnxInputs_.size(), 285 | inputShape_.data(), inputShape_.size())); 286 | 287 | if (NUM_ONNX_STATES > 0) { 288 | inputs.emplace_back(Ort::Value::CreateTensor(memoryInfo, onnxStates_.data(), onnxStates_.size(), 289 | stateShape_.data(), stateShape_.size())); 290 | inputNames[1] = stateName_.c_str(); 291 | numInputs++; 292 | } 293 | 294 | if (ONNX_USE_TIME_INPUT) { 295 | onnxTimeInput_[0] = currentCommunicationPoint; 296 | onnxTimeInput_[1] = dt; 297 | inputs.emplace_back(Ort::Value::CreateTensor(memoryInfo, onnxTimeInput_.data(), 298 | onnxTimeInput_.size(), timeInputShape_.data(), 299 | timeInputShape_.size())); 300 | inputNames[2] = timeInputName_.c_str(); 301 | numInputs++; 302 | } 303 | 304 | const char* output_names[] = {outputName_.c_str()}; 305 | Ort::Value outputs = Ort::Value::CreateTensor(memoryInfo, onnxOutputs_.data(), onnxOutputs_.size(), 306 | outputShape_.data(), outputShape_.size()); 307 | 308 | session_.Run(run_options, inputNames, inputs.data(), numInputs, output_names, &outputs, 1); 309 | } catch (const std::exception& /*e*/) { 310 | return false; 311 | } 312 | return true; 313 | } 314 | 315 | /** 316 | * \brief Performs a step in the OnnxFmu simulation. 317 | * 318 | * This function is called by the FMU framework to perform a step in the simulation. 319 | * It initializes the ONNX states if necessary, sets the ONNX inputs, runs the ONNX model, 320 | * gets the ONNX outputs, and sets the ONNX states. 321 | * 322 | * \param currentCommunicationPoint The current communication point in the simulation. 323 | * \param dt The time step size for the simulation. 324 | * \param newStep A boolean indicating whether a new step has started. 325 | * \param endOfStep The end of the current step in the simulation. 326 | * \returns `true` if the step was successful, `false` otherwise. 327 | */ 328 | bool OnnxFmu::DoStep(cppfmu::FMIReal currentCommunicationPoint, cppfmu::FMIReal dt, cppfmu::FMIBoolean /*newStep*/, 329 | cppfmu::FMIReal& /*endOfStep*/) 330 | { 331 | if (doStateInit_) { 332 | bool initOnnxStates = InitOnnxStates(); 333 | if (!initOnnxStates) { 334 | return false; 335 | } 336 | doStateInit_ = false; 337 | } 338 | bool setOnnxSuccessful = SetOnnxInputs(); 339 | if (!setOnnxSuccessful) { 340 | return false; 341 | } 342 | bool runOnnxSuccessful = RunOnnxModel(currentCommunicationPoint, dt); 343 | if (!runOnnxSuccessful) { 344 | return false; 345 | } 346 | bool getOnnxSuccessful = GetOnnxOutputs(); 347 | if (!getOnnxSuccessful) { 348 | return false; 349 | } 350 | bool setOnnxStateSuccessful = SetOnnxStates(); 351 | if (!setOnnxStateSuccessful) { 352 | return false; 353 | } 354 | return true; 355 | } 356 | -------------------------------------------------------------------------------- /src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | // Include definitions specific for each FMU using the template 7 | #include "model_definitions.h" 8 | 9 | union FMIVariable { 10 | struct 11 | { 12 | cppfmu::FMIReal real; 13 | }; 14 | struct 15 | { 16 | cppfmu::FMIInteger integer; 17 | }; 18 | struct 19 | { 20 | cppfmu::FMIString string; 21 | }; 22 | struct 23 | { 24 | cppfmu::FMIBoolean boolean; 25 | }; 26 | }; 27 | 28 | class OnnxFmu : public cppfmu::SlaveInstance 29 | { 30 | public: 31 | OnnxFmu(cppfmu::FMIString fmuResourceLocation); 32 | 33 | void formatOnnxPath(cppfmu::FMIString fmuResourceLocation); 34 | 35 | // New functions for the OnnxTemplate class 36 | void CreateSession(); 37 | bool SetOnnxInputs(); 38 | bool GetOnnxOutputs(); 39 | bool SetOnnxStates(); 40 | bool InitOnnxStates(); 41 | bool RunOnnxModel(cppfmu::FMIReal currentCommunicationPoint, cppfmu::FMIReal dt); 42 | 43 | // Override functions from cppmu::SlaveInstance 44 | void Reset() override; 45 | void SetReal(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIReal value[]) override; 46 | void GetReal(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIReal value[]) const override; 47 | void SetInteger(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIInteger value[]) override; 48 | void GetInteger(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIInteger value[]) const override; 49 | void SetBoolean(const cppfmu::FMIValueReference vr[], std::size_t nvr, const cppfmu::FMIBoolean value[]) override; 50 | void GetBoolean(const cppfmu::FMIValueReference vr[], std::size_t nvr, cppfmu::FMIBoolean value[]) const override; 51 | bool DoStep(cppfmu::FMIReal, cppfmu::FMIReal, cppfmu::FMIBoolean, cppfmu::FMIReal&) override; 52 | 53 | private: 54 | std::array fmuVariables_; 55 | 56 | Ort::Env env; 57 | Ort::RunOptions run_options; 58 | Ort::Session session_ {nullptr}; 59 | 60 | // store path as wstring for Windows or as char * for Linux 61 | #ifdef _WIN32 62 | std::wstring onnxPath_; 63 | #else 64 | std::string onnxPath_; 65 | #endif 66 | 67 | std::string inputName_ {ONNX_INPUT_NAME}; 68 | std::array inputShape_ {1, NUM_ONNX_INPUTS}; 69 | std::array onnxInputs_ {}; 70 | std::array, NUM_ONNX_FMU_INPUTS> onnxInputValueReferenceIndexPairs_ {ONNX_INPUT_VALUE_REFERENCES}; 71 | 72 | std::string stateName_ {ONNX_STATE_NAME}; 73 | std::array stateShape_ {1, NUM_ONNX_STATES}; 74 | std::array onnxStates_ {}; 75 | std::array onnxStateOutputIndexes_ {ONNX_STATE_OUTPUT_INDEXES}; 76 | 77 | std::string outputName_ {ONNX_OUTPUT_NAME}; 78 | std::array outputShape_ {1, NUM_ONNX_OUTPUTS}; 79 | std::array onnxOutputs_ {}; 80 | std::array, NUM_ONNX_FMU_OUTPUTS> onnxOutputValueReferenceIndexPairs_ { 81 | ONNX_OUTPUT_VALUE_REFERENCES}; 82 | 83 | std::string timeInputName_ {ONNX_TIME_INPUT_NAME}; 84 | std::array timeInputShape_ {1, 2}; 85 | std::array onnxTimeInput_ {0.0, 0.0}; 86 | 87 | std::array, NUM_ONNX_STATE_INIT> onnxStateInitValueReferenceIndexPairs_ { 88 | ONNX_STATE_INIT_VALUE_REFERENCES}; 89 | bool doStateInit_ = true; 90 | }; 91 | -------------------------------------------------------------------------------- /src/mlfmu/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/py.typed -------------------------------------------------------------------------------- /src/mlfmu/types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/src/mlfmu/types/__init__.py -------------------------------------------------------------------------------- /src/mlfmu/types/onnx_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from onnxruntime import InferenceSession, NodeArg 6 | 7 | 8 | class ONNXModel: 9 | """ONNX Metadata class. 10 | 11 | Represents an ONNX model and provides methods to load inputs and outputs. 12 | Allows to import the ONNX file and figure out the input/output sizes. 13 | 14 | Raises 15 | ------ 16 | ValueError 17 | If the ml model has 3 inputs, but the `usesTime` flag is set to `false` in the json interface. 18 | ValueError 19 | If the number of inputs to the ml model is larger than 3. 20 | ValueError 21 | If the number of outputs from the ml model is not exactly 1. 22 | """ 23 | 24 | filename: str = "" #: The name of the ONNX file. 25 | states_name: str = "" #: The name of the internal states input. 26 | state_size: int = 0 #: The size of the internal states input. 27 | input_name: str = "" #: The name of the main input. 28 | input_size: int = 0 #: The size of the main input. 29 | output_name: str = "" #: The name of the output. 30 | output_size: int = 0 #: The size of the output. 31 | time_input_name: str = "" #: The name of the time input. 32 | time_input: bool = False #: Flag indicating whether the model uses time input. 33 | __onnx_path: Path #: The path to the ONNX file. 34 | __onnx_session: InferenceSession #: The ONNX runtime inference session. 35 | 36 | def __init__( 37 | self, 38 | onnx_path: str | os.PathLike[str], 39 | *, 40 | time_input: bool = False, 41 | ) -> None: 42 | """ 43 | Initialize the ONNXModel object by loading the ONNX file and assigning model parameters. 44 | 45 | Args: 46 | onnx_path (Union[str, os.PathLike[str]]): The path to the ONNX file. 47 | time_input (bool, optional): Flag indicating whether the model uses time input. Defaults to False. 48 | """ 49 | # Load ONNX file into memory 50 | self.__onnx_path = onnx_path if isinstance(onnx_path, Path) else Path(onnx_path) 51 | self.__onnx_session = InferenceSession(onnx_path) 52 | 53 | # Assign model parameters 54 | self.filename = f"{self.__onnx_path.stem}.onnx" 55 | self.time_input = time_input 56 | 57 | self.load_inputs() 58 | self.load_outputs() 59 | 60 | def load_inputs(self) -> None: 61 | """Load the inputs from the ONNX file and assign the input name and size.""" 62 | # Get inputs from ONNX file 63 | inputs: list[NodeArg] = self.__onnx_session.get_inputs() 64 | input_names = [inp.name for inp in inputs] 65 | input_shapes = [inp.shape for inp in inputs] 66 | self.input_name = input_names[0] 67 | self.input_size = input_shapes[0][1] 68 | 69 | # Number of internal states 70 | num_states = 0 71 | 72 | # Based on number of inputs work out which are INTERNAL STATES, INPUTS and TIME DATA 73 | if len(input_names) == 3: # noqa: PLR2004 74 | self.states_name = input_names[1] 75 | self.time_input_name = input_names[2] 76 | num_states = input_shapes[1][1] 77 | if not self.time_input: 78 | raise ValueError( 79 | "The ml model has 3 inputs, but the `usesTime` flag is set to `false` in the json interface. " 80 | "A model can only have 3 inputs if it uses time input." 81 | ) 82 | elif len(input_names) == 2: # noqa: PLR2004 83 | if self.time_input: 84 | self.time_input_name = input_names[1] 85 | else: 86 | self.states_name = input_names[1] 87 | num_states = input_shapes[1][1] 88 | 89 | elif not input_names or len(input_names) > 3: # noqa: PLR2004 90 | raise ValueError(f"The number of inputs to the ml model (={len(input_names)}) must be 1, 2 or 3") 91 | 92 | self.state_size = num_states 93 | 94 | def load_outputs(self) -> None: 95 | """Load the outputs from the ONNX file and assign the output name and size.""" 96 | # Get outputs from ONNX file 97 | outputs: list[Any] = self.__onnx_session.get_outputs() 98 | output_names = [out.name for out in outputs] 99 | 100 | if len(output_names) != 1: 101 | raise ValueError(f"The number of outputs from the ml model (={len(output_names)}) must be exactly 1") 102 | 103 | output_shapes = [out.shape for out in outputs] 104 | self.output_name = output_names[0] 105 | self.output_size = output_shapes[0][1] 106 | -------------------------------------------------------------------------------- /src/mlfmu/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utilities for mlfmu.""" 2 | -------------------------------------------------------------------------------- /src/mlfmu/utils/builder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import subprocess 5 | from pathlib import Path 6 | 7 | from pydantic import ValidationError 8 | 9 | from mlfmu.types.fmu_component import FmiModel, ModelComponent 10 | from mlfmu.types.onnx_model import ONNXModel 11 | from mlfmu.utils.fmi_builder import generate_model_description 12 | from mlfmu.utils.signals import range_list_expanded 13 | 14 | # Paths to files needed for build 15 | path_to_this_file = Path(__file__).resolve() 16 | absolute_path = path_to_this_file.parent.parent 17 | fmu_build_folder = absolute_path / "fmu_build" 18 | template_parent_path = fmu_build_folder / "templates" / "fmu" 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def format_template_file( 24 | template_path: Path, 25 | save_path: Path, 26 | data: dict[str, str], 27 | ) -> None: 28 | """ 29 | Replace all the template strings with their corresponding values and save to a new file. 30 | 31 | Args 32 | ---- 33 | template_path (Path): The path to the template file. 34 | save_path (Path): The path to save the formatted file. 35 | data (dict[str, str]): The data containing the values to replace in the template. 36 | """ 37 | # TODO: Need to check that these calls are safe from a cybersecurity point of view # noqa: TD002 38 | with Path.open(template_path, encoding="utf-8") as template_file: 39 | template_string = template_file.read() 40 | 41 | formatted_string = template_string.format(**data) 42 | with Path.open(save_path, "w", encoding="utf-8") as save_file: 43 | _ = save_file.write(formatted_string) 44 | 45 | 46 | def create_model_description( 47 | fmu: FmiModel, 48 | src_path: Path, 49 | ) -> None: 50 | """ 51 | Generate modelDescription.xml structure for FMU, and save it in a file. 52 | 53 | Args 54 | ---- 55 | fmu (FmiModel): The FMI model. 56 | src_path (Path): The path to save the model description file. 57 | """ 58 | xml_structure = generate_model_description(fmu_model=fmu) 59 | 60 | # Save in file 61 | xml_structure.write(src_path / "modelDescription.xml", encoding="utf-8") 62 | 63 | 64 | def make_fmu_dirs(src_path: Path) -> None: 65 | """ 66 | Create all the directories needed to put all the FMU files in. 67 | 68 | Args 69 | ---- 70 | src_path (Path): The path to the FMU source directory. 71 | """ 72 | sources_path = src_path / "sources" 73 | resources_path = src_path / "resources" 74 | sources_path.mkdir(parents=True, exist_ok=True) 75 | resources_path.mkdir(parents=True, exist_ok=True) 76 | 77 | 78 | def create_files_from_templates( 79 | data: dict[str, str], 80 | fmu_src: Path, 81 | ) -> None: 82 | """ 83 | Create and format all needed C++ files for FMU generation. 84 | 85 | Args 86 | ---- 87 | data (dict[str, str]): The data containing the values to format the template files. 88 | fmu_src (Path): The path to the FMU source directory. 89 | """ 90 | sources_path = fmu_src / "sources" 91 | file_names = ["fmu.cpp", "model_definitions.h"] 92 | 93 | paths = [ 94 | ( 95 | template_parent_path / "_template.".join(file_name.split(".")), 96 | sources_path / file_name, 97 | ) 98 | for file_name in file_names 99 | ] 100 | 101 | for template_path, save_path in paths: 102 | format_template_file(template_path, save_path, data) 103 | 104 | 105 | def format_template_data(onnx: ONNXModel, fmi_model: FmiModel, model_component: ModelComponent) -> dict[str, str]: 106 | """ 107 | Generate the key-value pairs needed to format the template files to valid C++. 108 | 109 | Args 110 | ---- 111 | onnx (ONNXModel): The ONNX model. 112 | fmi_model (FmiModel): The FMI model. 113 | model_component (ModelComponent): The model component. 114 | 115 | Returns 116 | ------- 117 | dict[str, str]: The formatted template data. 118 | """ 119 | # Work out template mapping between ONNX and FMU ports 120 | inputs, outputs, state_init = fmi_model.get_template_mapping() 121 | state_output_indexes = [ 122 | index for state in model_component.states for index in range_list_expanded(state.agent_output_indexes) 123 | ] 124 | 125 | # Total number of inputs/outputs/internal states 126 | num_fmu_variables = fmi_model.get_total_variable_number() 127 | num_fmu_inputs = len(inputs) 128 | num_fmu_outputs = len(outputs) 129 | num_onnx_states = len(state_output_indexes) 130 | num_onnx_state_init = len(state_init) 131 | 132 | # Checking compatibility between ModelComponent and ONNXModel 133 | if num_fmu_inputs > onnx.input_size: 134 | raise ValueError( 135 | "The number of total input indexes for all inputs and parameter in the interface file " 136 | f"(={num_fmu_inputs}) cannot exceed the input size of the ml model (={onnx.input_size})" 137 | ) 138 | 139 | if num_fmu_outputs > onnx.output_size: 140 | raise ValueError( 141 | "The number of total output indexes for all outputs in the interface file " 142 | f"(={num_fmu_outputs}) cannot exceed the output size of the ml model (={onnx.output_size})" 143 | ) 144 | if num_onnx_states > onnx.state_size: 145 | raise ValueError( 146 | "The number of total output indexes for all states in the interface file " 147 | f"(={num_onnx_states}) cannot exceed either the state input size (={onnx.state_size})" 148 | ) 149 | if num_onnx_state_init > onnx.state_size: 150 | raise ValueError( 151 | "The number of states that are initialized in the interface file " 152 | f"(={num_onnx_state_init}) cannot exceed either the state input size (={onnx.state_size})" 153 | ) 154 | 155 | # Flatten vectors to comply with template requirements 156 | # -> onnx-index, variable-reference, onnx-index, variable-reference ... 157 | flattened_input_string = ", ".join( 158 | [str(index) for indexValueReferencePair in inputs for index in indexValueReferencePair] 159 | ) 160 | flattened_output_string = ", ".join( 161 | [str(index) for indexValueReferencePair in outputs for index in indexValueReferencePair] 162 | ) 163 | flattened_state_string = ", ".join([str(index) for index in state_output_indexes]) 164 | flattened_state_init_string = ", ".join( 165 | [str(index) for indexValueReferencePair in state_init for index in indexValueReferencePair] 166 | ) 167 | 168 | template_data: dict[str, str] = { 169 | "numFmuVariables": str(num_fmu_variables), 170 | "FmuName": fmi_model.name, 171 | "numOnnxInputs": str(onnx.input_size), 172 | "numOnnxOutputs": str(onnx.output_size), 173 | "numOnnxStates": str(onnx.state_size), 174 | "numOnnxStateInit": str(num_onnx_state_init), 175 | "onnxUsesTime": "true" if onnx.time_input else "false", 176 | "onnxInputName": onnx.input_name, 177 | "onnxStatesName": onnx.states_name, 178 | "onnxTimeInputName": onnx.time_input_name, 179 | "onnxOutputName": onnx.output_name, 180 | "onnxFileName": onnx.filename, 181 | "numOnnxFmuInputs": str(num_fmu_inputs), 182 | "numOnnxFmuOutputs": str(num_fmu_outputs), 183 | "numOnnxStatesOutputs": str(num_onnx_states), 184 | "onnxInputValueReferences": flattened_input_string, 185 | "onnxOutputValueReferences": flattened_output_string, 186 | "onnxStateOutputIndexes": flattened_state_string, 187 | "onnxStateInitValueReferences": flattened_state_init_string, 188 | } 189 | 190 | return template_data 191 | 192 | 193 | def validate_interface_spec( 194 | spec: str, 195 | ) -> tuple[ValidationError | None, ModelComponent]: 196 | """ 197 | Parse and validate JSON data from the interface file. 198 | 199 | Args 200 | ---- 201 | spec (str): Contents of the JSON file. 202 | 203 | Returns 204 | ------- 205 | tuple[Optional[ValidationError], ModelComponent]: 206 | The validation error (if any) and the validated model component. 207 | The pydantic model instance that contains all the interface information. 208 | """ 209 | parsed_spec = ModelComponent.model_validate_json(json_data=spec, strict=True) 210 | try: 211 | validated_model = ModelComponent.model_validate(parsed_spec) 212 | except ValidationError as e: 213 | return e, parsed_spec 214 | 215 | return None, validated_model 216 | 217 | 218 | def generate_fmu_files( 219 | fmu_src_path: os.PathLike[str], 220 | onnx_path: os.PathLike[str], 221 | interface_spec_path: os.PathLike[str], 222 | ) -> FmiModel: 223 | """ 224 | Generate FMU files based on the FMU source, ONNX model, and interface specification. 225 | 226 | Args 227 | ---- 228 | fmu_src_path (os.PathLike[str]): The path to the FMU source directory. 229 | onnx_path (os.PathLike[str]): The path to the ONNX model file. 230 | interface_spec_path (os.PathLike[str]): The path to the interface specification file. 231 | 232 | Returns 233 | ------- 234 | FmiModel: The FMI model. 235 | """ 236 | # Create Path instances for the path to the spec and ONNX file. 237 | onnx_path = Path(onnx_path) 238 | interface_spec_path = Path(interface_spec_path) 239 | 240 | # Load JSON interface contents 241 | with Path.open(interface_spec_path, encoding="utf-8") as template_file: 242 | interface_contents = template_file.read() 243 | 244 | # Validate the FMU interface spec against expected Schema 245 | error, component_model = validate_interface_spec(interface_contents) 246 | 247 | if error: 248 | # Display error and finish workflow 249 | raise error 250 | 251 | # Create ONNXModel and FmiModel instances -> load some metadata 252 | onnx_model = ONNXModel(onnx_path=onnx_path, time_input=bool(component_model.uses_time)) 253 | fmi_model = FmiModel(model=component_model) 254 | fmu_source = Path(fmu_src_path) / fmi_model.name 255 | 256 | template_data = format_template_data(onnx=onnx_model, fmi_model=fmi_model, model_component=component_model) 257 | 258 | # Generate all FMU files 259 | make_fmu_dirs(fmu_source) 260 | create_files_from_templates(data=template_data, fmu_src=fmu_source) 261 | create_model_description(fmu=fmi_model, src_path=fmu_source) 262 | 263 | # Copy ONNX file and save it inside FMU folder 264 | _ = shutil.copyfile(src=onnx_path, dst=fmu_source / "resources" / onnx_model.filename) 265 | 266 | return fmi_model 267 | 268 | 269 | def validate_fmu_source_files(fmu_path: os.PathLike[str]) -> None: 270 | """ 271 | Validate the FMU source files. 272 | 273 | Args 274 | ---- 275 | fmu_path (os.PathLike[str]): The path to the FMU source directory. 276 | 277 | Raises 278 | ------ 279 | FileNotFoundError: If required files are missing in the FMU source directory. 280 | """ 281 | fmu_path = Path(fmu_path) 282 | 283 | files_should_exist: list[str] = [ 284 | "modelDescription.xml", 285 | "sources/fmu.cpp", 286 | "sources/model_definitions.h", 287 | ] 288 | 289 | if files_not_exists := [file for file in files_should_exist if not (fmu_path / file).is_file()]: 290 | raise FileNotFoundError( 291 | f"The files {files_not_exists} are not contained in the provided FMU source path ({fmu_path})" 292 | ) 293 | 294 | resources_dir = fmu_path / "resources" 295 | 296 | num_onnx_files = len(list(resources_dir.glob("*.onnx"))) 297 | 298 | if num_onnx_files < 1: 299 | raise FileNotFoundError( 300 | f"There is no *.onnx file in the resource folder in the provided FMU source path ({fmu_path})" 301 | ) 302 | 303 | 304 | def build_fmu( 305 | fmu_src_path: os.PathLike[str], 306 | fmu_build_path: os.PathLike[str], 307 | fmu_save_path: os.PathLike[str], 308 | ) -> None: 309 | """ 310 | Build the FMU. 311 | 312 | Args 313 | ---- 314 | fmu_src_path (os.PathLike[str]): The path to the FMU source directory. 315 | fmu_build_path (os.PathLike[str]): The path to the FMU build directory. 316 | fmu_save_path (os.PathLike[str]): The path to save the built FMU. 317 | 318 | Raises 319 | ------ 320 | FileNotFoundError: If required files are missing in the FMU source directory. 321 | """ 322 | fmu_src_path = Path(fmu_src_path) 323 | validate_fmu_source_files(fmu_src_path) 324 | fmu_name = fmu_src_path.stem 325 | conan_install_command = [ 326 | "conan", 327 | "install", 328 | ".", 329 | "-of", 330 | str(fmu_build_path), 331 | "-u", 332 | "-b", 333 | "missing", 334 | "-o", 335 | "shared=True", 336 | ] 337 | cmake_set_folders = [ 338 | f"-DCMAKE_BINARY_DIR={fmu_build_path!s}", 339 | f"-DFMU_OUTPUT_DIR={fmu_save_path!s}", 340 | f"-DFMU_NAMES={fmu_name}", 341 | f"-DFMU_SOURCE_PATH={fmu_src_path.parent!s}", 342 | ] 343 | # Windows vs Linux 344 | conan_preset = "conan-default" if os.name == "nt" else "conan-release" 345 | cmake_command = ["cmake", *cmake_set_folders, "--preset", conan_preset] 346 | cmake_build_command = ["cmake", "--build", ".", "-j", "14", "--config", "Release"] 347 | 348 | # Change directory to the build folder 349 | os.chdir(fmu_build_folder) 350 | 351 | # Run conan install, cmake, cmake build 352 | logger.debug("Builder: Run conan install") 353 | try: 354 | _ = subprocess.run(conan_install_command, check=True) # noqa: S603 355 | except subprocess.CalledProcessError: 356 | logger.exception("Exception in conan install: %s") 357 | 358 | logger.debug("Builder: Run cmake") 359 | try: 360 | _ = subprocess.run(cmake_command, check=True) # noqa: S603 361 | except subprocess.CalledProcessError: 362 | logger.exception("Exception in cmake: %s") 363 | 364 | os.chdir(fmu_build_path) 365 | logger.debug("Builder: Run cmake build") 366 | try: 367 | _ = subprocess.run(cmake_build_command, check=True) # noqa: S603 368 | except subprocess.CalledProcessError: 369 | logger.exception("Exception in cmake build: %s") 370 | 371 | logger.debug("Builder: Done with build_fmu") 372 | 373 | # Return to original working directory (leave build dir) 374 | os.chdir(absolute_path) 375 | -------------------------------------------------------------------------------- /src/mlfmu/utils/fmi_builder.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from importlib import metadata 4 | from xml.etree.ElementTree import Element, ElementTree, SubElement, indent 5 | 6 | from mlfmu.types.fmu_component import ( 7 | FmiCausality, 8 | FmiModel, 9 | FmiVariability, 10 | FmiVariable, 11 | ) 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def requires_start(var: FmiVariable) -> bool: 17 | """ 18 | Test if a variable requires a start attribute. 19 | 20 | Returns 21 | ------- 22 | True if successful, False otherwise 23 | """ 24 | return var.causality in (FmiCausality.INPUT, FmiCausality.PARAMETER) or var.variability == FmiVariability.CONSTANT 25 | 26 | 27 | def generate_model_description(fmu_model: FmiModel) -> ElementTree: 28 | """ 29 | Generate FMU modelDescription as XML. 30 | 31 | Args 32 | ---- 33 | fmu_model (FmiModel): Object representation of FMI slave instance 34 | 35 | Returns 36 | ------- 37 | xml.etree.TreeElement.Element: modelDescription XML representation. 38 | """ 39 | t = datetime.datetime.now(datetime.timezone.utc) 40 | date_str = t.isoformat(timespec="seconds") 41 | TOOL_VERSION = metadata.version("mlfmu") # noqa: N806 42 | 43 | # Root tag 44 | model_description = { 45 | "fmiVersion": "2.0", 46 | "modelName": fmu_model.name, 47 | "guid": f"{fmu_model.guid!s}" if fmu_model.guid is not None else "@FMU_UUID@", 48 | "version": fmu_model.version, 49 | "generationDateAndTime": date_str, 50 | "variableNamingConvention": "structured", 51 | "generationTool": f"MLFMU {TOOL_VERSION}", 52 | } 53 | 54 | # Optional props 55 | if fmu_model.copyright is not None: 56 | model_description["copyright"] = fmu_model.copyright 57 | if fmu_model.license is not None: 58 | model_description["license"] = fmu_model.license 59 | if fmu_model.author is not None: 60 | model_description["author"] = fmu_model.author 61 | if fmu_model.description is not None: 62 | model_description["description"] = fmu_model.description 63 | 64 | root = Element("fmiModelDescription", model_description) 65 | 66 | # tag options 67 | cosim_options = { 68 | "modelIdentifier": fmu_model.name, 69 | "canHandleVariableCommunicationStepSize": "true", 70 | } 71 | _ = SubElement(root, "CoSimulation", attrib=cosim_options) 72 | 73 | # tag -> Append inputs/parameters/outputs 74 | variables = SubElement(root, "ModelVariables") 75 | 76 | # tag with tab inside --> Append all outputs 77 | model_structure = SubElement(root, "ModelStructure") 78 | outputs = SubElement(model_structure, "Outputs") 79 | initial_unknowns = SubElement(model_structure, "InitialUnknowns") 80 | 81 | # Get all variables to add them inside the tag 82 | model_variables = fmu_model.get_fmi_model_variables() 83 | 84 | # The variables needs to be added in the order of their valueReference 85 | sorted_model_variables = sorted(model_variables, key=lambda x: x.variable_reference) 86 | 87 | # Add each variable inside the tag 88 | for var in sorted_model_variables: 89 | # XML variable attributes 90 | var_attrs = { 91 | "name": var.name, 92 | "valueReference": str(var.variable_reference), 93 | "causality": var.causality.value, 94 | "description": var.description or "", 95 | "variability": var.variability.value if var.variability else FmiVariability.CONTINUOUS.value, 96 | } 97 | var_elem = SubElement(variables, "ScalarVariable", var_attrs) 98 | 99 | var_type_attrs = {} 100 | if requires_start(var): 101 | var_type_attrs["start"] = str(var.start_value) 102 | 103 | # FMI variable type element 104 | _ = SubElement(var_elem, var.type.value.capitalize(), var_type_attrs) 105 | 106 | # Adding outputs inside 107 | if var.causality == FmiCausality.OUTPUT: 108 | # Index is 1-indexed for tag 109 | unknown_attributes = {"index": str(var.variable_reference + 1)} 110 | # For each output create an tag inside both and 111 | _ = SubElement(outputs, "Unknown", unknown_attributes) 112 | _ = SubElement(initial_unknowns, "Unknown", unknown_attributes) 113 | 114 | # Create XML tree containing root element and pretty format its contents 115 | xml_tree = ElementTree(root) 116 | indent(xml_tree, space="\t", level=0) 117 | return xml_tree 118 | -------------------------------------------------------------------------------- /src/mlfmu/utils/interface.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | from typing import cast 5 | 6 | from dictIO.utils.path import relative_path 7 | from json_schema_for_humans.generate import ( 8 | SchemaToRender, 9 | TemplateRenderer, 10 | generate_schemas_doc, 11 | ) 12 | from json_schema_for_humans.generation_configuration import GenerationConfiguration 13 | from pydantic import BaseModel 14 | from pydantic._internal._model_construction import ModelMetaclass 15 | 16 | from mlfmu.types.fmu_component import ModelComponent 17 | 18 | __ALL__ = ["publish_interface_schema"] 19 | 20 | 21 | def generate_interface_schema( 22 | model: BaseModel | ModelMetaclass, 23 | schema_dir: str | os.PathLike[str] | None = None, 24 | ) -> None: 25 | """ 26 | Generate a JSON interface schema file for the given model. 27 | 28 | Args 29 | ---- 30 | model (BaseModel | ModelMetaclass): The pydantic model for which to generate the schema. 31 | schema_dir (str | os.PathLike[str], optional): 32 | The directory where the schema file will be saved. Defaults to None. 33 | """ 34 | schema_dir_default = Path.cwd() / "docs/schema" 35 | schema_dir = schema_dir or schema_dir_default 36 | # Make sure schema_dir argument is of type Path. If not, cast it to Path type. 37 | schema_dir = schema_dir if isinstance(schema_dir, Path) else Path(schema_dir) 38 | 39 | # Assert model argument is a pydantic BaseModel 40 | # Background: ModelMetaClass is added just to please static type checking, 41 | # which would otherwise complain. 42 | # Behind the scenes in pdyantic, models always inherit the attributes of BaseModel. 43 | if not hasattr(model, "model_json_schema"): 44 | raise ValueError("model argument must be a pydantic BaseModel") 45 | model = cast(BaseModel, model) 46 | 47 | # Create schema_dir if it does not exist 48 | schema_dir.mkdir(parents=True, exist_ok=True) 49 | 50 | json_file: Path = schema_dir / "schema.json" 51 | schema = json.dumps(model.model_json_schema(by_alias=True), indent=4) 52 | 53 | with Path.open(json_file, "w", encoding="utf-8") as f: 54 | _ = f.write(schema) 55 | 56 | return 57 | 58 | 59 | def generate_interface_docs( 60 | schema_dir: str | os.PathLike[str] | None = None, 61 | docs_dir: str | os.PathLike[str] | None = None, 62 | ) -> None: 63 | """ 64 | Generate HTML documentation for the JSON interface schema files in the schema directory. 65 | 66 | Args 67 | ---- 68 | schema_dir (str | os.PathLike[str], optional): 69 | The directory where the schema files are located. Defaults to None. 70 | docs_dir (str | os.PathLike[str], optional): 71 | The directory where the documentation files will be saved. Defaults to None. 72 | """ 73 | schema_dir_default = Path.cwd() / "docs/schema" 74 | schema_dir = schema_dir or schema_dir_default 75 | 76 | docs_dir_default = Path.cwd() / "docs/interface" 77 | docs_dir = docs_dir or docs_dir_default 78 | 79 | # Make sure schema_dir and docs_dir are of type Path. If not, cast it to Path type. 80 | schema_dir = schema_dir if isinstance(schema_dir, Path) else Path(schema_dir) 81 | docs_dir = docs_dir if isinstance(docs_dir, Path) else Path(docs_dir) 82 | 83 | # Create dirs in case they don't exist 84 | schema_dir.mkdir(parents=True, exist_ok=True) 85 | docs_dir.mkdir(parents=True, exist_ok=True) 86 | 87 | # Collect all schemata in schema dir 88 | pattern: str = "**.json" 89 | schemata: list[Path] = list(schema_dir.glob(pattern)) 90 | 91 | # Generate html documentation for schemata 92 | config: GenerationConfiguration = GenerationConfiguration( 93 | template_name="js", 94 | expand_buttons=True, 95 | link_to_reused_ref=False, 96 | show_breadcrumbs=False, 97 | ) 98 | 99 | schemas_to_render: list[SchemaToRender] = [] 100 | 101 | for schema in schemata: 102 | rel_path: Path = relative_path(from_path=schema_dir, to_path=schema.parent) 103 | name: str = schema.stem 104 | html_file: Path = docs_dir / rel_path / f"{name}.html" 105 | schema_to_render: SchemaToRender = SchemaToRender( 106 | schema_file=schema, 107 | result_file=html_file, 108 | output_dir=None, 109 | ) 110 | schemas_to_render.append(schema_to_render) 111 | 112 | _ = generate_schemas_doc( 113 | schemas_to_render=schemas_to_render, 114 | template_renderer=TemplateRenderer(config), 115 | ) 116 | 117 | 118 | def publish_interface_schema( 119 | schema_dir: str | os.PathLike[str] | None = None, 120 | docs_dir: str | os.PathLike[str] | None = None, 121 | ) -> None: 122 | """ 123 | Publish the JSON schema and HTML documentation for the interface. 124 | 125 | Args 126 | ---- 127 | schema_dir (str | os.PathLike[str], optional): 128 | The directory where the schema file will be saved. Defaults to None. 129 | docs_dir (str | os.PathLike[str], optional): 130 | The directory where the documentation files will be saved. Defaults to None. 131 | """ 132 | # Generate JSON schema 133 | generate_interface_schema(model=ModelComponent, schema_dir=schema_dir) 134 | 135 | # Generate documentation HTML 136 | generate_interface_docs(schema_dir=schema_dir, docs_dir=docs_dir) 137 | -------------------------------------------------------------------------------- /src/mlfmu/utils/logging.py: -------------------------------------------------------------------------------- 1 | """Functions to configure logging for the application.""" 2 | 3 | import logging 4 | import sys 5 | from pathlib import Path 6 | 7 | __all__ = ["configure_logging"] 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def configure_logging( 13 | log_level_console: str = "WARNING", 14 | log_file: Path | None = None, 15 | log_level_file: str = "WARNING", 16 | ) -> None: 17 | """Configure logging for the application, allowing for both console and file logging. 18 | 19 | Sets the log levels and formats for the output, ensuring that logs are captured as specified. 20 | 21 | Args: 22 | log_level_console (str): The logging level for console output. Defaults to "WARNING". 23 | log_file (Path | None): The path to the log file. If None, file logging is disabled. Defaults to None. 24 | log_level_file (str): The logging level for file output. Defaults to "WARNING". 25 | 26 | Raises 27 | ------ 28 | TypeError: If the provided log levels are invalid. 29 | 30 | Examples 31 | -------- 32 | configure_logging(log_level_console="INFO", log_file=Path("app.log"), log_level_file="DEBUG") 33 | """ 34 | # sourcery skip: extract-duplicate-method, extract-method 35 | log_level_console_numeric = getattr(logging, log_level_console.upper(), None) 36 | if not isinstance(log_level_console_numeric, int): 37 | raise TypeError(f"Invalid log level to console: {log_level_console_numeric}") 38 | 39 | log_level_file_numeric = getattr(logging, log_level_file.upper(), None) 40 | if not isinstance(log_level_file_numeric, int): 41 | raise TypeError(f"Invalid log level to file: {log_level_file_numeric}") 42 | 43 | root_logger = logging.getLogger() 44 | root_logger.setLevel(logging.DEBUG) 45 | 46 | console_handler = logging.StreamHandler(sys.stdout) 47 | console_handler.setLevel(log_level_console_numeric) 48 | console_formatter = logging.Formatter("%(levelname)-8s %(message)s") 49 | console_handler.setFormatter(console_formatter) 50 | root_logger.addHandler(console_handler) 51 | 52 | if log_file: 53 | if not log_file.parent.exists(): 54 | log_file.parent.mkdir(parents=True, exist_ok=True) 55 | file_handler = logging.FileHandler(str(log_file.absolute()), "a") 56 | print(f"Logging to: {log_file.absolute()}") # noqa: T201 57 | file_handler.setLevel(log_level_file_numeric) 58 | file_formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(message)s", "%Y-%m-%d %H:%M:%S") 59 | file_handler.setFormatter(file_formatter) 60 | root_logger.addHandler(file_handler) 61 | 62 | logging.getLogger("mlfmu").setLevel(logging.WARNING) 63 | 64 | return 65 | -------------------------------------------------------------------------------- /src/mlfmu/utils/path.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | 5 | def find_default_file( 6 | folder: Path, 7 | file_extension: str, 8 | default_name: str | None = None, 9 | ) -> Path | None: 10 | """Return a file inside folder with the file extension that matches file_extension. 11 | 12 | If there are multiple matches it uses the closest match to default_name if given. 13 | Return None if there is no clear match. 14 | 15 | Parameters 16 | ---------- 17 | folder : Path 18 | the folder to search in 19 | file_extension : str 20 | file extension to search for 21 | default_name : str | None, optional 22 | file name used to determine "closest match" 23 | in case multiple files match file_extension, by default None 24 | 25 | Returns 26 | ------- 27 | Path | None 28 | the path to the file if it is found, otherwise None 29 | """ 30 | # Check if there is a file with correct file extension in current working directory. If it exists use it. 31 | matching_files: list[Path] = [] 32 | 33 | for file in os.listdir(folder): 34 | file_path = folder / file 35 | if file_path.is_file() and file_path.suffix.lstrip(".") == file_extension: 36 | matching_files.append(file_path) 37 | 38 | if not matching_files: 39 | return None 40 | 41 | if len(matching_files) == 1: 42 | return matching_files[0] 43 | 44 | # If there are more matches on file extension. Use the one that matches the default name 45 | if default_name is None: 46 | return None 47 | 48 | name_matches = [file for file in matching_files if default_name in file.stem] 49 | 50 | if not name_matches: 51 | return None 52 | 53 | if len(name_matches) == 1: 54 | return name_matches[0] 55 | 56 | # If more multiple name matches use the exact match if it exists 57 | name_exact_matches = [file for file in matching_files if default_name == file.stem] 58 | 59 | return name_matches[0] if len(name_exact_matches) == 1 else None 60 | -------------------------------------------------------------------------------- /src/mlfmu/utils/signals.py: -------------------------------------------------------------------------------- 1 | def range_list_expanded(list_of_ranges: list[str]) -> list[int]: 2 | """ 3 | Expand ranges specified in interface. They should be formatted as [starts_index:end_index]. 4 | 5 | Args 6 | ---- 7 | list_of_ranges (List[str]): List of indexes or ranges. 8 | 9 | Returns 10 | ------- 11 | A list of all indexes covered in ranges or individual indexes 12 | """ 13 | indexes: list[int] = [] 14 | 15 | for val in list_of_ranges: 16 | if ":" in val: 17 | indexes.extend(range(*[int(num) for num in val.split(":")])) 18 | else: 19 | indexes.append(int(val)) 20 | 21 | return indexes 22 | -------------------------------------------------------------------------------- /src/mlfmu/utils/strings.py: -------------------------------------------------------------------------------- 1 | def to_camel(string: str) -> str: 2 | """Change casing of string to CamelCase.""" 3 | words = string.split("_") 4 | camel_string: str = words[0] + "".join(word.capitalize() for word in words[1:]) 5 | return camel_string 6 | -------------------------------------------------------------------------------- /stubs/onnxruntime/__init__.pyi: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Sequence 3 | from typing import Any 4 | 5 | class Dummy: ... 6 | 7 | class NodeArg: 8 | name: str 9 | shape: list[int] 10 | 11 | class Session: ... 12 | class SessionOptions: ... 13 | 14 | class InferenceSession(Session): 15 | def __init__( 16 | self, 17 | path_or_bytes: str | bytes | os.PathLike[str], 18 | sess_options: SessionOptions | None = None, 19 | providers: Sequence[str | tuple[str, dict[Any, Any]]] | None = None, 20 | provider_options: Sequence[dict[Any, Any]] | None = None, 21 | **kwargs: Any, 22 | ) -> None: ... 23 | def get_inputs(self) -> list[NodeArg]: ... 24 | def get_outputs(self) -> list[NodeArg]: ... 25 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | temp_* 2 | ~$temp* -------------------------------------------------------------------------------- /tests/cli/test_mlfmu_cli.py: -------------------------------------------------------------------------------- 1 | # pyright: reportPrivateUsage=false 2 | import sys 3 | from argparse import ArgumentError 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from mlfmu.api import MlFmuCommand 10 | from mlfmu.cli import mlfmu 11 | from mlfmu.cli.mlfmu import _argparser, main 12 | 13 | # *****Test commandline interface (CLI)************************************************************ 14 | 15 | 16 | @dataclass() 17 | class CliArgs: 18 | # Expected default values for the CLI arguments when mlfmu gets called via the commandline 19 | quiet: bool = False 20 | verbose: bool = False 21 | log: str | None = None 22 | log_level: str = field(default_factory=lambda: "WARNING") 23 | command: str = "" 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "inputs, expected", 28 | [ 29 | ([], ArgumentError), 30 | (["asd"], ArgumentError), 31 | (["build", "-q"], CliArgs(quiet=True, command="build")), 32 | (["build", "--quiet"], CliArgs(quiet=True, command="build")), 33 | (["build", "-v"], CliArgs(verbose=True, command="build")), 34 | (["build", "--verbose"], CliArgs(verbose=True, command="build")), 35 | (["build", "-qv"], ArgumentError), 36 | (["build", "--log", "logFile"], CliArgs(log="logFile", command="build")), 37 | (["build", "--log"], ArgumentError), 38 | (["build", "--log-level", "INFO"], CliArgs(log_level="INFO", command="build")), 39 | (["build", "--log-level"], ArgumentError), 40 | (["build", "-o"], ArgumentError), 41 | ], 42 | ) 43 | def test_cli( 44 | inputs: list[str], 45 | expected: CliArgs | type, 46 | monkeypatch: pytest.MonkeyPatch, 47 | ): 48 | """ 49 | Test the command-line interface (CLI) of the 'mlfmu' program. 50 | 51 | Args 52 | ---- 53 | inputs (List[str]): A list of input arguments to be passed to the CLI. 54 | expected (Union[CliArgs, type]): The expected output of the CLI. 55 | It can be either an instance of the `CliArgs` class or a subclass of `Exception`. 56 | monkeypatch (pytest.MonkeyPatch): A pytest fixture that allows patching of objects at runtime. 57 | 58 | Raises 59 | ------ 60 | AssertionError: If the `expected` argument is neither an instance of `CliArgs` 61 | nor a subclass of `Exception`. 62 | """ 63 | 64 | # sourcery skip: no-conditionals-in-tests 65 | # sourcery skip: no-loop-in-tests 66 | 67 | # Prepare 68 | monkeypatch.setattr(sys, "argv", ["mlfmu", *inputs]) 69 | parser = _argparser() 70 | 71 | # Execute 72 | if isinstance(expected, CliArgs): 73 | args_expected: CliArgs = expected 74 | args = parser.parse_args() 75 | 76 | # Assert args 77 | for key in args_expected.__dataclass_fields__: 78 | assert args.__getattribute__(key) == args_expected.__getattribute__(key) 79 | elif issubclass(expected, Exception): 80 | exception: type = expected 81 | 82 | # Assert that expected exception is raised 83 | with pytest.raises((exception, SystemExit)): 84 | args = parser.parse_args() 85 | else: 86 | raise AssertionError 87 | 88 | 89 | # *****Ensure the CLI correctly configures logging************************************************* 90 | 91 | 92 | @dataclass() 93 | class ConfigureLoggingArgs: 94 | # Values that main() is expected to pass to ConfigureLogging() by default when configuring the logging 95 | log_level_console: str = field(default_factory=lambda: "WARNING") 96 | log_file: Path | None = None 97 | log_level_file: str = field(default_factory=lambda: "WARNING") 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "inputs, expected", 102 | [ 103 | ([], ArgumentError), 104 | (["build"], ConfigureLoggingArgs()), 105 | (["build", "-q"], ConfigureLoggingArgs(log_level_console="ERROR")), 106 | ( 107 | ["build", "--quiet"], 108 | ConfigureLoggingArgs(log_level_console="ERROR"), 109 | ), 110 | (["build", "-v"], ConfigureLoggingArgs(log_level_console="INFO")), 111 | ( 112 | ["build", "--verbose"], 113 | ConfigureLoggingArgs(log_level_console="INFO"), 114 | ), 115 | (["build", "-qv"], ArgumentError), 116 | ( 117 | ["build", "--log", "logFile"], 118 | ConfigureLoggingArgs(log_file=Path("logFile")), 119 | ), 120 | (["build", "--log"], ArgumentError), 121 | ( 122 | ["build", "--log-level", "INFO"], 123 | ConfigureLoggingArgs(log_level_file="INFO"), 124 | ), 125 | (["build", "--log-level"], ArgumentError), 126 | ], 127 | ) 128 | def test_logging_configuration( 129 | inputs: list[str], 130 | expected: ConfigureLoggingArgs | type, 131 | monkeypatch: pytest.MonkeyPatch, 132 | ): 133 | """ 134 | Test the logging configuration of the `main` function in the `mlfmu` module. 135 | 136 | Args 137 | ---- 138 | inputs (List[str]): The list of input arguments to be passed to the `main` function. 139 | expected (Union[ConfigureLoggingArgs, type]): The expected output of the `main` function. 140 | It can be an instance of `ConfigureLoggingArgs` or a subclass of `Exception`. 141 | monkeypatch (pytest.MonkeyPatch): The monkeypatch fixture provided by pytest. 142 | 143 | Raises 144 | ---- 145 | AssertionError: If the `expected` argument is neither an instance of `ConfigureLoggingArgs` 146 | nor a subclass of `Exception`. 147 | """ 148 | 149 | # sourcery skip: no-conditionals-in-tests 150 | # sourcery skip: no-loop-in-tests 151 | 152 | # Prepare 153 | monkeypatch.setattr(sys, "argv", ["mlfmu", *inputs]) 154 | args: ConfigureLoggingArgs = ConfigureLoggingArgs() 155 | 156 | def fake_configure_logging( 157 | log_level_console: str, 158 | log_file: Path | None, 159 | log_level_file: str, 160 | ): 161 | args.log_level_console = log_level_console 162 | args.log_file = log_file 163 | args.log_level_file = log_level_file 164 | 165 | def fake_run( 166 | command: str, 167 | interface_file: str | None, 168 | model_file: str | None, 169 | fmu_path: str | None, 170 | source_folder: str | None, 171 | ): 172 | pass 173 | 174 | monkeypatch.setattr(mlfmu, "configure_logging", fake_configure_logging) 175 | monkeypatch.setattr(mlfmu, "run", fake_run) 176 | # Execute 177 | if isinstance(expected, ConfigureLoggingArgs): 178 | args_expected: ConfigureLoggingArgs = expected 179 | main() 180 | # Assert args 181 | for key in args_expected.__dataclass_fields__: 182 | assert args.__getattribute__(key) == args_expected.__getattribute__(key) 183 | elif issubclass(expected, Exception): 184 | exception: type = expected 185 | # Assert that expected exception is raised 186 | with pytest.raises((exception, SystemExit)): 187 | main() 188 | else: 189 | raise AssertionError 190 | 191 | 192 | # *****Ensure the CLI correctly invokes the API**************************************************** 193 | 194 | 195 | @dataclass() 196 | class ApiArgs: 197 | # Values that main() is expected to pass to run() by default when invoking the API 198 | command: MlFmuCommand | None = None 199 | interface_file: str | None = None 200 | model_file: str | None = None 201 | fmu_path: str | None = None 202 | source_folder: str | None = None 203 | 204 | 205 | @pytest.mark.parametrize( 206 | "inputs, expected", 207 | [ 208 | ([], ArgumentError), 209 | (["build"], ApiArgs()), 210 | ], 211 | ) 212 | def test_api_invokation( 213 | inputs: list[str], 214 | expected: ApiArgs | type, 215 | monkeypatch: pytest.MonkeyPatch, 216 | ): 217 | # sourcery skip: no-conditionals-in-tests 218 | # sourcery skip: no-loop-in-tests 219 | """ 220 | Test the invocation of the API function. 221 | 222 | Args 223 | ---- 224 | inputs (List[str]): The list of input arguments. 225 | expected (Union[ApiArgs, type]): The expected output, either an instance of ApiArgs or an exception type. 226 | monkeypatch (pytest.MonkeyPatch): The monkeypatch object for patching sys.argv. 227 | 228 | Raises 229 | ---- 230 | AssertionError: If the expected output is neither an instance of ApiArgs nor an exception type. 231 | """ 232 | 233 | # Prepare 234 | monkeypatch.setattr(sys, "argv", ["mlfmu", *inputs]) 235 | args: ApiArgs = ApiArgs() 236 | 237 | def fake_run( 238 | command: str, 239 | interface_file: str | None, 240 | model_file: str | None, 241 | fmu_path: str | None, 242 | source_folder: str | None, 243 | ): 244 | args.command = MlFmuCommand.from_string(command) 245 | args.interface_file = interface_file 246 | args.model_file = model_file 247 | args.fmu_path = fmu_path 248 | args.source_folder = source_folder 249 | 250 | monkeypatch.setattr(mlfmu, "run", fake_run) 251 | # Execute 252 | if isinstance(expected, ApiArgs): 253 | args_expected: ApiArgs = expected 254 | main() 255 | # Assert args 256 | for key in args_expected.__dataclass_fields__: 257 | assert args.__getattribute__(key) == args_expected.__getattribute__(key) 258 | elif issubclass(expected, Exception): 259 | exception: type = expected 260 | # Assert that expected exception is raised 261 | with pytest.raises((exception, SystemExit)): 262 | main() 263 | else: 264 | raise AssertionError 265 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | from shutil import rmtree 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture(scope="package", autouse=True) 10 | def chdir() -> None: 11 | """ 12 | Fixture that changes the current working directory to the 'test_working_directory' folder. 13 | This fixture is automatically used for the entire package. 14 | """ 15 | os.chdir(Path(__file__).parent.absolute() / "test_working_directory") 16 | 17 | 18 | @pytest.fixture(scope="package", autouse=True) 19 | def test_dir() -> Path: 20 | """ 21 | Fixture that returns the absolute path of the directory containing the current file. 22 | This fixture is automatically used for the entire package. 23 | """ 24 | return Path(__file__).parent.absolute() 25 | 26 | 27 | output_dirs = [ 28 | "results", 29 | ] 30 | output_files = [ 31 | "*test*.pdf", 32 | ] 33 | 34 | 35 | @pytest.fixture(autouse=True) 36 | def default_setup_and_teardown(): 37 | """ 38 | Fixture that performs setup and teardown actions before and after each test function. 39 | It removes the output directories and files specified in 'output_dirs' and 'output_files' lists. 40 | """ 41 | _remove_output_dirs_and_files() 42 | yield 43 | _remove_output_dirs_and_files() 44 | 45 | 46 | def _remove_output_dirs_and_files() -> None: 47 | """ 48 | Helper function that removes the output directories and files specified in 'output_dirs' and 'output_files' lists. 49 | """ 50 | for folder in output_dirs: 51 | rmtree(folder, ignore_errors=True) 52 | for pattern in output_files: 53 | for file in Path.cwd().glob(pattern): 54 | _file = Path(file) 55 | _file.unlink(missing_ok=True) 56 | 57 | 58 | @pytest.fixture(autouse=True) 59 | def setup_logging(caplog: pytest.LogCaptureFixture) -> None: 60 | """ 61 | Fixture that sets up logging for each test function. 62 | It sets the log level to 'INFO' and clears the log capture. 63 | """ 64 | caplog.set_level("INFO") 65 | caplog.clear() 66 | 67 | 68 | @pytest.fixture(autouse=True) 69 | def logger() -> logging.Logger: 70 | """Fixture that returns the logger object.""" 71 | return logging.getLogger() 72 | -------------------------------------------------------------------------------- /tests/data/example.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnv-opensource/mlfmu/4fdbc85ca1a75d5a74caa72f52608a074586d674/tests/data/example.onnx -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # pyright: reportPrivateUsage=false 2 | -------------------------------------------------------------------------------- /tests/test_working_directory/.gitignore: -------------------------------------------------------------------------------- 1 | *test*.pdf 2 | -------------------------------------------------------------------------------- /tests/test_working_directory/test_config_file: -------------------------------------------------------------------------------- 1 | max_number_of_runs 3; -------------------------------------------------------------------------------- /tests/test_working_directory/test_config_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_number_of_runs":3 3 | } -------------------------------------------------------------------------------- /tests/test_working_directory/test_config_file_empty: -------------------------------------------------------------------------------- 1 | // empty -------------------------------------------------------------------------------- /tests/test_working_directory/test_config_file_empty.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tests/types/test_fmu_component.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/utils/test_fmu_template.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from mlfmu.types.fmu_component import FmiModel 8 | from mlfmu.types.onnx_model import ONNXModel 9 | from mlfmu.utils.builder import format_template_data, validate_interface_spec 10 | 11 | 12 | @pytest.fixture(scope="session") 13 | def wind_generator_onnx() -> ONNXModel: 14 | return ONNXModel(Path.cwd().parent / "data" / "example.onnx", time_input=True) 15 | 16 | 17 | def test_valid_template_data(wind_generator_onnx: ONNXModel): 18 | valid_spec = { 19 | "name": "example", 20 | "version": "1.0", 21 | "inputs": [ 22 | {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} 23 | ], 24 | "outputs": [ 25 | { 26 | "name": "outputs", 27 | "description": "My outputs", 28 | "agentOutputIndexes": ["0:2"], 29 | "isArray": True, 30 | "length": 2, 31 | } 32 | ], 33 | "states": [ 34 | {"agentOutputIndexes": ["2:130"]}, 35 | {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, 36 | {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, 37 | ], 38 | } 39 | _, model = validate_interface_spec(json.dumps(valid_spec)) 40 | assert model is not None 41 | 42 | fmi_model = FmiModel(model=model) 43 | template_data = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) 44 | 45 | assert template_data["FmuName"] == "example" 46 | assert template_data["numFmuVariables"] == "6" 47 | assert template_data["numOnnxInputs"] == "2" 48 | assert template_data["numOnnxOutputs"] == "130" 49 | assert template_data["numOnnxStates"] == "130" 50 | assert template_data["onnxInputValueReferences"] == "0, 0, 1, 1" 51 | assert template_data["onnxOutputValueReferences"] == "0, 2, 1, 3" 52 | 53 | 54 | def test_template_data_invalid_input_size(wind_generator_onnx: ONNXModel): 55 | valid_spec = { 56 | "name": "example", 57 | "version": "1.0", 58 | "inputs": [ 59 | {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2}, 60 | { 61 | "name": "inputs2", 62 | "description": "My inputs 2", 63 | "agentInputIndexes": ["0:10"], 64 | "isArray": True, 65 | "length": 10, 66 | }, 67 | ], 68 | "outputs": [ 69 | {"name": "outputs", "description": "My outputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} 70 | ], 71 | "states": [ 72 | {"agentOutputIndexes": ["2:130"]}, 73 | {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, 74 | {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, 75 | ], 76 | } 77 | 78 | _, model = validate_interface_spec(json.dumps(valid_spec)) 79 | assert model is not None 80 | 81 | fmi_model = FmiModel(model=model) 82 | 83 | with pytest.raises(ValueError) as exc_info: 84 | _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) 85 | 86 | assert exc_info.match( 87 | re.escape( 88 | "The number of total input indexes for all inputs and parameter in the interface file (=12) \ 89 | cannot exceed the input size of the ml model (=2)" 90 | ) 91 | ) 92 | 93 | 94 | def test_template_data_invalid_output_size(wind_generator_onnx: ONNXModel): 95 | valid_spec = { 96 | "name": "example", 97 | "version": "1.0", 98 | "inputs": [ 99 | {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} 100 | ], 101 | "outputs": [ 102 | { 103 | "name": "outputs", 104 | "description": "My outputs", 105 | "agentOutputIndexes": ["0:2"], 106 | "isArray": True, 107 | "length": 2, 108 | }, 109 | { 110 | "name": "outputs2", 111 | "description": "My outputs 2", 112 | "agentOutputIndexes": ["0:200"], 113 | "isArray": True, 114 | "length": 200, 115 | }, 116 | ], 117 | "states": [ 118 | {"agentOutputIndexes": ["2:130"]}, 119 | {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, 120 | {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, 121 | ], 122 | } 123 | 124 | _, model = validate_interface_spec(json.dumps(valid_spec)) 125 | fmi_model = FmiModel(model=model) 126 | 127 | with pytest.raises(ValueError) as exc_info: 128 | _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) 129 | 130 | assert exc_info.match( 131 | re.escape( 132 | "The number of total output indexes for all outputs in the interface file (=202) \ 133 | cannot exceed the output size of the ml model (=130)" 134 | ) 135 | ) 136 | 137 | 138 | def test_template_data_invalid_state_size(wind_generator_onnx: ONNXModel): 139 | valid_spec = { 140 | "name": "example", 141 | "version": "1.0", 142 | "inputs": [ 143 | {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} 144 | ], 145 | "outputs": [ 146 | {"name": "outputs", "description": "My outputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} 147 | ], 148 | "states": [ 149 | {"agentOutputIndexes": ["2:200"]}, 150 | ], 151 | } 152 | 153 | _, model = validate_interface_spec(json.dumps(valid_spec)) 154 | assert model is not None 155 | 156 | fmi_model = FmiModel(model=model) 157 | 158 | with pytest.raises(ValueError) as exc_info: 159 | _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) 160 | 161 | assert exc_info.match( 162 | re.escape( 163 | "The number of total output indexes for all states in the interface file (=198) \ 164 | cannot exceed either the state input size (=130)" 165 | ) 166 | ) 167 | -------------------------------------------------------------------------------- /tests/utils/test_interface_validation.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from mlfmu.types.fmu_component import ModelComponent 7 | from mlfmu.utils.builder import validate_interface_spec 8 | 9 | 10 | def test_validate_simple_interface_spec(): 11 | # Assuming validate_interface_spec takes a dictionary as input 12 | valid_spec = { 13 | "name": "example", 14 | "version": "1.0", 15 | "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], 16 | "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], 17 | } 18 | error, model = validate_interface_spec(json.dumps(valid_spec)) 19 | assert error is None 20 | assert isinstance(model, ModelComponent) 21 | assert model.name == "example" 22 | assert model.version == "1.0" 23 | assert model.inputs[0].name == "input1" 24 | assert model.inputs[0].type == "integer" 25 | assert model.outputs[0].name == "output1" 26 | assert model.outputs[0].type == "real" 27 | 28 | 29 | def test_validate_interface_spec_wrong_types(): 30 | # Assuming validate_interface_spec returns False for invalid specs 31 | invalid_spec = { 32 | "name": "example", 33 | "version": "1.0", 34 | "inputs": [{"name": "input1", "type": "enum"}], # Missing enum type 35 | "outputs": [{"name": "output1", "type": "int"}], # Should be integer 36 | } 37 | 38 | with pytest.raises(ValidationError) as exc_info: 39 | _, _ = validate_interface_spec(json.dumps(invalid_spec)) 40 | 41 | # Model type error as it's missing the agentInputIndexes 42 | assert exc_info.match("Input should be 'real', 'integer', 'string' or 'boolean'") 43 | 44 | 45 | def test_validate_unnamed_spec(): 46 | invalid_spec = { 47 | "version": "1.0", 48 | "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], 49 | "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], 50 | } 51 | 52 | with pytest.raises(ValidationError) as exc_info: 53 | _, _ = validate_interface_spec(json.dumps(invalid_spec)) 54 | 55 | assert exc_info.match("Field required") 56 | 57 | 58 | def test_validate_invalid_agent_indices(): 59 | invalid_spec = { 60 | "name": "example", 61 | "version": "1.0", 62 | "inputs": [ 63 | {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": [0, ":10", "10.0"]} 64 | ], # Should be a stringified list of integers 65 | "outputs": [ 66 | {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:a"]} 67 | ], # Should not have letters 68 | } 69 | 70 | with pytest.raises(ValidationError) as exc_info: 71 | _, _ = validate_interface_spec(json.dumps(invalid_spec)) 72 | 73 | assert exc_info.match("Input should be a valid string") 74 | assert exc_info.match("String should match pattern") 75 | assert exc_info.match("4 validation errors for ModelComponent") 76 | 77 | 78 | def test_validate_default_parameters(): 79 | invalid_spec = { 80 | "name": "example", 81 | "version": "1.0", 82 | "inputs": [ 83 | {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": ["10"]} 84 | ], # Should be a stringified list of integers 85 | "outputs": [ 86 | {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:10"]} 87 | ], # Should not have letters 88 | } 89 | error, model = validate_interface_spec(json.dumps(invalid_spec)) 90 | assert error is None 91 | assert model is not None 92 | 93 | assert model.uses_time is False 94 | assert model.state_initialization_reuse is False 95 | assert model.name == "example" 96 | assert model.parameters == [] 97 | 98 | 99 | def test_validate_internal_states(): 100 | invalid_spec = { 101 | "name": "example", 102 | "version": "1.0", 103 | "inputs": [ 104 | {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": ["10"]} 105 | ], # Should be a stringified list of integers 106 | "outputs": [ 107 | {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:10"]} 108 | ], # Should not have letters 109 | "states": [ 110 | { 111 | "name": "state1", 112 | "description": "My state1", 113 | "startValue": 10, 114 | "initializationVariable": "input1", 115 | "agentOutputIndexes": ["0:10"], 116 | }, 117 | {"name": "state2", "description": "My state2", "agentOutputIndexes": ["0:10"]}, 118 | {"name": "state3", "initializationVariable": "input1", "agentOutputIndexes": ["0:10"]}, 119 | {"description": "My state4", "startValue": 10}, 120 | ], 121 | } 122 | with pytest.raises(ValidationError) as exc_info: 123 | _, _ = validate_interface_spec(json.dumps(invalid_spec)) 124 | 125 | assert exc_info.match( 126 | "Value error, Only one state initialization method is allowed to be used at a time: \ 127 | initialization_variable cannot be set if either start_value or name is set." 128 | ) 129 | assert exc_info.match( 130 | "Value error, name is set without start_value being set. \ 131 | Both fields need to be set for the state initialization to be valid" 132 | ) 133 | assert exc_info.match( 134 | "Value error, Only one state initialization method is allowed to be used at a time: \ 135 | initialization_variable cannot be set if either start_value or name is set." 136 | ) 137 | assert exc_info.match( 138 | "Value error, start_value is set without name being set. \ 139 | Both fields need to be set for the state initialization to be valid" 140 | ) 141 | -------------------------------------------------------------------------------- /tests/utils/test_modelDescription_builder.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from mlfmu.types.fmu_component import FmiModel 4 | from mlfmu.utils.builder import validate_interface_spec 5 | from mlfmu.utils.fmi_builder import generate_model_description 6 | 7 | 8 | def test_generate_simple_model_description(): 9 | valid_spec = { 10 | "name": "example", 11 | "version": "1.0", 12 | "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], 13 | "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], 14 | } 15 | _, model = validate_interface_spec(json.dumps(valid_spec)) 16 | assert model is not None 17 | 18 | fmi_model = FmiModel(model=model) 19 | xml_structure = generate_model_description(fmu_model=fmi_model) 20 | variables = xml_structure.findall(".//ScalarVariable") 21 | 22 | assert xml_structure.getroot().tag == "fmiModelDescription" 23 | assert variables[0].attrib["name"] == "input1" 24 | assert variables[0].attrib["causality"] == "input" 25 | assert variables[0].attrib["variability"] == "continuous" 26 | assert variables[0].attrib["description"] == "My input1" 27 | assert variables[0][0].tag == "Integer" 28 | 29 | assert variables[1].attrib["name"] == "output1" 30 | assert variables[1].attrib["causality"] == "output" 31 | assert variables[1].attrib["variability"] == "continuous" 32 | assert variables[1].attrib["description"] == "My output1" 33 | assert variables[1][0].tag == "Real" 34 | 35 | 36 | def test_generate_model_description_with_internal_state_params(): 37 | valid_spec = { 38 | "name": "example", 39 | "version": "1.0", 40 | "states": [ 41 | { 42 | "name": "state1", 43 | "description": "My state1", 44 | "startValue": 0.0, 45 | "type": "real", 46 | "agentOutputIndexes": ["0"], 47 | } 48 | ], 49 | "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], 50 | } 51 | _, model = validate_interface_spec(json.dumps(valid_spec)) 52 | assert model is not None 53 | 54 | fmi_model = FmiModel(model=model) 55 | xml_structure = generate_model_description(fmu_model=fmi_model) 56 | variables = xml_structure.findall(".//ScalarVariable") 57 | 58 | assert xml_structure.getroot().tag == "fmiModelDescription" 59 | 60 | assert variables[0].attrib["name"] == "output1" 61 | assert variables[0].attrib["causality"] == "output" 62 | 63 | assert variables[1].attrib["name"] == "state1" 64 | assert variables[1].attrib["causality"] == "parameter" 65 | assert variables[1][0].tag == "Real" 66 | assert variables[1][0].attrib["start"] == "0.0" 67 | 68 | 69 | def test_generate_vector_ports(): 70 | valid_spec = { 71 | "name": "example", 72 | "version": "1.0", 73 | "inputs": [ 74 | { 75 | "name": "inputVector", 76 | "description": "My input1", 77 | "agentInputIndexes": ["0:5"], 78 | "type": "real", 79 | "isArray": True, 80 | "length": 5, 81 | } 82 | ], 83 | "outputs": [ 84 | { 85 | "name": "outputVector", 86 | "description": "My output1", 87 | "agentInputIndexes": ["0:5"], 88 | "isArray": True, 89 | "length": 5, 90 | } 91 | ], 92 | } 93 | _, model = validate_interface_spec(json.dumps(valid_spec)) 94 | assert model is not None 95 | 96 | fmi_model = FmiModel(model=model) 97 | xml_structure = generate_model_description(fmu_model=fmi_model) 98 | variables = xml_structure.findall(".//ScalarVariable") 99 | 100 | assert model 101 | assert variables[0].attrib["name"] == "inputVector[0]" 102 | assert variables[1].attrib["name"] == "inputVector[1]" 103 | assert variables[2].attrib["name"] == "inputVector[2]" 104 | assert variables[3].attrib["name"] == "inputVector[3]" 105 | assert variables[4].attrib["name"] == "inputVector[4]" 106 | 107 | assert variables[5].attrib["name"] == "outputVector[0]" 108 | assert variables[6].attrib["name"] == "outputVector[1]" 109 | assert variables[7].attrib["name"] == "outputVector[2]" 110 | assert variables[8].attrib["name"] == "outputVector[3]" 111 | assert variables[9].attrib["name"] == "outputVector[4]" 112 | 113 | 114 | def test_generate_model_description_with_start_value(): 115 | valid_spec = { 116 | "name": "example", 117 | "version": "1.0", 118 | "usesTime": True, 119 | "inputs": [ 120 | { 121 | "name": "input1", 122 | "description": "My input1", 123 | "agentInputIndexes": ["0"], 124 | "type": "integer", 125 | "startValue": 10, 126 | }, 127 | { 128 | "name": "input2", 129 | "description": "My input2", 130 | "agentOutputIndexes": ["0"], 131 | "type": "boolean", 132 | "startValue": True, 133 | }, 134 | {"name": "input3", "description": "My input3", "agentOutputIndexes": ["0"], "startValue": 10.0}, 135 | ], 136 | } 137 | _, model = validate_interface_spec(json.dumps(valid_spec)) 138 | assert model is not None 139 | 140 | fmi_model = FmiModel(model=model) 141 | xml_structure = generate_model_description(fmu_model=fmi_model) 142 | variables = xml_structure.findall(".//ScalarVariable") 143 | 144 | assert xml_structure.getroot().tag == "fmiModelDescription" 145 | assert variables[0].attrib["name"] == "input1" 146 | assert variables[0].attrib["causality"] == "input" 147 | assert variables[0][0].tag == "Integer" 148 | assert variables[0][0].attrib["start"] == "10" 149 | 150 | assert variables[1].attrib["name"] == "input2" 151 | assert variables[1].attrib["causality"] == "input" 152 | assert variables[1][0].tag == "Boolean" 153 | assert variables[1][0].attrib["start"] == "True" 154 | 155 | assert variables[2].attrib["name"] == "input3" 156 | assert variables[2].attrib["causality"] == "input" 157 | assert variables[2][0].tag == "Real" 158 | assert variables[2][0].attrib["start"] == "10.0" 159 | 160 | 161 | def test_generate_model_description_output(): 162 | valid_spec = { 163 | "name": "example", 164 | "version": "1.0", 165 | "usesTime": True, 166 | "inputs": [ 167 | { 168 | "name": "input1", 169 | "description": "My input1", 170 | "agentInputIndexes": ["0"], 171 | "type": "integer", 172 | "startValue": 10, 173 | }, 174 | { 175 | "name": "input2", 176 | "description": "My input2", 177 | "agentOutputIndexes": ["0"], 178 | "type": "boolean", 179 | "startValue": True, 180 | }, 181 | {"name": "input3", "description": "My input3", "agentOutputIndexes": ["0"], "startValue": 10.0}, 182 | ], 183 | "outputs": [ 184 | {"name": "output1", "description": "My output1", "agentInputIndexes": ["0"], "type": "real"}, 185 | {"name": "output1", "description": "My output1", "agentInputIndexes": ["0"], "type": "real"}, 186 | ], 187 | } 188 | _, model = validate_interface_spec(json.dumps(valid_spec)) 189 | assert model is not None 190 | 191 | fmi_model = FmiModel(model=model) 192 | xml_structure = generate_model_description(fmu_model=fmi_model) 193 | variables = xml_structure.findall(".//ScalarVariable") 194 | output_variables = [var for var in variables if var.attrib.get("causality") == "output"] 195 | outputs_registered = xml_structure.findall(".//Outputs/Unknown") 196 | 197 | # The index should be the valueReference + 1 198 | assert int(output_variables[0].attrib["valueReference"]) + 1 == int(outputs_registered[0].attrib["index"]) 199 | assert int(output_variables[1].attrib["valueReference"]) + 1 == int(outputs_registered[1].attrib["index"]) 200 | --------------------------------------------------------------------------------