From 5648fab177b75b1dcbc9c53da1c56c8b1196a2be Mon Sep 17 00:00:00 2001 From: Adeel Asghar Date: Mon, 28 Sep 2026 13:21:12 +0200 Subject: [PATCH 1/3] Add docstrings to the main classes and functions Fixes #372 Document the public API of the OMPython package. All classes and functions across the package now provide a docstring describing their purpose, arguments, return values and raised exceptions. --- OMPython/ModelicaSystem.py | 76 ++++++++++-- OMPython/OMCSession.py | 76 ++++++++++++ OMPython/OMParser.py | 27 ++++ OMPython/OMTypedParser.py | 15 +++ OMPython/_version.py | 10 ++ OMPython/compatibility_v400.py | 1 + OMPython/model_execution.py | 13 ++ OMPython/modelica_doe_abc.py | 1 + OMPython/modelica_doe_omc.py | 24 ++++ OMPython/modelica_doe_runner.py | 27 ++++ OMPython/modelica_system_abc.py | 12 ++ OMPython/modelica_system_omc.py | 41 +++++++ OMPython/modelica_system_runner.py | 33 ++++- OMPython/om_session_abc.py | 20 +++ OMPython/om_session_omc.py | 190 +++++++++++++++++++++++++++-- OMPython/om_session_runner.py | 31 +++++ 16 files changed, 571 insertions(+), 26 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3f15c372..3e4a947f 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -44,7 +44,11 @@ @depreciated_class(msg="Please use class ModelicaSystemOMC instead!") class ModelicaSystem(ModelicaSystemOMC): """ - Compatibility class. + High-level interface for loading, compiling, and simulating Modelica models. + + This class provides backwards compatibility with OMPython v4.0.0 while extending + ModelicaSystemOMC. It manages model compilation, parameter modification, simulation + runs, and result inspection via an OMC session. """ def __init__( @@ -59,6 +63,26 @@ def __init__( omc_process: Optional[OMCSessionLocal] = None, build: bool = True, ) -> None: + """Initialize and optionally build a Modelica model. + + Args: + fileName: Path to the Modelica source file (.mo). Either absolute + or relative to the working directory. + modelName: The name of the Modelica model class (e.g. "ModelName" or + "PackageName.ModelName"). + lmodel: List of libraries to load before the model. Entries can be + library names (e.g. ["Modelica"]) or name-version tuples (e.g. + [("Modelica", "3.2.3")]). + commandLineOptions: Extra command-line options passed to OMC. + variableFilter: Regular expression filter for variables to store in + the simulation result file. Defaults to ".*". + customBuildDirectory: Path to directory for build artifacts and + executable. If unspecified, a temporary directory is created. + omhome: Path to the OpenModelica installation directory. + omc_process: Pre-existing OMCSessionLocal instance. If unspecified, + a new local session is created. + build: If True (default), builds the model executable upon initialization. + """ super().__init__( command_line_options=commandLineOptions, work_directory=customBuildDirectory, @@ -75,6 +99,11 @@ def __init__( self._getconn = self._session def setCommandLineOptions(self, commandLineOptions: str): + """Pass command-line flags to the underlying OMC compiler instance. + + Args: + commandLineOptions: Flag or option string (e.g. "--generateSymbolicLinearization"). + """ super().set_command_line_options(command_line_option=commandLineOptions) def simulate_cmd( # type: ignore[override] @@ -148,6 +177,19 @@ def _set_compatibility_helper( args: Any, kwargs: dict[str, Any], ) -> dict[str, Any]: + """Convert the legacy positional/keyword inputs into a value dictionary. + + Args: + pkey: The keyword name to look up in ``kwargs``. + args: The positional arguments given to the wrapper. + kwargs: The keyword arguments given to the wrapper. + + Returns: + A dictionary mapping variable names to values. + + Raises: + ModelicaSystemError: If a ``key=value`` string is invalid. + """ input_args = [] if len(args) == 1: input_args.append(args[0]) @@ -157,6 +199,7 @@ def _set_compatibility_helper( # the code below is based on _prepare_input_data2() def prepare_str(str_in: str) -> dict[str, str]: + """Parse a single ``key=value`` string into a one-entry dictionary.""" str_in = str_in.replace(" ", "") key_val_list: list[str] = str_in.split("=") if len(key_val_list) != 2: @@ -392,19 +435,19 @@ def getOutputs( @depreciated_class(msg="Please use class ModelicaDoEOMC instead!") class ModelicaSystemDoE(ModelicaDoEOMC): """ - Compatibility class. + Compatibility class for Design of Experiments (DoE) with Modelica models. + + Inherits from ModelicaDoEOMC to provide backwards compatibility with OMPython v4.0.0. """ @depreciated_class(msg="Please use class ModelExecutionConfig instead!") class ModelicaSystemCmd(ModelExecutionConfig): """ - Compatibility class; not much content. + Compatibility wrapper for model execution configuration. - Missing definitions: - * get_exe() - see self.definition.cmd_model_executable - * get_cmd() - use self.get_cmd_args() or self.definition().get_cmd() - * run() - use self.definition().run() + Subclasses ModelExecutionConfig to store configuration for running compiled model + binaries. """ def __init__( @@ -413,6 +456,13 @@ def __init__( modelname: str, timeout: Optional[float] = None, ) -> None: + """Initialize ModelicaSystemCmd. + + Args: + runpath: Working directory path where the model binary is located. + modelname: Name of the Modelica model. + timeout: Execution timeout in seconds. + """ super().__init__( runpath=runpath, timeout=timeout, @@ -422,10 +472,16 @@ def __init__( def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: - """ - Parse a simflag definition; this is deprecated! + """Parse legacy simulation flag string into a dictionary suitable for simargs. + + Args: + simflags: Space-separated simulation flags (e.g. "-s=dassl -override=stopTime=2.0"). + + Returns: + Dictionary mapping flag names to values or override dictionaries. - The return data can be used as input for self.args_set(). + Raises: + ModelExecutionException: If a flag or override definition is malformed. """ warnings.warn( message="The argument 'simflags' is depreciated and will be removed in future versions; " diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 974a8170..43867ca3 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -47,6 +47,12 @@ class OMCSessionCmd: """ def __init__(self, session: OMSessionABC, readonly: bool = False): + """Initialize the OMC API compatibility wrapper. + + Args: + session: The OMC session to send expressions to. + readonly: Whether responses may be served from a cache. + """ if not isinstance(session, OMSessionABC): raise OMCSessionException("Invalid OMC process definition!") self._session = session @@ -54,7 +60,19 @@ def __init__(self, session: OMSessionABC, readonly: bool = False): self._omc_cache: dict[tuple[str, bool], Any] = {} def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = True): + """Send an OMC API question to the session. + + Args: + question: The OMC API function name to call. + opt: Optional list of arguments for the API call. + parsed: Whether to parse the OMC response. + Returns: + The (optionally parsed) OMC response. + + Raises: + OMSessionException: If the options are invalid or the call fails. + """ if opt is None: expression = question elif isinstance(opt, list): @@ -81,66 +99,87 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr # TODO: Open Modelica Compiler API functions. Would be nice to generate these. def loadFile(self, filename): + """Load a Modelica file. Deprecated.""" return self._ask(question='loadFile', opt=[f'"{filename}"']) def loadModel(self, className): + """Load a Modelica model/package. Deprecated.""" return self._ask(question='loadModel', opt=[className]) def isModel(self, className): + """Check if ``className`` is a model. Deprecated.""" return self._ask(question='isModel', opt=[className]) def isPackage(self, className): + """Check if ``className`` is a package. Deprecated.""" return self._ask(question='isPackage', opt=[className]) def isPrimitive(self, className): + """Check if ``className`` is a primitive. Deprecated.""" return self._ask(question='isPrimitive', opt=[className]) def isConnector(self, className): + """Check if ``className`` is a connector. Deprecated.""" return self._ask(question='isConnector', opt=[className]) def isRecord(self, className): + """Check if ``className`` is a record. Deprecated.""" return self._ask(question='isRecord', opt=[className]) def isBlock(self, className): + """Check if ``className`` is a block. Deprecated.""" return self._ask(question='isBlock', opt=[className]) def isType(self, className): + """Check if ``className`` is a type. Deprecated.""" return self._ask(question='isType', opt=[className]) def isFunction(self, className): + """Check if ``className`` is a function. Deprecated.""" return self._ask(question='isFunction', opt=[className]) def isClass(self, className): + """Check if ``className`` is a class. Deprecated.""" return self._ask(question='isClass', opt=[className]) def isParameter(self, className): + """Check if ``className`` is a parameter. Deprecated.""" return self._ask(question='isParameter', opt=[className]) def isConstant(self, className): + """Check if ``className`` is a constant. Deprecated.""" return self._ask(question='isConstant', opt=[className]) def isProtected(self, className): + """Check if ``className`` is protected. Deprecated.""" return self._ask(question='isProtected', opt=[className]) def getPackages(self, className="AllLoadedClasses"): + """Get the loaded packages. Deprecated.""" return self._ask(question='getPackages', opt=[className]) def getClassRestriction(self, className): + """Get the class restriction of ``className``. Deprecated.""" return self._ask(question='getClassRestriction', opt=[className]) def getDerivedClassModifierNames(self, className): + """Get the derived class modifier names. Deprecated.""" return self._ask(question='getDerivedClassModifierNames', opt=[className]) def getDerivedClassModifierValue(self, className, modifierName): + """Get the value of a derived class modifier. Deprecated.""" return self._ask(question='getDerivedClassModifierValue', opt=[className, modifierName]) def typeNameStrings(self, className): + """Get the type name strings of ``className``. Deprecated.""" return self._ask(question='typeNameStrings', opt=[className]) def getComponents(self, className): + """Get the components of ``className``. Deprecated.""" return self._ask(question='getComponents', opt=[className]) def getClassComment(self, className): + """Get the comment of ``className``. Deprecated.""" try: return self._ask(question='getClassComment', opt=[className]) except pyparsing.ParseException as ex: @@ -153,22 +192,28 @@ def getNthComponent(self, className, comp_id): return self._ask(question='getNthComponent', opt=[className, comp_id]) def getNthComponentAnnotation(self, className, comp_id): + """Get the annotation of the n-th component. Deprecated.""" return self._ask(question='getNthComponentAnnotation', opt=[className, comp_id]) def getImportCount(self, className): + """Get the number of imports of ``className``. Deprecated.""" return self._ask(question='getImportCount', opt=[className]) def getNthImport(self, className, importNumber): + """Get the n-th import of ``className``. Deprecated.""" # [Path, id, kind] return self._ask(question='getNthImport', opt=[className, importNumber]) def getInheritanceCount(self, className): + """Get the number of inherited classes of ``className``. Deprecated.""" return self._ask(question='getInheritanceCount', opt=[className]) def getNthInheritedClass(self, className, inheritanceDepth): + """Get the n-th inherited class of ``className``. Deprecated.""" return self._ask(question='getNthInheritedClass', opt=[className, inheritanceDepth]) def getParameterNames(self, className): + """Get the parameter names of ``className``. Deprecated.""" try: return self._ask(question='getParameterNames', opt=[className]) except KeyError as ex: @@ -177,6 +222,7 @@ def getParameterNames(self, className): return [] def getParameterValue(self, className, parameterName): + """Get the value of a parameter. Deprecated.""" try: return self._ask(question='getParameterValue', opt=[className, parameterName]) except pyparsing.ParseException as ex: @@ -185,18 +231,23 @@ def getParameterValue(self, className, parameterName): return "" def getComponentModifierNames(self, className, componentName): + """Get the modifier names of a component. Deprecated.""" return self._ask(question='getComponentModifierNames', opt=[className, componentName]) def getComponentModifierValue(self, className, componentName): + """Get the modifier value of a component. Deprecated.""" return self._ask(question='getComponentModifierValue', opt=[className, componentName]) def getExtendsModifierNames(self, className, componentName): + """Get the modifier names of an extends clause. Deprecated.""" return self._ask(question='getExtendsModifierNames', opt=[className, componentName]) def getExtendsModifierValue(self, className, extendsName, modifierName): + """Get the modifier value of an extends clause. Deprecated.""" return self._ask(question='getExtendsModifierValue', opt=[className, extendsName, modifierName]) def getNthComponentModification(self, className, comp_id): + """Get the modification of the n-th component. Deprecated.""" # FIXME: OMPython exception Results KeyError exception # get {$Code(....)} field @@ -217,6 +268,19 @@ def getNthComponentModification(self, className, comp_id): # end getClassNames; def getClassNames(self, className=None, recursive=False, qualified=False, sort=False, builtin=False, showProtected=False): + """Get class names, optionally filtered. Deprecated. + + Args: + className: Name of the class to query (defaults to all loaded classes). + recursive: Whether to include nested classes. + qualified: Whether to return qualified names. + sort: Whether to sort the result. + builtin: Whether to include built-in classes. + showProtected: Whether to include protected classes. + + Returns: + The parsed list of class names. + """ opt = [className] if className else [] + [f'recursive={str(recursive).lower()}', f'qualified={str(qualified).lower()}', f'sort={str(sort).lower()}', @@ -249,6 +313,7 @@ def __init__( super().__init__(timeout=timeout) def __del__(self): + """Clean up the underlying OMC process.""" if hasattr(self, 'omc_process'): del self.omc_process @@ -273,6 +338,14 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) def execute(self, command: str): + """Execute a raw command on the OMC server. Deprecated. + + Args: + command: The raw OMC expression to execute. + + Returns: + The unparsed OMC response. + """ warnings.warn( message="This function is depreciated and will be removed in future versions; " "please use sendExpression() instead", @@ -298,12 +371,15 @@ def sendExpression( return self.omc_process.sendExpression(expr=command, parsed=parsed, raise_on_error=raise_on_error) def get_version(self) -> str: + """Get the version of the OMC server. Deprecated.""" return self.omc_process.get_version() def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """Get the model execution command prefix. Deprecated.""" return self.omc_process.model_execution_prefix(cwd=cwd) def set_workdir(self, workdir: OMPathABC) -> None: + """Set the working directory. Deprecated.""" return self.omc_process.set_workdir(workdir=workdir) diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index a82a9ca0..29516727 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -67,6 +67,7 @@ def typeCheck(string): def make_values(strings, name): + """Parse a value string and store its values into the result structure.""" if strings[0] == "(" and strings[-1] == ")": strings = strings[1:-1] if strings[0] == "{" and strings[-1] == "}": @@ -166,6 +167,7 @@ def make_values(strings, name): def delete_elements(strings): + """Remove parenthesized elements (and braces) from the given string.""" index = 0 while index < len(strings): character = strings[index] @@ -192,6 +194,7 @@ def delete_elements(strings): def make_subset_sets(strings, name): + """Parse a subset definition and store it into the result structure.""" main_set_name = "SET1" subset_name = "Subset1" set_name = "Set1" @@ -266,6 +269,7 @@ def make_subset_sets(strings, name): def make_sets(strings, name): + """Parse a set definition and store it into the result structure.""" if strings == "{}": return main_set_name = "SET1" @@ -324,6 +328,7 @@ def make_sets(strings, name): def get_inner_sets(strings, for_this, name): + """Extract inner (nested) sets from the given string.""" start = 0 end = 0 main_set_name = "SET1" @@ -396,6 +401,7 @@ def get_inner_sets(strings, for_this, name): def make_elements(strings): + """Parse the element definitions and store them into the result structure.""" index = 0 main_set_name = "SET1" @@ -510,6 +516,7 @@ def make_elements(strings): def check_for_next_string(next_string): + """Remove brace-wrapped blocks from the string, returning an empty string if none remain.""" anchor = 0 position = 0 stop = 0 @@ -533,8 +540,14 @@ def check_for_next_string(next_string): def get_the_set(string): + """Split the given string into the current set and the next set. + + Returns: + A ``(current_set, next_set)`` tuple of the parsed set strings. + """ def skip_all_inner_sets(position): + """Skip nested sets and return the end position of the main set.""" position += 1 count = 1 main_count = 1 @@ -741,6 +754,7 @@ def skip_all_inner_sets(position): def formatSimRes(strings): + """Parse a ``SimulationResult`` record and store it into the result structure.""" result['SimulationResults'] = {} simRes = strings[strings.find(' resultFile') + 1:strings.find('\nend SimulationResult')] simRes = simRes.translate(None, "\\") @@ -783,6 +797,7 @@ def formatSimRes(strings): def formatRecords(strings): + """Parse a ``record`` definition and store it into the result structure.""" result['RecordResults'] = {} recordName = strings[strings.find("record ") + 1:strings.find("\n")] recordName = recordName.replace("ecord ", '').strip() @@ -805,6 +820,10 @@ def formatRecords(strings): def check_for_values(string): + """Parse an OMC response string into the result structure. + + Handles typed values, records (SimulationResult), and nested sets. + """ main_set_name = "SET1" if len(string) == 0: return result @@ -882,6 +901,14 @@ def check_for_values(string): # this should be checked such that the content of this file can be used as class with correct handling of # variable usage def om_parser_basic(string: str): + """Parse an OMC response string and return the parsed result structure. + + Args: + string: The (untyped) OMC response to parse. + + Returns: + A dictionary with the parsed sets/elements/values. + """ result_return = check_for_values(string=string) global result diff --git a/OMPython/OMTypedParser.py b/OMPython/OMTypedParser.py index 9fe810e0..4f6193f0 100644 --- a/OMPython/OMTypedParser.py +++ b/OMPython/OMTypedParser.py @@ -55,6 +55,7 @@ def convert_numbers(s, loc, toks): + """Convert parsed numeric tokens to int (falls back to float).""" n = toks[0] try: return int(n) @@ -63,6 +64,7 @@ def convert_numbers(s, loc, toks): def convert_string2(s, s2): + """Convert a quoted-string token back to a Modelica string literal.""" tmp = s2[0].replace("\\\"", "\"") tmp = tmp.replace("\"", "\\\"") tmp = tmp.replace("\'", "\\'") @@ -74,18 +76,22 @@ def convert_string2(s, s2): def convert_string(s, s2): + """Unescape double quotes in a string token.""" return s2[0].replace("\\\"", '"') def convert_dict(d): + """Convert a parsed record into a dictionary.""" return dict(d[0]) def convert_tuple(t): + """Convert a parsed array/tuple into a Python tuple.""" return tuple(t[0]) def evaluate_expression(s, loc, toks): + """Evaluate an arithmetic dimension expression or return it verbatim.""" # Convert the tokens (ParseResults) into a string expression flat_list = [item for sublist in toks[0] for item in sublist] expr = "".join(flat_list) @@ -157,6 +163,15 @@ def evaluate_expression(s, loc, toks): def om_parser_typed(string) -> Any: + """Parse a typed OMC response string. + + Args: + string: The typed OMC response (with ``record`` blocks, arrays, + tuples and native Modelica types). + + Returns: + The parsed Python object, or None for an empty response. + """ res = omcGrammar.parse_string(string) if len(res) == 0: return None diff --git a/OMPython/_version.py b/OMPython/_version.py index cebacd10..4c686836 100644 --- a/OMPython/_version.py +++ b/OMPython/_version.py @@ -32,6 +32,16 @@ def _read_version_from_pyproject() -> str: def _resolve_version() -> str: + """Resolve the installed package version. + + Preference is given to the version reported by importlib.metadata for the + installed distribution. If the package is not installed, the version is + read from the ``pyproject.toml`` next to this module; both fall back to + a hardcoded string. + + Returns: + The OMPython version as a string. + """ try: return version(__package__ or "OMPython") except PackageNotFoundError: diff --git a/OMPython/compatibility_v400.py b/OMPython/compatibility_v400.py index 61fa27a8..8c869d97 100644 --- a/OMPython/compatibility_v400.py +++ b/OMPython/compatibility_v400.py @@ -22,6 +22,7 @@ class Wrapper(cls): """ def __init__(self, *args, **kwargs): + """Construct the deprecated class and emit a deprecation warning.""" message = f"The class {cls.__name__} is depreciated and will be removed in future versions!" if msg is not None: message += f" {msg}" diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py index 30900c1f..25ace74d 100644 --- a/OMPython/model_execution.py +++ b/OMPython/model_execution.py @@ -120,6 +120,19 @@ def __init__( timeout: Optional[float] = None, model_name: Optional[str] = None, ) -> None: + """Initialize a model execution configuration. + + Args: + runpath: Directory in which the compiled model executable is located. + cmd_prefix: Command prefix to invoke the executable (e.g. for docker or WSL). + cmd_local: Whether the executable is run on the local machine. + cmd_windows: Whether the executable targets Windows. + timeout: Execution timeout in seconds; defaults to MODEL_EXECUTION_TIMEOUT. + model_name: Name of the model to execute. + + Raises: + ModelExecutionException: If ``model_name`` is None. + """ if model_name is None: raise ModelExecutionException("Missing model name!") diff --git a/OMPython/modelica_doe_abc.py b/OMPython/modelica_doe_abc.py index 062f8833..350711bb 100644 --- a/OMPython/modelica_doe_abc.py +++ b/OMPython/modelica_doe_abc.py @@ -291,6 +291,7 @@ def simulate( raise ModelicaSystemError("Missing Doe Summary!") def worker(worker_id, task_queue): + """Run simulations taken from ``task_queue`` until it is empty.""" while True: try: # Get the next task from the queue diff --git a/OMPython/modelica_doe_omc.py b/OMPython/modelica_doe_omc.py index f8f95030..cb53abe3 100644 --- a/OMPython/modelica_doe_omc.py +++ b/OMPython/modelica_doe_omc.py @@ -45,7 +45,17 @@ def __init__( resultpath: Optional[str | os.PathLike] = None, parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, ) -> None: + """Initialize a DoE run based on a ModelicaSystemOMC model. + Args: + mod: The (configured) ModelicaSystemOMC instance to run the DoE with. + simargs: Simulation arguments passed to each model run. + resultpath: Directory for the DoE results. + parameters: Dictionary of structural parameters to vary. + + Raises: + ModelicaSystemError: If ``mod`` is not a ModelicaSystemOMC instance. + """ if not isinstance(mod, ModelicaSystemOMC): raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") @@ -62,6 +72,20 @@ def _prepare_structure_parameters( pc_structure: Tuple, param_structure: dict[str, list[str] | list[int] | list[float]], ) -> dict[str, str | int | float]: + """Set structural parameters, rebuild the model and report their values. + + Args: + idx_pc_structure: Index of the current parameter combination (run). + pc_structure: Tuple of parameter values for this combination. + param_structure: Mapping of parameter names to their possible values. + + Returns: + Dictionary of the parameter names to the values applied for this run. + + Raises: + ModelicaSystemError: If a model executable has no OMC backend or a + structural parameter cannot be set. + """ build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" build_dir.mkdir() self._mod.setWorkDirectory(work_directory=build_dir) diff --git a/OMPython/modelica_doe_runner.py b/OMPython/modelica_doe_runner.py index 6efc4681..bdac1b31 100644 --- a/OMPython/modelica_doe_runner.py +++ b/OMPython/modelica_doe_runner.py @@ -38,6 +38,17 @@ def __init__( resultpath: Optional[str | os.PathLike] = None, parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, ) -> None: + """Initialize a DoE run based on a pre-compiled model binary. + + Args: + mod: The ModelicaSystemRunner instance used to execute the model. + simargs: Simulation arguments passed to each model run. + resultpath: Directory for the DoE results. + parameters: Dictionary of structural parameters to vary. + + Raises: + ModelicaSystemError: If ``mod`` is not a ModelicaSystemABC instance. + """ if not isinstance(mod, ModelicaSystemABC): raise ModelicaSystemError(f"Invalid definition for ModelicaSystem*: {type(mod)}!") @@ -54,6 +65,22 @@ def _prepare_structure_parameters( pc_structure: Tuple, param_structure: dict[str, list[str] | list[int] | list[float]], ) -> dict[str, str | int | float]: + """Apply the structural parameter combination for one DoE run. + + As the runner uses a pre-compiled model binary, structural parameters + cannot be set. + + Args: + idx_pc_structure: Index of the current parameter combination (run). + pc_structure: Tuple of parameter values for this combination. + param_structure: Mapping of parameter names to their possible values. + + Returns: + An empty dictionary, as no structural parameters can be applied. + + Raises: + ModelicaSystemError: If ``param_structure`` is not empty. + """ if len(param_structure.keys()) > 0: raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " "pre-compiled binary of model.") diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 41a67205..1cb37280 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -209,6 +209,18 @@ def check_model_executable(self): raise ModelicaSystemError("Model executable not working!") def _xmlparse(self, xml_file: OMPathABC): + """Parse a model initialization XML file. + + Reads the ``*_init.xml`` written by the model executable, extracting the + default experiment settings and all scalar variables (parameters, + continuous variables, inputs and outputs) into the model attributes. + + Args: + xml_file: Path to the initialization XML file. + + Raises: + ModelicaSystemError: If the XML file does not exist or cannot be parsed. + """ if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") diff --git a/OMPython/modelica_system_omc.py b/OMPython/modelica_system_omc.py index c4a441c6..48e831b4 100644 --- a/OMPython/modelica_system_omc.py +++ b/OMPython/modelica_system_omc.py @@ -164,11 +164,27 @@ def set_command_line_options(self, command_line_option: str): self.sendExpression(expr=expr, parsed=False) def _loadFile(self, fileName: OMPathABC): + """Load a Modelica file via the OMC ``loadFile`` call. + + Args: + fileName: Path to the ``.mo`` file to load. + """ # load file self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') # for loading file/package, loading model and building model def _loadLibrary(self, libraries: list): + """Load a list of Modelica libraries or files. + + Each element can be a library name (e.g. ``"Modelica"``), a ``.mo`` + path, or a ``(name, version)`` tuple. + + Args: + libraries: List of libraries/files to load. + + Raises: + ModelicaSystemError: If an element has an unsupported type. + """ # load Modelica standard libraries or Modelica files if needed for element in libraries: if element is not None: @@ -192,6 +208,18 @@ def _loadLibrary(self, libraries: list): '2)[("Modelica","3.2.3"), "PowerSystems"]\n') def buildModel(self, variableFilter: Optional[str] = None): + """Build (translate) the model via OMC ``buildModel``. + + The build result is validated against the produced model executable and + initialization XML file. + + Args: + variableFilter: Filter for variables to include in the model; + falls back to the instance filter or ``".*"`` if not given. + + Raises: + ModelicaSystemError: If the model executable or its init XML is missing. + """ filter_def: Optional[str] = None if variableFilter is not None: filter_def = variableFilter @@ -241,6 +269,17 @@ def _requestApi( properties: Optional[str] = None, raise_on_error: bool = True, ) -> Any: + """Send a generic OMC API call. + + Args: + apiName: Name of the OMC API function to call. + entity: First argument of the API call (e.g. model name or file). + properties: Second argument of the API call. + raise_on_error: Whether to raise if OMC reports an error. + + Returns: + The parsed result of the API call. + """ if entity is not None and properties is not None: expr = f'{apiName}({entity}, {properties})' elif entity is not None and properties is None: @@ -283,6 +322,7 @@ def getContinuousFinal( raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") def get_continuous_solution(name_list: list[str]) -> None: + """Update the continuous variables with their final simulation values.""" for name in name_list: if name in self._continuous: value = self.getSolutions(name) @@ -379,6 +419,7 @@ def getOutputsFinal( raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") def get_outputs_solution(name_list: list[str]) -> None: + """Update the output variables with their final simulation values.""" for name in name_list: if name in self._outputs: value = self.getSolutions(name) diff --git a/OMPython/modelica_system_runner.py b/OMPython/modelica_system_runner.py index 6eb753ae..a37b7ae2 100644 --- a/OMPython/modelica_system_runner.py +++ b/OMPython/modelica_system_runner.py @@ -25,6 +25,9 @@ class ModelicaSystemRunner(ModelicaSystemABC): """ Class to simulate a Modelica model using a pre-compiled model binary. + + Executes a pre-compiled Modelica executable and reads its initialization XML + file without requiring an active OpenModelica Compiler (OMC) server connection. """ def __init__( @@ -32,6 +35,17 @@ def __init__( work_directory: Optional[str | os.PathLike] = None, session: Optional[OMSessionABC] = None, ) -> None: + """Initialize ModelicaSystemRunner. + + Args: + work_directory: Directory containing the compiled model executable and + initialization XML file. If unspecified, a temporary directory is created. + session: An instance of OMSessionRunner. If unspecified, a new + OMSessionRunner is created. + + Raises: + ModelicaSystemError: If the provided session is not an OMSessionRunner. + """ if session is None: session = OMSessionRunner() @@ -48,13 +62,20 @@ def setup( model_name: Optional[str] = None, variable_filter: Optional[str] = None, ) -> None: - """ - Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists - within the working directory. At least two files are needed: + """Set up the runner for a pre-compiled model. + + Expects the model files to exist within the working directory: + * Model executable ('' or '.exe'; on Windows, + optionally '.bat') + * Model initialization file ('_init.xml') + + Args: + model_name: The name of the model to execute. + variable_filter: Optional regex pattern for filtering result variables. - * model executable (as '' or '.exe'; in case of Windows additional '.bat' - is expected to evaluate the path to needed dlls - * the model initialization file (as '_init.xml') + Raises: + ModelicaSystemError: If the instance already has a model configured, + if model_name is missing, or if the model binary/XML is invalid. """ if self._model_name is not None: diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py index d19dae57..e9109511 100644 --- a/OMPython/om_session_abc.py +++ b/OMPython/om_session_abc.py @@ -38,6 +38,7 @@ class _OMPathCompatibility(pathlib.Path): # modified copy of pathlib.Path.__new__() definition def __new__(cls, *args, **kwargs): + """Modified copy of ``pathlib.Path.__new__`` for Python < 3.12.""" logger.warning("Python < 3.12 - using a version of class OMCPath " "based on pathlib.Path for local usage only.") @@ -79,6 +80,12 @@ class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ def __init__(self, *path, session: OMSessionABC) -> None: + """Initialize the OMPath with the owning session. + + Args: + *path: Path segments as in pathlib.PurePosixPath. + session: The session used for filesystem-related operations. + """ super().__init__(*path) self._session = session @@ -195,6 +202,11 @@ class PostInitCaller(type): """ def __call__(cls, *args, **kwargs): + """Call the class and trigger ``__post_init__`` of all bases. + + Invokes ``type.__call__`` and then automatically runs every + ``__post_init__`` up the MRO. + """ obj = type.__call__(cls, *args, **kwargs) obj.__post_init__() return obj @@ -298,6 +310,14 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC @staticmethod def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + """Create a new unique temporary directory below ``tempdir_base``. + + Args: + tempdir_base: Base directory for the new temporary directory. + + Returns: + An OMPathABC pointing to the created temporary directory. + """ names = [str(uuid.uuid4()) for _ in range(100)] tempdir: Optional[OMPathABC] = None diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 1d021e2c..691b1c68 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -220,8 +220,11 @@ def __init__( timeout: Optional[float] = None, **kwargs, ) -> None: - """ - Initialisation for OMCSession + """Initialize an OMC session base. + + Args: + timeout: Communication timeout in seconds with the OMC server. + **kwargs: Extra arguments forwarded to base classes. """ super().__init__(timeout=timeout) @@ -276,6 +279,11 @@ def __post_init__(self) -> None: self._omc_zmq = omc def __del__(self): + """Shut down the OMC server. + + Sends ``quit()`` to OMC, closes the log file and terminates (or kills) + the OMC process if it did not exit on its own. + """ if isinstance(self._omc_zmq, zmq.Socket): try: self.sendExpression(expr="quit()") @@ -553,6 +561,11 @@ def get_log(self) -> str: return log def _get_portfile_path(self) -> Optional[pathlib.Path]: + """Extract the OMC port file path from the OMC session log. + + Returns: + Path to the OMC port file, or None if not found in the log. + """ omc_log = self.get_log() portfile = self._re_portfile_path.findall(string=omc_log) @@ -569,18 +582,34 @@ class DockerPopen: Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). """ - def __init__(self, pid): + def __init__(self, pid: int) -> None: + """Initialize DockerPopen with the process ID. + + Args: + pid: The process ID (PID) of the container process. + """ self.pid = pid self.process = psutil.Process(pid) self.returncode = 0 - def poll(self): + def poll(self) -> Optional[bool]: + """Check if the process is still running. + + Returns: + None if still running, True if terminated. + """ return None if self.process.is_running() else True - def kill(self): + def kill(self) -> None: + """Send SIGKILL to the process.""" return os.kill(pid=self.pid, signal=signal.SIGKILL) - def wait(self, timeout): + def wait(self, timeout: Optional[float] = None) -> None: + """Wait for the process to terminate. + + Args: + timeout: Maximum wait time in seconds. + """ try: self.process.wait(timeout=timeout) except psutil.TimeoutExpired: @@ -602,6 +631,17 @@ def __init__( dockerNetwork: Optional[str] = None, port: Optional[int] = None, ) -> None: + """Initialize Docker-based OMC session base. + + Args: + timeout: Timeout in seconds for OMC communication. + docker: Docker image name to run. + dockerContainer: Existing container ID to connect to. + dockerExtraArgs: Additional arguments passed to the docker command. + dockerOpenModelicaPath: Path to the OMC binary inside the container. + dockerNetwork: Docker network to connect to. + port: Port to expose/bind for ZMQ communication. + """ super().__init__(timeout=timeout) if dockerExtraArgs is None: @@ -627,6 +667,21 @@ def __init__( self._cmd_prefix = self.model_execution_prefix() def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: + """Find the OMC process running inside the Docker container. + + Polls ``docker top`` until a process matching the session random + string appears. + + Args: + docker_cid: Docker container ID. + + Returns: + A DockerPopen for the OMC process. + + Raises: + OMSessionException: If OMC does not start within the timeout. + NotImplementedError: On win32 (docker sessions are unsupported). + """ if sys.platform == 'win32': raise NotImplementedError("Docker not supported on win32!") @@ -657,6 +712,7 @@ def _docker_omc_start( docker_cid: Optional[str] = None, omc_port: Optional[int] = None, ) -> Tuple[subprocess.Popen, DockerPopen, str]: + """Start the OMC server in a Docker container (abstract).""" pass @staticmethod @@ -674,6 +730,19 @@ def _omc_port_get( self, docker_cid: str, ) -> str: + """Determine the port on which the OMC server listens. + + Polls the OMC port file inside the container until the port is known. + + Args: + docker_cid: Docker container ID. + + Returns: + The server address (port) string of the OMC server. + + Raises: + OMSessionException: If the OMC server does not start within the timeout. + """ port = None if not isinstance(docker_cid, str): @@ -756,7 +825,16 @@ def __init__( dockerNetwork: Optional[str] = None, port: Optional[int] = None, ) -> None: - + """Start a new Docker container and launch an OMC server inside it. + + Args: + timeout: Timeout in seconds for OMC communication. + docker: Docker image name (defaults to standard OpenModelica image). + dockerExtraArgs: Additional arguments passed to `docker run`. + dockerOpenModelicaPath: Path to `omc` inside the container. + dockerNetwork: Optional Docker network name. + port: Interactive ZMQ port number. + """ super().__init__( timeout=timeout, docker=docker, @@ -767,7 +845,7 @@ def __init__( ) def __del__(self) -> None: - + """Stop the OMC process in the Docker container and clean up.""" if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): try: self._docker_process.wait(timeout=2.0) @@ -844,7 +922,19 @@ def _docker_omc_start( docker_cid: Optional[str] = None, omc_port: Optional[int] = None, ) -> Tuple[subprocess.Popen, DockerPopen, str]: + """Start the OMC server inside a Docker container. + + Args: + docker_image: Docker image to use. + docker_cid: Optional predefined container ID. + omc_port: Optional interactive ZMQ port. + Returns: + A tuple of (docker process, OMC process handle, port string). + + Raises: + OMSessionException: If the image name is missing or the server fails to start. + """ if not isinstance(docker_image, str): raise OMSessionException("A docker image name must be provided!") @@ -911,7 +1001,16 @@ def __init__( dockerNetwork: Optional[str] = None, port: Optional[int] = None, ) -> None: - + """Connect to an existing running Docker container and launch OMC inside it. + + Args: + timeout: Timeout in seconds for OMC communication. + dockerContainer: Container ID or name of the running container. + dockerExtraArgs: Additional arguments passed to `docker exec`. + dockerOpenModelicaPath: Path to `omc` inside the container. + dockerNetwork: Optional Docker network name. + port: Interactive ZMQ port number. + """ super().__init__( timeout=timeout, dockerContainer=dockerContainer, @@ -922,7 +1021,10 @@ def __init__( ) def __del__(self) -> None: + """Clean up the OMC process in the connected container. + Sends ``quit()`` and terminates the docker process/cid. + """ super().__del__() # docker container ID was provided - do NOT kill the docker process! @@ -967,7 +1069,19 @@ def _docker_omc_start( docker_cid: Optional[str] = None, omc_port: Optional[int] = None, ) -> Tuple[subprocess.Popen, DockerPopen, str]: + """Start the OMC server inside an existing Docker container. + + Args: + docker_image: Not used; kept for the abstract API. + docker_cid: ID (or name) of the running container. + omc_port: Optional interactive ZMQ port. + + Returns: + A tuple of (omc process, docker process handle, container ID). + Raises: + OMSessionException: If no container ID is given or OMC fails to start. + """ if not isinstance(docker_cid, str): raise OMSessionException("A docker container ID must be provided!") @@ -1007,7 +1121,13 @@ def __init__( timeout: Optional[float] = None, omhome: Optional[str | os.PathLike] = None, ) -> None: + """Start and connect to a local OMC server instance via ZeroMQ. + Args: + timeout: Communication timeout in seconds with the OMC server. + omhome: Path to the OpenModelica installation directory. If None, + it is resolved from OPENMODELICAHOME or the system PATH. + """ super().__init__(timeout=timeout) self.model_execution_local = True @@ -1021,6 +1141,18 @@ def __init__( @staticmethod def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: + """Resolve the OpenModelica installation directory. + + Args: + omhome: Explicit OpenModelica home path. If None, ``OPENMODELICAHOME`` + or the location of ``omc`` on PATH is used. + + Returns: + Path to the OpenModelica installation directory. + + Raises: + OMSessionException: If OpenModelica cannot be located. + """ # use the provided path if omhome is not None: return pathlib.Path(omhome) @@ -1038,6 +1170,11 @@ def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: raise OMSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") def _omc_process_get(self) -> subprocess.Popen: + """Start the local ``omc`` process in interactive ZMQ mode. + + Returns: + The Popen handle of the started OMC process. + """ my_env = os.environ.copy() my_env["PATH"] = (self._omhome / "bin").as_posix() + os.pathsep + my_env["PATH"] @@ -1054,6 +1191,14 @@ def _omc_process_get(self) -> subprocess.Popen: return omc_process def _omc_port_get(self) -> str: + """Read the ZMQ port of the local OMC server from its port file. + + Returns: + The port string the OMC server listens on. + + Raises: + OMSessionException: If the server does not start within the timeout. + """ port = None # See if the omc server is running @@ -1088,6 +1233,12 @@ def __init__( omc_port: str, timeout: Optional[float] = None, ) -> None: + """Connect to an already running OMC server on a given port. + + Args: + omc_port: The ZMQ port (address string) of the running OMC server. + timeout: Communication timeout in seconds with the OMC server. + """ super().__init__(timeout=timeout) self._omc_port = omc_port @@ -1104,9 +1255,15 @@ def __init__( wsl_distribution: Optional[str] = None, wsl_user: Optional[str] = None, ) -> None: + """Start an OMC server process under Windows Subsystem for Linux (WSL). + Args: + timeout: Communication timeout in seconds with the OMC server. + wsl_omc: Command or path of ``omc`` inside the WSL distribution. + wsl_distribution: Optional WSL distribution name to use. + wsl_user: Optional WSL user to run as. + """ super().__init__(timeout=timeout) - # where to find OpenModelica self._wsl_omc = wsl_omc # store WSL distribution and user @@ -1136,6 +1293,11 @@ def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: return wsl_cmd def _omc_process_get(self) -> subprocess.Popen: + """Start the ``omc`` process inside the WSL distribution. + + Returns: + The Popen handle of the started OMC process. + """ my_env = os.environ.copy() omc_command = self.model_execution_prefix() + [ @@ -1152,6 +1314,14 @@ def _omc_process_get(self) -> subprocess.Popen: return omc_process def _omc_port_get(self) -> str: + """Read the ZMQ port of the WSL OMC server from its port file. + + Returns: + The port string the OMC server listens on. + + Raises: + OMSessionException: If the server does not start within the timeout. + """ port = None # See if the omc server is running diff --git a/OMPython/om_session_runner.py b/OMPython/om_session_runner.py index 317fd863..b6a7028c 100644 --- a/OMPython/om_session_runner.py +++ b/OMPython/om_session_runner.py @@ -37,6 +37,11 @@ class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): """ def _path(self) -> pathlib.Path: + """Convert the POSIX path to a local pathlib.Path. + + Returns: + The local path corresponding to the POSIX path. + """ return pathlib.Path(self.as_posix()) class _OMPathRunnerLocal(OMPathRunnerABC): @@ -323,6 +328,18 @@ def __init__( cmd_prefix: Optional[list[str]] = None, model_execution_local: bool = True, ) -> None: + """Initialize a runner-based OMC session without an OMC server. + + Args: + ompath_runner: OMPath implementation class used for this session. + timeout: Timeout in seconds for operations. + version: Version string reported as the OpenModelica version. + cmd_prefix: Command prefix for model execution. + model_execution_local: Whether the model executes on the local machine. + + Raises: + OMSessionException: If ``ompath_runner`` is not an OMPathRunnerABC subclass. + """ super().__init__(timeout=timeout) self._version = version @@ -348,6 +365,15 @@ def __init__( cmd_prefix: Optional[list[str]] = None, model_execution_local: bool = True, ) -> None: + """Initialize an OMSessionRunner session without an OMC server. + + Args: + ompath_runner: OMPath implementation class used for this session. + timeout: Timeout in seconds for operations. + version: Version string reported as the OpenModelica version. + cmd_prefix: Command prefix for model execution. + model_execution_local: Whether the model executes on the local machine. + """ super().__init__( ompath_runner=ompath_runner, timeout=timeout, @@ -397,4 +423,9 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self._tempdir(tempdir_base=tempdir_base) def sendExpression(self, expr: str, parsed: bool = True, raise_on_error: bool = True) -> Any: + """Sending expressions is not supported for a runner-based session. + + Raises: + OMSessionException: Always, as there is no OMC server to talk to. + """ raise OMSessionException(f"{self.__class__.__name__} does not uses an OMC server!") From 488aed2bf2ad1aed328ed6cdd108c24592f4d298 Mon Sep 17 00:00:00 2001 From: Adeel Asghar Date: Mon, 28 Sep 2026 14:02:06 +0200 Subject: [PATCH 2/3] Use self.process.kill() --- OMPython/om_session_omc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 691b1c68..69204698 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -602,7 +602,7 @@ def poll(self) -> Optional[bool]: def kill(self) -> None: """Send SIGKILL to the process.""" - return os.kill(pid=self.pid, signal=signal.SIGKILL) + self.process.kill() def wait(self, timeout: Optional[float] = None) -> None: """Wait for the process to terminate. From 2d3a031b82b6c0ae34d5e7d5452b59d37aeeff26 Mon Sep 17 00:00:00 2001 From: Adeel Asghar Date: Mon, 28 Sep 2026 14:05:43 +0200 Subject: [PATCH 3/3] Remove signal import --- OMPython/om_session_omc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 69204698..e1d0e461 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -14,7 +14,6 @@ import platform import re import shutil -import signal import subprocess import sys import tempfile