Skip to content

ModelicaSystem - use OMCPath #322

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 53 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c12c277
[OMCPath] add class
syntron Jun 28, 2025
908a239
[OMCPath] add implementation using OMC via sendExpression()
syntron Jun 27, 2025
00d016f
[OMCPath] add pytest (only docker at the moment)
syntron Jun 28, 2025
a449445
[OMCPath] TODO items
syntron Jun 28, 2025
17c9ee8
[test_OMCPath] mypy fix
syntron Jul 2, 2025
942635e
[test_OMCPath] fix end of file
syntron Jul 2, 2025
11b2524
[test_OMCPath] define test using OMCSessionZMQ() locally
syntron Jul 3, 2025
05b3c4d
add TODO - need to check Python versions
syntron Jul 6, 2025
79e118a
[test_OMCPath] activate docker based on test_docker
syntron Jul 6, 2025
e0cb453
[OMCPath] add more functionality and docstrings
syntron Jul 11, 2025
2b86442
[OMCPath] remove TODO entries
syntron Jul 11, 2025
a9fb9f9
[OMCPath] define limited compatibility for Python < 3.12
syntron Jul 11, 2025
fcb8571
[OMCSEssionZMQ] use OMCpath
syntron Jul 11, 2025
51b9160
[OMCSessionZMQ] create a tempdir using omcpath_tempdir()
syntron Jul 11, 2025
dd9afed
[OMCPath] fix mypy
syntron Jul 11, 2025
dc0393e
[OMCPath] add warning message for Python < 3.12
syntron Jul 11, 2025
6ff54d3
[OMCPath] try to make mypy happy ...
syntron Jul 11, 2025
c4323b3
[test_OMCPath] only for Python >= 3.12
syntron Jul 11, 2025
ec552dd
[test_OMCPath] update test
syntron Jul 11, 2025
3b99f2f
[OMCPath._omc_resolve] use sendExpression() with parsed=False
syntron Jul 12, 2025
a54b796
[test_OMCPath] cleanup; use the same code for local OMC and docker ba…
syntron Jul 12, 2025
02c40bc
[test_OMCPath] define test for WSL
syntron Jul 12, 2025
4feccf2
[test_OMCPath] use omcpath_tempdir() instead of hard-coded tempdir de…
syntron Jul 12, 2025
3625807
[OMCPath] spelling fix
syntron Jul 15, 2025
930cff1
[OMCPath] implementation version 3
syntron Jul 16, 2025
1de0e3b
[OMCSession*] fix flake8 (PyCharm likes the empty lines)
syntron Jul 16, 2025
8f38def
[OMCSessionZMQ] more generic definiton for omcpath_tempdir()
syntron Jul 16, 2025
9dc161b
[OMCPathCompatibility] mypy on github ...
syntron Jul 16, 2025
fd906cc
[OMCPathCompatibility] improve log messages
syntron Jul 16, 2025
90f8c98
[test_OMCPath] update
syntron Jul 16, 2025
b2a9190
[OMCPathReal] align exists() to the definition used in pathlib
syntron Jul 24, 2025
b11bfa9
[test_OMCPath] fix error
syntron Jul 26, 2025
26e73f6
[ModelicaSystem] update handling of work directory
syntron Jul 24, 2025
a9442b5
[ModelicaSystem] update handling of xml_file
syntron Jul 9, 2025
c4fabc5
[ModelicaSystem] replace ET.parse() with ET.ElementTree(ET.fromstring())
syntron Jul 9, 2025
3133f61
[ModelicaSystem._xmlparse] mypy fixes & cleanup
syntron Jul 10, 2025
33ea460
[ModelicaSystem] remove class variable _xml_file
syntron Jul 10, 2025
fa1f453
[ModelicaSystem] fix mypy warning - value can have different types in…
syntron Jul 12, 2025
250870c
[ModelicaSystem] do not use package csv
syntron Jul 26, 2025
66b9df3
[ModelicaSystem] create override file using pathlib.Path.write_text()
syntron Jul 26, 2025
a676435
[ModelicaSystem] update handling of override file
syntron Aug 5, 2025
6b55beb
Merge branch 'ModelicaSystem_workdir' into ModelicaSystemCmd_use_OMCPath
syntron Aug 16, 2025
0ffa36b
Merge branch 'ModelicaSystem_xml' into ModelicaSystemCmd_use_OMCPath
syntron Aug 16, 2025
3eb80f7
Merge branch 'ModelicaSystem_prepare_OMCPath' into ModelicaSystemCmd_…
syntron Aug 16, 2025
4384fa5
[ModelicaSystem] update handling of override file
syntron Aug 5, 2025
6617ab2
[ModelicaSystem] fix rebase fallout 2
syntron Jul 7, 2025
3022e65
[test_ModelicaSystem] fix test_customBuildDirectory()
syntron Jul 11, 2025
daa3caf
[ModelicaSystem] fix blank lines (flake8)
syntron Jul 22, 2025
f627daf
[test_optimization] fix due to OMCPath usage
syntron Aug 6, 2025
47dd995
[test_FMIExport] fix due to OMCPath usage
syntron Aug 6, 2025
d42753e
[ModelicaSystem] improve definition of getSolution
syntron Aug 9, 2025
229d624
[ModelicaSystem] use OMCPath for nearly all file system interactions
syntron Jul 11, 2025
087db3d
[ModelicaSystem] improve result file handling in simulate()
syntron Aug 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 115 additions & 77 deletions OMPython/ModelicaSystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,20 @@
"""

import ast
import csv
from dataclasses import dataclass
import logging
import numbers
import numpy as np
import os
import pathlib
import platform
import re
import subprocess
import tempfile
import textwrap
from typing import Optional, Any
import warnings
import xml.etree.ElementTree as ET

from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal
from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal, OMCPath

# define logger using the current module name as ID
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -115,8 +112,8 @@ def __getitem__(self, index: int):
class ModelicaSystemCmd:
"""A compiled model executable."""

def __init__(self, runpath: pathlib.Path, modelname: str, timeout: Optional[float] = None) -> None:
self._runpath = pathlib.Path(runpath).resolve().absolute()
def __init__(self, runpath: OMCPath, modelname: str, timeout: Optional[float] = None) -> None:
self._runpath = runpath
self._model_name = modelname
self._timeout = timeout
self._args: dict[str, str | None] = {}
Expand Down Expand Up @@ -176,7 +173,7 @@ def args_set(self, args: dict[str, Optional[str | dict[str, str]]]) -> None:
for arg in args:
self.arg_set(key=arg, val=args[arg])

def get_exe(self) -> pathlib.Path:
def get_exe(self) -> OMCPath:
"""Get the path to the compiled model executable."""
if platform.system() == "Windows":
path_exe = self._runpath / f"{self._model_name}.exe"
Expand Down Expand Up @@ -296,12 +293,12 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, str]]]:
class ModelicaSystem:
def __init__(
self,
fileName: Optional[str | os.PathLike | pathlib.Path] = None,
fileName: Optional[str | os.PathLike] = None,
modelName: Optional[str] = None,
lmodel: Optional[list[str | tuple[str, str]]] = None,
commandLineOptions: Optional[str] = None,
variableFilter: Optional[str] = None,
customBuildDirectory: Optional[str | os.PathLike | pathlib.Path] = None,
customBuildDirectory: Optional[str | os.PathLike] = None,
omhome: Optional[str] = None,
omc_process: Optional[OMCProcessLocal] = None,
build: bool = True,
Expand Down Expand Up @@ -383,12 +380,15 @@ def __init__(
if not isinstance(lmodel, list):
raise ModelicaSystemError(f"Invalid input type for lmodel: {type(lmodel)} - list expected!")

self._xml_file = None
self._lmodel = lmodel # may be needed if model is derived from other model
self._model_name = modelName # Model class name
self._file_name = pathlib.Path(fileName).resolve() if fileName is not None else None # Model file/package name
if fileName is not None:
file_name = self._getconn.omcpath(fileName).resolve()
else:
file_name = None
self._file_name: Optional[OMCPath] = file_name # Model file/package name
self._simulated = False # True if the model has already been simulated
self._result_file: Optional[pathlib.Path] = None # for storing result file
self._result_file: Optional[OMCPath] = None # for storing result file
self._variable_filter = variableFilter

if self._file_name is not None and not self._file_name.is_file(): # if file does not exist
Expand All @@ -400,7 +400,7 @@ def __init__(
self.setCommandLineOptions("--linearizationDumpLanguage=python")
self.setCommandLineOptions("--generateSymbolicLinearization")

self._tempdir = self.setTempDirectory(customBuildDirectory)
self._work_dir: OMCPath = self.setWorkDirectory(customBuildDirectory)

if self._file_name is not None:
self._loadLibrary(lmodel=self._lmodel)
Expand All @@ -420,7 +420,7 @@ def setCommandLineOptions(self, commandLineOptions: Optional[str] = None):
exp = f'setCommandLineOptions("{commandLineOptions}")'
self.sendExpression(exp)

def _loadFile(self, fileName: pathlib.Path):
def _loadFile(self, fileName: OMCPath):
# load file
self.sendExpression(f'loadFile("{fileName.as_posix()}")')

Expand Down Expand Up @@ -448,25 +448,34 @@ def _loadLibrary(self, lmodel: list):
'1)["Modelica"]\n'
'2)[("Modelica","3.2.3"), "PowerSystems"]\n')

def setTempDirectory(self, customBuildDirectory: Optional[str | os.PathLike | pathlib.Path] = None) -> pathlib.Path:
# create a unique temp directory for each session and build the model in that directory
def setWorkDirectory(self, customBuildDirectory: Optional[str | os.PathLike] = None) -> OMCPath:
"""
Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this
directory. If no directory is defined a unique temporary directory is created.
"""
if customBuildDirectory is not None:
if not os.path.exists(customBuildDirectory):
raise IOError(f"{customBuildDirectory} does not exist")
tempdir = pathlib.Path(customBuildDirectory).absolute()
workdir = self._getconn.omcpath(customBuildDirectory).absolute()
if not workdir.is_dir():
raise IOError(f"Provided work directory does not exists: {customBuildDirectory}!")
else:
tempdir = pathlib.Path(tempfile.mkdtemp()).absolute()
if not tempdir.is_dir():
raise IOError(f"{tempdir} could not be created")
workdir = self._getconn.omcpath_tempdir().absolute()
if not workdir.is_dir():
raise IOError(f"{workdir} could not be created")

logger.info("Define tempdir as %s", tempdir)
exp = f'cd("{tempdir.as_posix()}")'
logger.info("Define work dir as %s", workdir)
exp = f'cd("{workdir.as_posix()}")'
self.sendExpression(exp)

return tempdir
# set the class variable _tempdir ...
self._work_dir = workdir
# ... and also return the defined path
return workdir

def getWorkDirectory(self) -> pathlib.Path:
return self._tempdir
def getWorkDirectory(self) -> OMCPath:
"""
Return the defined working directory for this ModelicaSystem / OpenModelica session.
"""
return self._work_dir

def buildModel(self, variableFilter: Optional[str] = None):
if variableFilter is not None:
Expand All @@ -480,8 +489,8 @@ def buildModel(self, variableFilter: Optional[str] = None):
buildModelResult = self._requestApi("buildModel", self._model_name, properties=varFilter)
logger.debug("OM model build result: %s", buildModelResult)

self._xml_file = pathlib.Path(buildModelResult[0]).parent / buildModelResult[1]
self._xmlparse()
xml_file = self._getconn.omcpath(buildModelResult[0]).parent / buildModelResult[1]
self._xmlparse(xml_file=xml_file)

def sendExpression(self, expr: str, parsed: bool = True):
try:
Expand All @@ -507,30 +516,42 @@ def _requestApi(self, apiName, entity=None, properties=None): # 2

return self.sendExpression(exp)

def _xmlparse(self):
if not self._xml_file.is_file():
raise ModelicaSystemError(f"XML file not generated: {self._xml_file}")
def _xmlparse(self, xml_file: OMCPath):
if not xml_file.is_file():
raise ModelicaSystemError(f"XML file not generated: {xml_file}")

tree = ET.parse(self._xml_file)
xml_content = xml_file.read_text()
tree = ET.ElementTree(ET.fromstring(xml_content))
rootCQ = tree.getroot()
for attr in rootCQ.iter('DefaultExperiment'):
for key in ("startTime", "stopTime", "stepSize", "tolerance",
"solver", "outputFormat"):
self._simulate_options[key] = attr.get(key)
self._simulate_options[key] = str(attr.get(key))

for sv in rootCQ.iter('ScalarVariable'):
scalar = {}
for key in ("name", "description", "variability", "causality", "alias"):
scalar[key] = sv.get(key)
scalar["changeable"] = sv.get('isValueChangeable')
scalar["aliasvariable"] = sv.get('aliasVariable')
translations = {
"alias": "alias",
"aliasvariable": "aliasVariable",
"causality": "causality",
"changeable": "isValueChangeable",
"description": "description",
"name": "name",
"variability": "variability",
}

scalar: dict[str, Any] = {}
for key_dst, key_src in translations.items():
val = sv.get(key_src)
scalar[key_dst] = None if val is None else str(val)

ch = list(sv)
for att in ch:
scalar["start"] = att.get('start')
scalar["min"] = att.get('min')
scalar["max"] = att.get('max')
scalar["unit"] = att.get('unit')

# save parameters in the corresponding class variables
if scalar["variability"] == "parameter":
if scalar["name"] in self._override_variables:
self._params[scalar["name"]] = self._override_variables[scalar["name"]]
Expand Down Expand Up @@ -915,7 +936,7 @@ def getOptimizationOptions(self, names: Optional[str | list[str]] = None) -> dic

def simulate_cmd(
self,
result_file: pathlib.Path,
result_file: OMCPath,
simflags: Optional[str] = None,
simargs: Optional[dict[str, Optional[str | dict[str, str]]]] = None,
timeout: Optional[float] = None,
Expand All @@ -942,7 +963,11 @@ def simulate_cmd(
An instance if ModelicaSystemCmd to run the requested simulation.
"""

om_cmd = ModelicaSystemCmd(runpath=self._tempdir, modelname=self._model_name, timeout=timeout)
om_cmd = ModelicaSystemCmd(
runpath=self.getWorkDirectory(),
modelname=self._model_name,
timeout=timeout,
)

# always define the result file to use
om_cmd.arg_set(key="r", val=result_file.as_posix())
Expand All @@ -954,16 +979,17 @@ def simulate_cmd(
if simargs:
om_cmd.args_set(args=simargs)

overrideFile = self._tempdir / f"{self._model_name}_override.txt"
if self._override_variables or self._simulate_options_override:
tmpdict = self._override_variables.copy()
tmpdict.update(self._simulate_options_override)
# write to override file
with open(file=overrideFile, mode="w", encoding="utf-8") as fh:
for key, value in tmpdict.items():
fh.write(f"{key}={value}\n")
override_file = result_file.parent / f"{result_file.stem}_override.txt"

om_cmd.arg_set(key="overrideFile", val=overrideFile.as_posix())
override_content = (
"\n".join([f"{key}={value}" for key, value in self._override_variables.items()])
+ "\n".join([f"{key}={value}" for key, value in self._simulate_options_override.items()])
+ "\n"
)

override_file.write_text(override_content)
om_cmd.arg_set(key="overrideFile", val=override_file.as_posix())

if self._inputs: # if model has input quantities
for key in self._inputs:
Expand Down Expand Up @@ -1013,11 +1039,16 @@ def simulate(

if resultfile is None:
# default result file generated by OM
self._result_file = self._tempdir / f"{self._model_name}_res.mat"
elif os.path.exists(resultfile):
self._result_file = pathlib.Path(resultfile)
self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat"
elif isinstance(resultfile, OMCPath):
self._result_file = resultfile
else:
self._result_file = self._tempdir / resultfile
self._result_file = self._getconn.omcpath(resultfile)
if not self._result_file.is_absolute():
self._result_file = self.getWorkDirectory() / resultfile

if not isinstance(self._result_file, OMCPath):
raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!")

om_cmd = self.simulate_cmd(
result_file=self._result_file,
Expand All @@ -1036,15 +1067,19 @@ def simulate(
# check for an empty (=> 0B) result file which indicates a crash of the model executable
# see: https://github.com/OpenModelica/OMPython/issues/261
# https://github.com/OpenModelica/OpenModelica/issues/13829
if self._result_file.stat().st_size == 0:
if self._result_file.size() == 0:
self._result_file.unlink()
raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!")

logger.warning(f"Return code = {returncode} but result file exists!")

self._simulated = True

def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Optional[str] = None) -> tuple[str] | np.ndarray:
def getSolutions(
self,
varList: Optional[str | list[str]] = None,
resultfile: Optional[str | os.PathLike] = None,
) -> tuple[str] | np.ndarray:
"""Extract simulation results from a result data file.

Args:
Expand Down Expand Up @@ -1081,7 +1116,7 @@ def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Op
raise ModelicaSystemError("No result file found. Run simulate() first.")
result_file = self._result_file
else:
result_file = pathlib.Path(resultfile)
result_file = self._getconn.omcpath(resultfile)

# check for result file exits
if not result_file.is_file():
Expand Down Expand Up @@ -1373,7 +1408,7 @@ def setInputs(

return True

def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path:
def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath:
"""
Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument,
this file is used; else a generic file name is created.
Expand Down Expand Up @@ -1419,11 +1454,12 @@ def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path
csv_rows.append(row)

if csvfile is None:
csvfile = self._tempdir / f'{self._model_name}.csv'
csvfile = self.getWorkDirectory() / f'{self._model_name}.csv'

# basic definition of a CSV file using csv_rows as input
csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n"

with open(file=csvfile, mode="w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerows(csv_rows)
csvfile.write_text(csv_content)

return csvfile

Expand Down Expand Up @@ -1534,24 +1570,28 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N
* `result = linearize(); A = result[0]` mostly just for backwards
compatibility, because linearize() used to return `[A, B, C, D]`.
"""

if self._xml_file is None:
if len(self._quantities) == 0:
# if self._quantities has no content, the xml file was not parsed; see self._xmlparse()
raise ModelicaSystemError(
"Linearization cannot be performed as the model is not build, "
"use ModelicaSystem() to build the model first"
)

om_cmd = ModelicaSystemCmd(runpath=self._tempdir, modelname=self._model_name, timeout=timeout)

overrideLinearFile = self._tempdir / f'{self._model_name}_override_linear.txt'
om_cmd = ModelicaSystemCmd(
runpath=self.getWorkDirectory(),
modelname=self._model_name,
timeout=timeout,
)

with open(file=overrideLinearFile, mode="w", encoding="utf-8") as fh:
for key, value in self._override_variables.items():
fh.write(f"{key}={value}\n")
for key, value in self._linearization_options.items():
fh.write(f"{key}={value}\n")
override_content = (
"\n".join([f"{key}={value}" for key, value in self._override_variables.items()])
+ "\n".join([f"{key}={value}" for key, value in self._linearization_options.items()])
+ "\n"
)
override_file = self.getWorkDirectory() / f'{self._model_name}_override_linear.txt'
override_file.write_text(override_content)

om_cmd.arg_set(key="overrideFile", val=overrideLinearFile.as_posix())
om_cmd.arg_set(key="overrideFile", val=override_file.as_posix())

if self._inputs:
for key in self._inputs:
Expand All @@ -1573,19 +1613,17 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N
om_cmd.args_set(args=simargs)

# the file create by the model executable which contains the matrix and linear inputs, outputs and states
linear_file = self._tempdir / "linearized_model.py"

linear_file = self.getWorkDirectory() / "linearized_model.py"
linear_file.unlink(missing_ok=True)

returncode = om_cmd.run()
if returncode != 0:
raise ModelicaSystemError(f"Linearize failed with return code: {returncode}")
if not linear_file.is_file():
raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!")

self._simulated = True

if not linear_file.exists():
raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!")

# extract data from the python file with the linearized model using the ast module - this allows to get the
# needed information without executing the created code
linear_data = {}
Expand Down
Loading
Loading